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.
@@ -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
+ }
@@ -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 string that uniquely identifies a value
3
- * inside a nested object/array structure.
4
- * Example: ["db", "users", 0, "password"] => "db.users[0].password"
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
- if (!Array.isArray(segments) || segments.length === 0) {
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
- if (typeof segment === 'string' && segment.length > 0) {
21
- return index === 0 ? segment : `.${segment}`;
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
- throw new Error('Path segments must be non-empty strings or numbers.');
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.