Skip to content

Commit 2e96384

Browse files
committed
fix(storage): allow digits in SQL table names and return errors instead of panicking
escapeForTableName's allow-list omitted digits, so a network, channel or namespace containing one — "channel1" being about as common as it gets — was rejected. Worse, the failure arrived as a panic: the only caller was the Must* formatter, and nothing on the path from store construction recovers, so a routine configuration value crashed the node. Allow digits in the table name parameters and validate the composed identifier instead of the fragment, so a leading digit is accepted behind a prefix and rejected only when there is none. Return errors along the whole chain rather than panicking, and drop the Must* calls from it. Report every invalid short-code override rather than only the first. The prefix and the params are shared by all seventeen names, so a problem there is validated once up front and reported once; overrides apply to a single key each, so those are collected and joined — otherwise an operator with two bad entries under token.storage.tableNames fixes one, restarts, and hits the next. Add FuzzGetTableNamesNoPanic, covering both properties (never panics; either errors or emits a legal SQL identifier) and wired into nightly-fuzz.yml. The table prefix is deliberately not fuzzed: every accepted prefix is memoised forever in the package-level formatter cache, which has no eviction, so fuzzing it would grow the worker heap for the whole four-hour nightly run. The prefix edge cases are table-driven tests instead. Fixes #2034 Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent df6ef1a commit 2e96384

12 files changed

Lines changed: 649 additions & 68 deletions

File tree

.github/workflows/nightly-fuzz.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ jobs:
105105
- name: fabtoken-owner-verifier
106106
pkg: ./token/core/fabtoken/v1/driver
107107
func: FuzzOwnerVerifierNoPanic
108+
- name: storage-sql-table-names
109+
pkg: ./token/services/storage/db/sql/common
110+
func: FuzzGetTableNamesNoPanic
108111

