yamlock 0.3.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +205 -0
- package/README.md +274 -41
- package/bin/yamlock +8 -0
- package/dist/cli/cli.js +489 -79
- package/dist/crypto/decrypt.js +112 -13
- package/dist/crypto/encrypt.js +107 -18
- package/dist/crypto/payload-v2.js +371 -0
- package/dist/crypto/utils.js +65 -14
- package/dist/errors.js +60 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +10 -0
- package/dist/utils/config.js +331 -54
- package/dist/utils/file.js +58 -0
- package/dist/utils/migrate.js +176 -0
- package/dist/utils/path-pattern.js +260 -0
- package/dist/utils/path.js +58 -9
- package/docs/api.md +63 -0
- package/docs/design/path-patterns.md +213 -0
- package/docs/design/payload-v2.md +344 -0
- package/docs/errors.md +72 -0
- package/docs/yaml-behavior.md +52 -0
- package/examples/basic.js +31 -0
- package/examples/docs/ci-cd.md +58 -0
- package/examples/docs/key-rotation.md +65 -0
- package/package.json +19 -7
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { YAMLOCK_ERROR_CODES } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const COMPILED_PATTERN = Symbol('yamlock.compiledPathPattern');
|
|
4
|
+
const ALLOWED_ESCAPES = new Set(['\\', '.', '[', ']', ',', '*']);
|
|
5
|
+
const EMPTY_COMPILED_PATTERNS = Object.freeze([]);
|
|
6
|
+
|
|
7
|
+
const TOKEN_TYPES = Object.freeze({
|
|
8
|
+
ARRAY_INDEX: 'array-index',
|
|
9
|
+
ARRAY_WILDCARD: 'array-wildcard',
|
|
10
|
+
GLOBSTAR: 'globstar',
|
|
11
|
+
OBJECT_KEY: 'object-key',
|
|
12
|
+
OBJECT_WILDCARD: 'object-wildcard'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export class PathPatternSyntaxError extends Error {
|
|
16
|
+
constructor(message, offset = 0) {
|
|
17
|
+
super(`Invalid path pattern at offset ${offset}: ${message}`);
|
|
18
|
+
this.name = 'PathPatternSyntaxError';
|
|
19
|
+
this.code = YAMLOCK_ERROR_CODES.INVALID_PATH_PATTERNS;
|
|
20
|
+
this.offset = offset;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function syntaxError(message, offset) {
|
|
25
|
+
throw new PathPatternSyntaxError(message, offset);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function freezeToken(type, value) {
|
|
29
|
+
return Object.freeze(value === undefined ? { type } : { type, value });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseObjectToken(source, start) {
|
|
33
|
+
let index = start;
|
|
34
|
+
let value = '';
|
|
35
|
+
let hasUnescapedWildcard = false;
|
|
36
|
+
|
|
37
|
+
while (index < source.length) {
|
|
38
|
+
const character = source[index];
|
|
39
|
+
if (character === '.' || character === '[') {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (character === '\\') {
|
|
44
|
+
const escaped = source[index + 1];
|
|
45
|
+
if (escaped === undefined) {
|
|
46
|
+
syntaxError('A trailing backslash is not allowed.', index);
|
|
47
|
+
}
|
|
48
|
+
if (!ALLOWED_ESCAPES.has(escaped)) {
|
|
49
|
+
syntaxError(`Unsupported escape \\${escaped}.`, index);
|
|
50
|
+
}
|
|
51
|
+
value += escaped;
|
|
52
|
+
index += 2;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (character === ']') {
|
|
57
|
+
syntaxError('Unexpected closing bracket.', index);
|
|
58
|
+
}
|
|
59
|
+
if (character === ',') {
|
|
60
|
+
syntaxError('Literal commas must be escaped.', index);
|
|
61
|
+
}
|
|
62
|
+
if (character === '*') {
|
|
63
|
+
hasUnescapedWildcard = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
value += character;
|
|
67
|
+
index += 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (index === start) {
|
|
71
|
+
syntaxError('Object path segments must not be empty.', start);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawSegment = source.slice(start, index);
|
|
75
|
+
if (hasUnescapedWildcard) {
|
|
76
|
+
if (rawSegment === '*') {
|
|
77
|
+
return {
|
|
78
|
+
nextIndex: index,
|
|
79
|
+
token: freezeToken(TOKEN_TYPES.OBJECT_WILDCARD)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (rawSegment === '**') {
|
|
83
|
+
return {
|
|
84
|
+
nextIndex: index,
|
|
85
|
+
token: freezeToken(TOKEN_TYPES.GLOBSTAR)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
syntaxError('Wildcards must occupy a complete object segment.', start);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
nextIndex: index,
|
|
93
|
+
token: freezeToken(TOKEN_TYPES.OBJECT_KEY, value)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseArrayToken(source, start) {
|
|
98
|
+
const closingBracket = source.indexOf(']', start + 1);
|
|
99
|
+
if (closingBracket === -1) {
|
|
100
|
+
syntaxError('Array segments require a closing bracket.', start);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const content = source.slice(start + 1, closingBracket);
|
|
104
|
+
if (content === '*') {
|
|
105
|
+
return {
|
|
106
|
+
nextIndex: closingBracket + 1,
|
|
107
|
+
token: freezeToken(TOKEN_TYPES.ARRAY_WILDCARD)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!/^(?:0|[1-9]\d*)$/u.test(content)) {
|
|
112
|
+
syntaxError('Array indexes must be canonical non-negative integers or *.', start);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const value = Number(content);
|
|
116
|
+
if (!Number.isSafeInteger(value)) {
|
|
117
|
+
syntaxError('Array indexes must be safe integers.', start);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
nextIndex: closingBracket + 1,
|
|
122
|
+
token: freezeToken(TOKEN_TYPES.ARRAY_INDEX, value)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compileNormalizedPattern(source) {
|
|
127
|
+
const tokens = [];
|
|
128
|
+
let index = 0;
|
|
129
|
+
|
|
130
|
+
while (index < source.length) {
|
|
131
|
+
const parsed = source[index] === '['
|
|
132
|
+
? parseArrayToken(source, index)
|
|
133
|
+
: parseObjectToken(source, index);
|
|
134
|
+
tokens.push(parsed.token);
|
|
135
|
+
index = parsed.nextIndex;
|
|
136
|
+
|
|
137
|
+
if (index === source.length) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (source[index] === '[') {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (source[index] !== '.') {
|
|
146
|
+
syntaxError(`Unexpected character ${source[index]}.`, index);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
index += 1;
|
|
150
|
+
if (index === source.length) {
|
|
151
|
+
syntaxError('A pattern must not end with a dot.', index - 1);
|
|
152
|
+
}
|
|
153
|
+
if (source[index] === '[') {
|
|
154
|
+
syntaxError('Array segments must not follow a dot.', index);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const compiled = {
|
|
159
|
+
source,
|
|
160
|
+
tokens: Object.freeze(tokens)
|
|
161
|
+
};
|
|
162
|
+
Object.defineProperty(compiled, COMPILED_PATTERN, { value: true });
|
|
163
|
+
return Object.freeze(compiled);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function compilePathPattern(pattern) {
|
|
167
|
+
if (typeof pattern !== 'string') {
|
|
168
|
+
syntaxError('A pattern must be a string.', 0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const source = pattern.trim();
|
|
172
|
+
if (source.length === 0) {
|
|
173
|
+
syntaxError('A pattern must not be empty.', 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return compileNormalizedPattern(source);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function compilePathPatterns(patterns) {
|
|
180
|
+
if (patterns === undefined || (Array.isArray(patterns) && patterns.length === 0)) {
|
|
181
|
+
return EMPTY_COMPILED_PATTERNS;
|
|
182
|
+
}
|
|
183
|
+
if (!Array.isArray(patterns)) {
|
|
184
|
+
syntaxError('pathPatterns must be an array of non-empty strings.', 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const compiledBySource = new Map();
|
|
188
|
+
for (const pattern of patterns) {
|
|
189
|
+
const compiled = compilePathPattern(pattern);
|
|
190
|
+
compiledBySource.set(compiled.source, compiled);
|
|
191
|
+
}
|
|
192
|
+
return Object.freeze([...compiledBySource.values()]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validatePathSegments(segments) {
|
|
196
|
+
if (!Array.isArray(segments)) {
|
|
197
|
+
throw new TypeError('Path segments must be an array.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const invalid = segments.some((segment) => (
|
|
201
|
+
(typeof segment !== 'string' || segment.length === 0) &&
|
|
202
|
+
(!Number.isInteger(segment) || segment < 0)
|
|
203
|
+
));
|
|
204
|
+
if (invalid) {
|
|
205
|
+
throw new TypeError('Path segments must be non-empty strings or non-negative integers.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function tokenMatchesSegment(token, segment) {
|
|
210
|
+
switch (token.type) {
|
|
211
|
+
case TOKEN_TYPES.OBJECT_KEY:
|
|
212
|
+
return typeof segment === 'string' && segment === token.value;
|
|
213
|
+
case TOKEN_TYPES.ARRAY_INDEX:
|
|
214
|
+
return typeof segment === 'number' && segment === token.value;
|
|
215
|
+
case TOKEN_TYPES.OBJECT_WILDCARD:
|
|
216
|
+
return typeof segment === 'string';
|
|
217
|
+
case TOKEN_TYPES.ARRAY_WILDCARD:
|
|
218
|
+
return typeof segment === 'number';
|
|
219
|
+
default:
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function matchesPathPattern(compiledPattern, segments) {
|
|
225
|
+
if (!compiledPattern?.[COMPILED_PATTERN]) {
|
|
226
|
+
throw new TypeError('matchesPathPattern requires a compiled path pattern.');
|
|
227
|
+
}
|
|
228
|
+
validatePathSegments(segments);
|
|
229
|
+
|
|
230
|
+
let previous = new Array(segments.length + 1).fill(false);
|
|
231
|
+
previous[0] = true;
|
|
232
|
+
|
|
233
|
+
for (const token of compiledPattern.tokens) {
|
|
234
|
+
const current = new Array(segments.length + 1).fill(false);
|
|
235
|
+
if (token.type === TOKEN_TYPES.GLOBSTAR) {
|
|
236
|
+
current[0] = previous[0];
|
|
237
|
+
for (let index = 1; index <= segments.length; index += 1) {
|
|
238
|
+
current[index] = previous[index] || current[index - 1];
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
for (let index = 1; index <= segments.length; index += 1) {
|
|
242
|
+
current[index] = previous[index - 1] && tokenMatchesSegment(
|
|
243
|
+
token,
|
|
244
|
+
segments[index - 1]
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
previous = current;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return previous[segments.length];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function matchesAnyPathPattern(compiledPatterns, segments) {
|
|
255
|
+
if (!Array.isArray(compiledPatterns)) {
|
|
256
|
+
throw new TypeError('matchesAnyPathPattern requires an array of compiled patterns.');
|
|
257
|
+
}
|
|
258
|
+
validatePathSegments(segments);
|
|
259
|
+
return compiledPatterns.some((pattern) => matchesPathPattern(pattern, segments));
|
|
260
|
+
}
|
package/dist/utils/path.js
CHANGED
|
@@ -1,15 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
YAMLOCK_ERROR_CODES,
|
|
3
|
+
YamlockValidationError
|
|
4
|
+
} from '../errors.js';
|
|
5
|
+
|
|
6
|
+
const RESERVED_PATH_CHARACTERS = new Set(['\\', '.', '[', ']', ',']);
|
|
7
|
+
|
|
8
|
+
function validateSegments(segments, functionName) {
|
|
9
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
10
|
+
throw new YamlockValidationError(
|
|
11
|
+
`${functionName} requires a non-empty segments array.`,
|
|
12
|
+
{ code: YAMLOCK_ERROR_CODES.INVALID_PATH_SEGMENTS }
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const invalid = segments.some((segment) => (
|
|
17
|
+
(typeof segment !== 'string' || segment.length === 0) &&
|
|
18
|
+
(!Number.isInteger(segment) || segment < 0)
|
|
19
|
+
));
|
|
20
|
+
if (invalid) {
|
|
21
|
+
throw new YamlockValidationError(
|
|
22
|
+
'Path segments must be non-empty strings or non-negative integers.',
|
|
23
|
+
{ code: YAMLOCK_ERROR_CODES.INVALID_PATH_SEGMENTS }
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeStringSegment(segment) {
|
|
29
|
+
return [...segment]
|
|
30
|
+
.map((character) => RESERVED_PATH_CHARACTERS.has(character) ? `\\${character}` : character)
|
|
31
|
+
.join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
1
34
|
/**
|
|
2
|
-
* Builds a dot/bracket path
|
|
3
|
-
*
|
|
4
|
-
* Example: ["db", "users", 0
|
|
35
|
+
* Builds a canonical dot/bracket path that uniquely identifies a value.
|
|
36
|
+
* Reserved characters in object keys are escaped with a backslash.
|
|
37
|
+
* Example: ["db.settings", "users", 0] => "db\\.settings.users[0]"
|
|
5
38
|
*
|
|
6
39
|
* @param {Array<string|number>} segments
|
|
7
40
|
* @returns {string}
|
|
8
41
|
*/
|
|
9
42
|
export function serializePath(segments) {
|
|
10
|
-
|
|
11
|
-
throw new Error('serializePath requires a non-empty segments array.');
|
|
12
|
-
}
|
|
43
|
+
validateSegments(segments, 'serializePath');
|
|
13
44
|
|
|
14
45
|
return segments
|
|
15
46
|
.map((segment, index) => {
|
|
@@ -17,11 +48,29 @@ export function serializePath(segments) {
|
|
|
17
48
|
return `[${segment}]`;
|
|
18
49
|
}
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
51
|
+
const escaped = escapeStringSegment(segment);
|
|
52
|
+
return index === 0 ? escaped : `.${escaped}`;
|
|
53
|
+
})
|
|
54
|
+
.join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reproduces the path representation written by yamlock before escaping was
|
|
59
|
+
* introduced. This is used only to read existing payloads.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array<string|number>} segments
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function serializeLegacyPath(segments) {
|
|
65
|
+
validateSegments(segments, 'serializeLegacyPath');
|
|
66
|
+
|
|
67
|
+
return segments
|
|
68
|
+
.map((segment, index) => {
|
|
69
|
+
if (typeof segment === 'number') {
|
|
70
|
+
return `[${segment}]`;
|
|
22
71
|
}
|
|
23
72
|
|
|
24
|
-
|
|
73
|
+
return index === 0 ? segment : `.${segment}`;
|
|
25
74
|
})
|
|
26
75
|
.join('');
|
|
27
76
|
}
|
package/docs/api.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Public Node.js API
|
|
2
|
+
|
|
3
|
+
yamlock `1.x` exposes one ESM entry point. The supported package exports are:
|
|
4
|
+
|
|
5
|
+
- `encryptValue(value, key, fieldPath, options?)`
|
|
6
|
+
- `decryptValue(payload, key, fieldPath, options?)`
|
|
7
|
+
- `processConfig(config, options)`
|
|
8
|
+
- `serializePath(segments)`
|
|
9
|
+
- `getSupportedAlgorithms()`
|
|
10
|
+
- `YAMLOCK_ERROR_CODES` and the documented `YamlockError` class hierarchy
|
|
11
|
+
|
|
12
|
+
The package includes TypeScript declarations for these values and for their
|
|
13
|
+
options, path segments, keys, config containers, and error codes. Internal files
|
|
14
|
+
under `dist/` are implementation details and are intentionally unavailable as
|
|
15
|
+
package subpath exports.
|
|
16
|
+
|
|
17
|
+
## Stability contract for 1.x
|
|
18
|
+
|
|
19
|
+
- The default writer produces authenticated v2 payloads. Their serialized
|
|
20
|
+
fields, KDF profile, limits, and field-path binding remain compatible
|
|
21
|
+
throughout `1.x`.
|
|
22
|
+
- Legacy payload reading remains available throughout `1.x`. Explicit legacy
|
|
23
|
+
writing through `formatVersion: 1`, legacy algorithm options, or CLI
|
|
24
|
+
`--legacy` is a deprecated compatibility path, but will not be removed before
|
|
25
|
+
a future major release.
|
|
26
|
+
- Canonical path serialization and the legacy-path read fallback remain
|
|
27
|
+
compatible throughout `1.x`.
|
|
28
|
+
- Existing exports, documented option names, return types, error classes, and
|
|
29
|
+
error codes will not be removed or incompatibly redefined in a minor or patch
|
|
30
|
+
release.
|
|
31
|
+
- New optional exports, options, and error codes may be added in a minor
|
|
32
|
+
release. Human-readable error messages may be clarified without changing the
|
|
33
|
+
stable error code.
|
|
34
|
+
- The synchronous API remains supported. A future async API, if added, will be
|
|
35
|
+
additive rather than replacing the synchronous functions in `1.x`.
|
|
36
|
+
|
|
37
|
+
## Crypto options
|
|
38
|
+
|
|
39
|
+
With no crypto options, `encryptValue` and `processConfig` write v2 payloads.
|
|
40
|
+
V2 accepts `formatVersion: 2` and the fixed `aes-256-gcm` profile; free-form
|
|
41
|
+
algorithm sizing is rejected.
|
|
42
|
+
|
|
43
|
+
Legacy compatibility accepts `formatVersion: 1`, an algorithm string, or an
|
|
44
|
+
options object with `algorithm`, `keyLength`, `ivLength`, and `authTagLength`.
|
|
45
|
+
The algorithm stored in a payload is authoritative during decryption; sizing
|
|
46
|
+
overrides exist only for low-level legacy compatibility.
|
|
47
|
+
|
|
48
|
+
`processConfig` additionally accepts exact `paths`, structural `pathPatterns`,
|
|
49
|
+
`parentPath`, a custom `pathSerializer`, `nonStringPolicy`, and encrypt-only
|
|
50
|
+
`existingPayloadPolicy`. It returns a new config container and does not mutate
|
|
51
|
+
the input. With `nonStringPolicy: 'stringify'`, selected finite JSON primitives
|
|
52
|
+
may become strings, so the TypeScript return type is intentionally widened.
|
|
53
|
+
|
|
54
|
+
`paths` remains exact throughout the `1.x` line. `pathPatterns` is a separate
|
|
55
|
+
selector list with whole-segment `*`, `[*]`, and `**` wildcards. Exact paths
|
|
56
|
+
and patterns form a union, while payload authentication always uses the exact
|
|
57
|
+
leaf path rather than pattern text. `pathPatterns` cannot be combined with a
|
|
58
|
+
custom `pathSerializer`. See the [path pattern design](design/path-patterns.md)
|
|
59
|
+
for the grammar and compatibility rules.
|
|
60
|
+
|
|
61
|
+
See the [Node.js error contract](errors.md) and the
|
|
62
|
+
[payload v2 design](design/payload-v2.md) for the security and serialization
|
|
63
|
+
details.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Path pattern design
|
|
2
|
+
|
|
3
|
+
Status: implemented on `dev` for a future `1.x` minor release; release and
|
|
4
|
+
hosted-CI verification remain pending.
|
|
5
|
+
|
|
6
|
+
## Goals
|
|
7
|
+
|
|
8
|
+
- Select a config subtree without listing every leaf.
|
|
9
|
+
- Select repeated object fields and array elements with structural wildcards.
|
|
10
|
+
- Keep the existing exact `paths` contract fully backward compatible.
|
|
11
|
+
- Keep selector syntax separate from the exact field path authenticated by a
|
|
12
|
+
payload.
|
|
13
|
+
- Share the same selection behavior across `processConfig` and the CLI
|
|
14
|
+
`encrypt`, `decrypt`, and `migrate` commands.
|
|
15
|
+
- Reject malformed or ambiguous patterns before reading or modifying a config
|
|
16
|
+
file.
|
|
17
|
+
|
|
18
|
+
## Non-goals
|
|
19
|
+
|
|
20
|
+
- Changing `serializePath`, payload metadata, key derivation, or authenticated
|
|
21
|
+
field paths.
|
|
22
|
+
- Treating existing `paths` strings as globs.
|
|
23
|
+
- Regular expressions, partial-segment wildcards, character classes, braces,
|
|
24
|
+
negation, or exclusion rules.
|
|
25
|
+
- Selecting containers as values. yamlock continues to process leaves only.
|
|
26
|
+
- Pattern matching against a custom `pathSerializer` in the first release.
|
|
27
|
+
|
|
28
|
+
## Compatibility decision
|
|
29
|
+
|
|
30
|
+
The Node.js API adds a separate `pathPatterns?: string[]` option. The CLI adds
|
|
31
|
+
`--path-patterns <pattern1,pattern2>`. Existing `paths` and `--paths`
|
|
32
|
+
remain exact selectors, including strings containing literal `*` characters.
|
|
33
|
+
|
|
34
|
+
Exact selectors and patterns form a union: a leaf is selected when either its
|
|
35
|
+
exact serialized path is present in `paths` or its structural segments match a
|
|
36
|
+
pattern. When both selector arrays are omitted or empty, all leaves remain
|
|
37
|
+
selected as they are today. Duplicate and overlapping selectors process a leaf
|
|
38
|
+
only once.
|
|
39
|
+
|
|
40
|
+
Patterns operate on the original string/array path segments. They never become
|
|
41
|
+
the field path passed to `encryptValue` or `decryptValue`; encryption and
|
|
42
|
+
authentication continue to use the exact canonical path, or the exact output
|
|
43
|
+
of `pathSerializer` where supported. This prevents a broad selector such as
|
|
44
|
+
`db.**` from weakening field-path binding.
|
|
45
|
+
|
|
46
|
+
`pathPatterns` and `pathSerializer` are mutually exclusive. Exact
|
|
47
|
+
`paths` remain available with a custom serializer. Failing closed avoids an
|
|
48
|
+
ambiguous API where pattern syntax appears to use a serializer but actually
|
|
49
|
+
matches a different structural representation.
|
|
50
|
+
|
|
51
|
+
## Pattern grammar
|
|
52
|
+
|
|
53
|
+
Patterns use the existing dot/bracket structure plus three whole-segment
|
|
54
|
+
wildcards:
|
|
55
|
+
|
|
56
|
+
| Syntax | Meaning |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `name` | One exact object-key segment |
|
|
59
|
+
| `[0]` | One exact array-index segment |
|
|
60
|
+
| `*` | Any one object-key segment |
|
|
61
|
+
| `[*]` | Any one array-index segment |
|
|
62
|
+
| `**` | Zero or more object-key or array-index segments |
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
|
|
66
|
+
| Pattern | Matches | Does not match |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `db.**` | `db.password`, `db.replica.password`, a leaf at `db` | `database.password` |
|
|
69
|
+
| `services.*.token` | `services.api.token` | `services[0].token` |
|
|
70
|
+
| `users[*].token` | `users[0].token`, `users[12].token` | `users.admin.token` |
|
|
71
|
+
| `**.token` | `token`, `api.token`, `users[0].token` | `token.value` |
|
|
72
|
+
| `matrix[*][*].secret` | `matrix[0][1].secret` | `matrix.primary.secret` |
|
|
73
|
+
|
|
74
|
+
`*` and `**` are special only when they occupy a complete object segment.
|
|
75
|
+
`[*]` is special only as a complete array segment. Partial globs such as
|
|
76
|
+
`service-*.token` are invalid rather than being interpreted differently by
|
|
77
|
+
different callers.
|
|
78
|
+
|
|
79
|
+
## Escaping
|
|
80
|
+
|
|
81
|
+
Pattern literals retain the canonical escaping rules for `\\`, `.`, `[`, `]`,
|
|
82
|
+
and `,`. In addition, `\*` represents a literal asterisk inside an object key.
|
|
83
|
+
Examples:
|
|
84
|
+
|
|
85
|
+
| Pattern | Selected structural path |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `a\.b.**` | descendants of the literal root key `a.b` |
|
|
88
|
+
| `labels\,primary` | the literal root key `labels,primary` |
|
|
89
|
+
| `\*` | the literal root key `*` |
|
|
90
|
+
| `features.\*\.enabled` | the literal key `*.enabled` below `features` |
|
|
91
|
+
| `items\[\*\]` | the literal object key `items[*]` |
|
|
92
|
+
|
|
93
|
+
A trailing backslash, an unsupported escape, an empty segment, malformed
|
|
94
|
+
brackets, a negative/non-integer array index, or a wildcard embedded in a
|
|
95
|
+
literal segment is invalid. CLI comma splitting continues to preserve escaped
|
|
96
|
+
commas before the shared pattern parser validates each item.
|
|
97
|
+
|
|
98
|
+
This escaping affects selectors only. It does not add `*` to the reserved
|
|
99
|
+
characters used by `serializePath`, so existing payload paths and exact
|
|
100
|
+
selectors do not change.
|
|
101
|
+
|
|
102
|
+
## Matching semantics
|
|
103
|
+
|
|
104
|
+
- Patterns match complete leaf paths, not string prefixes.
|
|
105
|
+
- `**` may consume zero, one, or many structural segments, including array
|
|
106
|
+
indexes.
|
|
107
|
+
- `db` selects a leaf exactly at `db`; it does not select descendants. Use
|
|
108
|
+
`db.**` for the subtree.
|
|
109
|
+
- `*` never matches an array index, and `[*]` never matches an object key.
|
|
110
|
+
- A pattern can contain more than one `**`; matching must remain deterministic.
|
|
111
|
+
- Empty containers and sparse-array holes contain no leaves and therefore do
|
|
112
|
+
not produce matches.
|
|
113
|
+
- `parentPath` participates in the full structural path before matching.
|
|
114
|
+
- Pattern order does not affect the result.
|
|
115
|
+
|
|
116
|
+
Patterns must be parsed into structural tokens once before traversal. Matching
|
|
117
|
+
must use a bounded dynamic-programming or equivalent token algorithm rather
|
|
118
|
+
than converting user input into a backtracking regular expression. A malformed
|
|
119
|
+
pattern must fail before traversal, crypto work, or file access.
|
|
120
|
+
|
|
121
|
+
## API and CLI
|
|
122
|
+
|
|
123
|
+
Node.js API:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
processConfig(config, {
|
|
127
|
+
mode: 'encrypt',
|
|
128
|
+
key,
|
|
129
|
+
paths: ['root.literal'],
|
|
130
|
+
pathPatterns: ['db.**', 'users[*].token']
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
CLI:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
yamlock encrypt config.yaml \
|
|
138
|
+
--key "$YAMLOCK_KEY" \
|
|
139
|
+
--paths 'root.literal' \
|
|
140
|
+
--path-patterns 'db.**,users[*].token'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The CLI option applies to `encrypt`, `decrypt`, and `migrate`. It requires at
|
|
144
|
+
least one non-empty pattern. Invalid syntax uses the structured
|
|
145
|
+
`[yamlock:ERR_INVALID_PATH_PATTERNS]` error and exit code `1` before the input
|
|
146
|
+
file is read. The Node.js API adds the same stable error code through
|
|
147
|
+
`YamlockConfigError`.
|
|
148
|
+
|
|
149
|
+
## Interaction with existing behavior
|
|
150
|
+
|
|
151
|
+
### Encryption
|
|
152
|
+
|
|
153
|
+
Each matched plaintext leaf is encrypted with its exact field path. Existing
|
|
154
|
+
payload handling still follows `existingPayloadPolicy`; overlapping patterns do
|
|
155
|
+
not create nested encryption layers.
|
|
156
|
+
|
|
157
|
+
### Decryption
|
|
158
|
+
|
|
159
|
+
Every matched leaf must satisfy the same payload, key, and exact field-path
|
|
160
|
+
checks as an exact selection. A broad pattern does not silently skip plaintext
|
|
161
|
+
or malformed values.
|
|
162
|
+
|
|
163
|
+
### Migration
|
|
164
|
+
|
|
165
|
+
Every matched leaf follows the existing fail-closed migration rules. Plaintext
|
|
166
|
+
fails, selected v2 payloads require `--allow-mixed`, and legacy payloads migrate
|
|
167
|
+
to v2 only after the complete selection validates. Pattern overlap does not
|
|
168
|
+
inflate migration statistics.
|
|
169
|
+
|
|
170
|
+
### Non-string values
|
|
171
|
+
|
|
172
|
+
Pattern selection occurs before `nonStringPolicy`, matching the current exact
|
|
173
|
+
selector behavior. Selected values are ignored, stringified, or rejected by
|
|
174
|
+
the configured policy; unselected values remain unchanged.
|
|
175
|
+
|
|
176
|
+
### YAML
|
|
177
|
+
|
|
178
|
+
Patterns operate on the resolved object/array structure returned by `js-yaml`.
|
|
179
|
+
Anchors, aliases, and merge keys therefore follow their independent resolved
|
|
180
|
+
paths, consistent with the existing YAML rewrite contract.
|
|
181
|
+
|
|
182
|
+
## Required tests
|
|
183
|
+
|
|
184
|
+
- Existing exact `paths` tests remain unchanged, including literal `*` keys.
|
|
185
|
+
- Exact object keys and array indexes stay distinct.
|
|
186
|
+
- `*`, `[*]`, and `**` cover zero/one/many segment matches and mixed nesting.
|
|
187
|
+
- Escaped dots, brackets, commas, backslashes, and literal asterisks match only
|
|
188
|
+
their intended object keys.
|
|
189
|
+
- Malformed patterns fail before traversal and before CLI file reads.
|
|
190
|
+
- `parentPath` is included; `pathSerializer` plus patterns fails explicitly.
|
|
191
|
+
- Exact selectors and patterns form a union; duplicates and overlap process a
|
|
192
|
+
leaf once.
|
|
193
|
+
- Sparse arrays, empty containers, Unicode keys, null-prototype objects, and
|
|
194
|
+
own `__proto__` keys retain their current behavior.
|
|
195
|
+
- Encrypt/decrypt round trips authenticate exact leaf paths rather than pattern
|
|
196
|
+
text.
|
|
197
|
+
- Wrong keys, wrong paths, malformed payloads, plaintext decrypt selections,
|
|
198
|
+
and unsafe migration selections still fail closed.
|
|
199
|
+
- CLI integration tests cover JSON and YAML, escaped comma splitting, dry-run,
|
|
200
|
+
separate output, migration, and no-write failure cases.
|
|
201
|
+
- TypeScript declarations, public error codes, documentation, package smoke,
|
|
202
|
+
and the installed CLI expose the same contract.
|
|
203
|
+
|
|
204
|
+
## Delivery sequence
|
|
205
|
+
|
|
206
|
+
1. Implement and unit-test the pattern tokenizer/compiler and matcher without
|
|
207
|
+
connecting it to config traversal.
|
|
208
|
+
2. Add `pathPatterns` to `processConfig`, types, and stable errors while keeping
|
|
209
|
+
exact `paths` regression tests frozen.
|
|
210
|
+
3. Add shared CLI validation plus `--path-patterns` for encrypt/decrypt.
|
|
211
|
+
4. Add migration support and integration tests for atomic/no-write failures.
|
|
212
|
+
5. Update public documentation and package smoke, then release the completed
|
|
213
|
+
additive feature in a minor version after full CI.
|