109112
steps:
110113
- name: Checkout code

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,14 @@ to `<params>_<short_code>` (params still apply when provided; short-code overrid
693693
> will cause the node to look for tables under different names. Make sure the underlying
694694
> tables already exist under the unprefixed names before enabling this flag.
695695

696+
> **Note:** `<params>` is the TMS identity (network, channel, namespace). `_`, `-` and `.`
697+
> in those values are escaped to `__`, `_d` and `_f`; letters, digits and underscores pass
698+
> through unchanged, so a channel named `channel1` is fine. The composed name must still be
699+
> a valid SQL identifier — it cannot start with a digit, which is only reachable with
700+
> `skipPrefix: true` and a network name starting with a digit. Any other character is a
701+
> configuration error and is reported as such, not a crash. See
702+
> [Table Name Customisation](services/storage.md#table-name-customisation).
703+
696704
---
697705

698706
### Optional: token.tms.<name>.services.storage.cleanup

docs/services/storage.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ Two independent options let you control how the final name is composed:
7474
| Override individual short codes | `token.storage.tableNames` | _(none)_ |
7575
| Skip the FSC-generated prefix entirely | `token.storage.skipPrefix` | `false` |
7676

77+
### Allowed characters
78+
79+
The `TableNameParams` are the TMS identity — **network**, **channel** and **namespace**
80+
and they become part of every table name, so they must be reducible to a legal SQL
81+
identifier. The characters that are common in Fabric names but illegal in an identifier
82+
are escaped rather than rejected:
83+
84+
| Character in a param | Becomes |
85+
|---|---|
86+
| `_` | `__` |
87+
| `-` | `_d` |
88+
| `.` | `_f` |
89+
90+
Letters, digits and underscores pass through as-is, so a channel called `channel1` or
91+
`mychannel01` is fine. The **final composed name** — prefix included — must still be a
92+
valid identifier: only letters, digits and underscores, and it cannot **start** with a
93+
digit (unquoted identifiers in both SQLite and PostgreSQL must start with a letter or an
94+
underscore). Since the prefix comes first and can never start with a digit, this only
95+
bites when `skipPrefix` is enabled *and* the network name starts with a digit.
96+
97+
Anything else — a space, `!`, `/`, `;`, … — is a **configuration error**: store
98+
construction returns an error naming the offending value, it does not crash the node.
99+
A bad prefix or param breaks every table name in the same way and is reported once;
100+
invalid [short-code overrides](#overriding-short-codes-tablenames) are per-key, so **all**
101+
of them are reported together and you do not have to fix them one restart at a time.
102+
103+
The `TablePrefix` is stricter: only letters and underscores, at most 100 characters. It is
104+
lower-cased before use.
105+
77106
### Overriding short codes (`tableNames`)
78107

79108
You can replace any short code globally (for all TMS instances on the node) via the

token/services/storage/db/sql/common/init.go

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,19 @@ package common
88

99
import (
1010
"github.com/LFDT-Panurus/panurus/token/services/logging"
11+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
1112
"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/driver/common"
1213
)
1314

14-
const defaultPrefix = "fsc"
15+
const (
16+
defaultPrefix = "fsc"
17+
// sharedNameProbe is the short code used to validate the part of a table name
18+
// that every short code shares: the prefix and the params. It is a real
19+
// canonical short code rather than a synthetic one, so a failure reports a name
20+
// the operator actually recognises, and it is never read from the overrides, so
21+
// the check does not depend on them.
22+
sharedNameProbe = "movements"
23+
)
1524

1625
var (
1726
logger = logging.MustGetLogger()
@@ -92,7 +101,7 @@ func GetTableNamesWithOverrides(prefix string, overrides TableNamesConfig, param
92101
return TableNames{}, err
93102
}
94103

95-
return buildTableNames(prefix, params, overrides, nc.MustFormat)
104+
return buildTableNames(prefix, params, overrides, nc.Format)
96105
}
97106

98107
// GetTableNamesWithOverridesSkipPrefix is like GetTableNamesWithOverrides but
@@ -103,48 +112,76 @@ func GetTableNamesWithOverridesSkipPrefix(prefix string, overrides TableNamesCon
103112
return TableNames{}, err
104113
}
105114

106-
return buildTableNames(prefix, params, overrides, nc.MustFormatWithoutPrefix)
115+
return buildTableNames(prefix, params, overrides, nc.FormatWithoutPrefix)
107116
}
108117

109118
// buildTableNames constructs a TableNames value by applying format to each
110119
// canonical short code (after resolving any override), forwarding params.
111-
func buildTableNames(prefix string, params []string, overrides TableNamesConfig, format func(string, ...string) string) (TableNames, error) {
120+
//
121+
// format is the error-returning formatter on purpose: an illegal character in
122+
// the prefix, in a short-code override or in one of the params (network,
123+
// channel, namespace) must surface as a configuration error to the caller, not
124+
// as a panic at store-construction time.
125+
func buildTableNames(prefix string, params []string, overrides TableNamesConfig, format func(string, ...string) (string, error)) (TableNames, error) {
112126
// Warn on unknown override keys before applying any overrides.
113127
for k := range overrides {
114128
if _, ok := knownShortCodes[k]; !ok {
115129
logger.Warnf("unknown table name override key %q — ignored", k)
116130
}
117131
}
118132

119-
// resolve returns the effective short code: the override value if present,
120-
// otherwise the canonical default.
121-
resolve := func(defaultCode string) string {
133+
// The prefix and the params are shared by every short code, so a problem there
134+
// breaks all of the names in the same way. Validate that shared part once, with
135+
// a short code that is always a legal identifier fragment, so it is reported
136+
// once instead of repeated for every table.
137+
if _, err := format(sharedNameProbe, params...); err != nil {
138+
return TableNames{}, errors.WithMessage(err, "invalid table name prefix or parameters")
139+
}
140+
141+
// Past that point a failure can only come from the short code itself, and an
142+
// override is specific to its own key, so collect them all: returning just the
143+
// first would make an operator fix one key, restart, and hit the next.
144+
var errs []error
145+
146+
// name formats the effective short code for defaultCode: the override value
147+
// if present, otherwise the canonical default.
148+
name := func(defaultCode string) string {
149+
code := defaultCode
122150
if v, ok := overrides[defaultCode]; ok {
123-
return v
151+
code = v
152+
}
153+
tableName, err := format(code, params...)
154+
if err != nil {
155+
errs = append(errs, errors.Wrapf(err, "failed to build table name for short code [%s]", code))
124156
}
125157

126-
return defaultCode
158+
return tableName
127159
}
128160

129-
return TableNames{
161+
tableNames := TableNames{
130162
Prefix: prefix,
131163
Params: params,
132-
Movements: format(resolve("movements"), params...),
133-
Transactions: format(resolve("txs"), params...),
134-
TransactionEndorseAck: format(resolve("tx_ends"), params...),
135-
Requests: format(resolve("requests"), params...),
136-
Validations: format(resolve("req_vals"), params...),
137-
Tokens: format(resolve("tokens"), params...),
138-
Ownership: format(resolve("tkn_own"), params...),
139-
Certifications: format(resolve("tkn_crts"), params...),
140-
TokenLocks: format(resolve("tkn_locks"), params...),
141-
PublicParams: format(resolve("public_params"), params...),
142-
Wallets: format(resolve("wallets"), params...),
143-
IdentityConfigurations: format(resolve("id_cfgs"), params...),
144-
IdentityInfo: format(resolve("id_info"), params...),
145-
Signers: format(resolve("id_signers"), params...),
146-
KeyStore: format(resolve("key_store"), params...),
147-
EIDLeases: format(resolve("eid_leases"), params...),
148-
TokenSKICleanups: format(resolve("tkn_ski_cleanups"), params...),
149-
}, nil
164+
Movements: name("movements"),
165+
Transactions: name("txs"),
166+
TransactionEndorseAck: name("tx_ends"),
167+
Requests: name("requests"),
168+
Validations: name("req_vals"),
169+
Tokens: name("tokens"),
170+
Ownership: name("tkn_own"),
171+
Certifications: name("tkn_crts"),
172+
TokenLocks: name("tkn_locks"),
173+
PublicParams: name("public_params"),
174+
Wallets: name("wallets"),
175+
IdentityConfigurations: name("id_cfgs"),
176+
IdentityInfo: name("id_info"),
177+
Signers: name("id_signers"),
178+
KeyStore: name("key_store"),
179+
EIDLeases: name("eid_leases"),
180+
TokenSKICleanups: name("tkn_ski_cleanups"),
181+
}
182+
if err := errors.Join(errs...); err != nil {
183+
return TableNames{}, err
184+
}
185+
186+
return tableNames, nil
150187
}

token/services/storage/db/sql/common/init_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ SPDX-License-Identifier: Apache-2.0
77
package common_test
88

99
import (
10+
"strings"
1011
"testing"
1112

1213
common "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/common"
@@ -243,6 +244,118 @@ func TestGetTableNamesWithOverridesSkipPrefix(t *testing.T) {
243244
assert.Contains(t, got.Transactions, "txs")
244245
}
245246

247+
// TestGetTableNamesWithDigitsInParams is a regression test for
248+
// https://github.com/LFDT-Panurus/panurus/issues/2034: a network, channel or
249+
// namespace name containing a digit — e.g. the very common "channel1" — used to
250+
// panic while building the table names instead of producing a valid name.
251+
func TestGetTableNamesWithDigitsInParams(t *testing.T) {
252+
for _, params := range [][]string{
253+
{"testnetwork", "channel1", "ns"},
254+
{"testnetwork", "testchannel1", "ns"},
255+
{"testnetwork", "mychannel01", "ns"},
256+
{"network1", "channel1", "namespace1"},
257+
{"net0", "ch-1.2", "ns_3"},
258+
} {
259+
t.Run(strings.Join(params, "/"), func(t *testing.T) {
260+
var got common.TableNames
261+
var err error
262+
require.NotPanics(t, func() {
263+
got, err = common.GetTableNames("pfx", params...)
264+
})
265+
require.NoError(t, err)
266+
267+
for _, name := range allTableNames(got) {
268+
assert.Regexp(t, `^[a-zA-Z_][a-zA-Z0-9_]*$`, name)
269+
}
270+
})
271+
}
272+
273+
// Spot-check the exact name produced for the canonical case.
274+
names, err := common.GetTableNames("pfx", "testnetwork", "channel1", "ns")
275+
require.NoError(t, err)
276+
assert.Equal(t, "pfx_testnetwork__channel1__ns_txs", names.Transactions)
277+
278+
// The same must hold when the prefix is skipped.
279+
got, err := common.GetTableNamesWithOverridesSkipPrefix("pfx", nil, "testnetwork", "channel1", "ns")
280+
require.NoError(t, err)
281+
assert.Equal(t, "testnetwork__channel1__ns_txs", got.Transactions)
282+
}
283+
284+
// TestGetTableNamesInvalidParamsReturnError checks that a param that cannot be
285+
// turned into a legal SQL identifier is reported as an error rather than
286+
// crashing the process: this call chain is reachable from ordinary store
287+
// construction and nothing along it recovers.
288+
func TestGetTableNamesInvalidParamsReturnError(t *testing.T) {
289+
for _, params := range [][]string{
290+
{"testnetwork", "channel!"},
291+
{"testnetwork", "channel 1"},
292+
{"testnetwork", "channel;drop table x"},
293+
} {
294+
t.Run(strings.Join(params, "/"), func(t *testing.T) {
295+
require.NotPanics(t, func() {
296+
names, err := common.GetTableNames("pfx", params...)
297+
require.Error(t, err)
298+
assert.Equal(t, common.TableNames{}, names)
299+
})
300+
})
301+
}
302+
303+
// A param starting with a digit is fine behind a prefix, but not when the
304+
// prefix is skipped: an unquoted identifier cannot start with a digit in
305+
// either SQLite or PostgreSQL.
306+
withPrefix, err := common.GetTableNames("pfx", "1network", "channel1")
307+
require.NoError(t, err)
308+
assert.Equal(t, "pfx_1network__channel1_txs", withPrefix.Transactions)
309+
310+
require.NotPanics(t, func() {
311+
names, err := common.GetTableNamesWithOverridesSkipPrefix("pfx", nil, "1network", "channel1")
312+
require.Error(t, err)
313+
assert.Equal(t, common.TableNames{}, names)
314+
})
315+
}
316+
317+
// TestGetTableNamesInvalidOverridesReportEveryKey checks that every invalid
318+
// short-code override is reported, not just the first one. An override applies to
319+
// a single key, so reporting only one would make an operator fix that key, restart
320+
// the node, and hit the next one — once per bad key.
321+
func TestGetTableNamesInvalidOverridesReportEveryKey(t *testing.T) {
322+
overrides := common.TableNamesConfig{
323+
"txs": "bad!name",
324+
"tokens": "worse name",
325+
"wallets": "wrong;name",
326+
}
327+
328+
names, err := common.GetTableNamesWithOverrides("pfx", overrides, "net", "ch", "ns")
329+
require.Error(t, err)
330+
assert.Equal(t, common.TableNames{}, names)
331+
for _, badName := range overrides {
332+
assert.Contains(t, err.Error(), badName,
333+
"every invalid override must be reported, got: %s", err)
334+
}
335+
}
336+
337+
// TestGetTableNamesInvalidParamsReportedOnce checks that a problem in the part
338+
// every table name shares — the prefix and the params — is reported once instead
339+
// of repeated for each of the seventeen names it breaks.
340+
func TestGetTableNamesInvalidParamsReportedOnce(t *testing.T) {
341+
names, err := common.GetTableNames("pfx", "net", "ch!")
342+
require.Error(t, err)
343+
assert.Equal(t, common.TableNames{}, names)
344+
assert.Equal(t, 1, strings.Count(err.Error(), "unsupported chars"),
345+
"a shared failure must be reported once, got: %s", err)
346+
}
347+
348+
// allTableNames returns every generated table name in tn.
349+
func allTableNames(tn common.TableNames) []string {
350+
return []string{
351+
tn.Movements, tn.Transactions, tn.TransactionEndorseAck,
352+
tn.Requests, tn.Validations, tn.Tokens, tn.Ownership,
353+
tn.Certifications, tn.TokenLocks, tn.PublicParams,
354+
tn.Wallets, tn.IdentityConfigurations, tn.IdentityInfo,
355+
tn.Signers, tn.KeyStore, tn.EIDLeases, tn.TokenSKICleanups,
356+
}
357+
}
358+
246359
// TestGetTableNamesWithConfig_SkipPrefixFalse checks that GetTableNamesWithConfig
247360
// behaves identically to GetTableNamesWithOverrides when SkipPrefix is false.
248361
func TestGetTableNamesWithConfig_SkipPrefixFalse(t *testing.T) {

0 commit comments

Comments
 (0)