yamlock 1.0.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 ADDED
@@ -0,0 +1,205 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. Release versions are finalized in dedicated commits and use matching numeric Git tags.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [1.1.0] - 2026-08-14
8
+
9
+ ### Added
10
+ - Tag-gated GitHub Release automation that reuses the full CI matrix, validates the tag against `main`, derives notes from the matching changelog section, verifies a draft, and publishes the release as latest.
11
+ - Structural `pathPatterns` / `--path-patterns` selectors for `processConfig` and CLI encrypt/decrypt/migrate workflows, with whole-segment `*`, `[*]`, and `**` matching, exact-path authentication, and fail-closed validation without reinterpreting existing `paths`.
12
+
13
+ ## [1.0.0] - 2026-08-11
14
+
15
+ ### Added
16
+ - Authenticated payload v2 for the Node.js API, using AES-256-GCM, scrypt, strict parsing, and authenticated field-path metadata.
17
+ - Frozen legacy payload fixtures and v2 regression tests for deterministic vectors, tampering, malformed input, mixed-format configs, and resource limits.
18
+ - Payload v2 design documentation covering the threat model, serialized format, compatibility, and staged migration.
19
+ - Safe `yamlock migrate` workflow with dry-run summaries, selective paths, mixed-format validation, explicit backups, separate outputs, and atomic permission-preserving writes.
20
+ - Explicit CLI legacy-write compatibility through `yamlock encrypt --legacy`; custom encryption algorithms require this mode.
21
+ - Strict repeated-encryption checks through CLI `--error-on-encrypted` and Node.js `existingPayloadPolicy: 'error'`.
22
+ - Explicit `--force-encrypt` / `existingPayloadPolicy: 'encrypt'` handling for intentional `yl|...` plaintext or nested encryption.
23
+ - YAML rewrite documentation and regression coverage for comments, formatting, anchors, aliases, merge keys, and explicit/custom tags.
24
+ - Public `serializePath(segments)` helper for constructing canonical field paths without string ambiguity.
25
+ - Edge-case regression coverage for empty containers, sparse arrays, special and Unicode keys, empty strings, and large values.
26
+ - Automated npm tarball smoke coverage for package contents, public API imports, and the installed distribution-first CLI.
27
+ - Stable public Node.js error classes and `ERR_*` codes for validation, payload, authentication, legacy decryption, and config-processing failures.
28
+ - Bundled TypeScript declarations and a documented `1.x` stability contract for the public Node.js API.
29
+ - Built-in coverage thresholds plus automated Markdown-link and release-metadata checks.
30
+ - Packaged runnable API and CI/CD examples with regression coverage against the installed distribution.
31
+ - Conventional `help`, `-h`, and `--help` CLI entry points with installed-package smoke coverage.
32
+ - Tracked Bumpster configuration, synchronized `VERSION` metadata, and a pre-bump release gate.
33
+
34
+ ### Changed
35
+ - Updated `js-yaml` to 4.3.1 to address merge-key, alias, and ordered-map denial-of-service advisories; refreshed ESLint 9 tooling within the existing major version.
36
+ - Package builds now exclude ignored local dotfiles, and tarball smoke checks recursively for local workflow artifacts.
37
+ - CI now covers supported Node.js releases on Linux and macOS, uses current Node 24-based GitHub Actions, and performs a tag-gated package preflight.
38
+ - Development and dependency updates now target `dev`; Bumpster releases atomically synchronize `dev`, `main`, and `vX.Y.Z` tags.
39
+ - `encryptValue`, `processConfig`, and `yamlock encrypt` now write authenticated v2 payloads by default.
40
+ - `decryptValue` and `processConfig` continue to auto-detect and read both legacy and v2 payloads; API callers can request legacy writing explicitly with `formatVersion: 1` or legacy algorithm options.
41
+ - Repeated `processConfig`/CLI encryption now authenticates and preserves existing payloads instead of adding another encryption layer; fully encrypted in-place inputs are not rewritten.
42
+ - CLI argument parsing now rejects unknown or duplicate options, missing values, extra positional arguments, and options that do not belong to the selected command before reading input files; `keygen --length` accepts only integers from 1 to 4096 bytes.
43
+ - CLI integration tests now force an explicit source mode through the real `bin/yamlock` launcher, preventing stale local `dist` output from masking source changes while preserving distribution-first behavior for normal runs.
44
+ - `processConfig` now validates non-string policies, path options, serializer output, collisions, and circular structures; opaque values are preserved by `ignore`, while `stringify` fails closed for values that cannot be converted without type loss.
45
+ - Selective processing applies non-string policies only to selected leaves, and YAML timestamp values are no longer collapsed into empty objects during CLI traversal.
46
+ - Normal CLI encrypt/decrypt writes now use the shared atomic temporary-file writer, verify that the source did not change after reading, preserve source or existing-output modes, reject symbolic-link paths, and clean up temporary files after failures.
47
+ - CLI help now warns that YAML presentation details are normalized during writes and points users toward `--dry-run` or `--output` workflows.
48
+ - YAML and JSON parse failures now report sanitized diagnostics without echoing source lines that may contain secrets.
49
+ - Default field paths now escape reserved characters in object keys, CLI path lists understand escaped commas, and existing payloads with legacy ambiguous paths remain readable.
50
+ - Config processing and migration now preserve sparse-array length and holes; `processConfig` also retains null prototypes and own keys such as `__proto__` without prototype assignment.
51
+
52
+ ## [0.3.0] - 2025-12-01
53
+ ### Added
54
+ - `processConfig` non-string policies (`ignore`, `stringify`, `error`) and optional `pathSerializer` hook.
55
+ - Unit tests demonstrating the new policies.
56
+
57
+ ### Changed
58
+ - README highlights non-string handling in the advanced usage section.
59
+
60
+ ## [0.2.10] - 2025-12-01
61
+ ### Added
62
+ - README example demonstrating the `--dry-run` workflow (with sample diff output).
63
+
64
+ ### Changed
65
+ - Package version bumped to keep documentation in sync.
66
+
67
+ ## [0.2.9] - 2025-12-01
68
+ ### Added
69
+ - CLI `--dry-run` flag prints the diff without touching files.
70
+ - Structured error codes (e.g., `[yamlock:ERR_MISSING_KEY]`) for machine-readable failure handling.
71
+ - Integration tests covering dry-run and the new error format.
72
+
73
+ ### Changed
74
+ - README documents the `--dry-run` flag and behavior.
75
+
76
+ ## [0.2.8] - 2025-12-01
77
+ ### Added
78
+ - Advanced usage docs: selective path tips, CI/CD workflow example (`examples/docs/ci-cd.md`), and an expanded key-rotation guide with scripting advice.
79
+ - README now links to the new docs and highlights selective encryption in a dedicated section.
80
+
81
+ ### Changed
82
+ - Package version bumped to keep documentation in sync.
83
+
84
+ ## [0.2.7] - 2025-12-01
85
+ ### Added
86
+ - README badges for npm version and Node test status, plus a release information section summarizing install options.
87
+
88
+ ### Changed
89
+ - Package version bumped to keep documentation in sync.
90
+
91
+ ## [0.2.6] - 2025-11-28
92
+ ### Added
93
+ - Unit tests covering `processConfig` path filters and CLI integration tests for custom algorithms/decrypt paths.
94
+
95
+ ### Changed
96
+ - README/CLI behavior unchanged.
97
+
98
+ ## [0.2.5] - 2025-11-28
99
+ ### Added
100
+ - `yamlock algorithms` now distinguishes between tested presets and other OpenSSL-provided ciphers.
101
+ - Integration tests updated to confirm the new output format.
102
+
103
+ ### Changed
104
+ - README notes that the algorithms command shows both tested and additional cipher lists.
105
+
106
+ ## [0.2.4] - 2025-11-28
107
+ ### Added
108
+ - `yamlock keygen` command generates random keys with configurable length and format, including guidance on storing them.
109
+ - Integration tests covering key generation.
110
+
111
+ ### Changed
112
+ - README documents the key generator usage.
113
+
114
+ ## [0.2.3] - 2025-11-27
115
+ ### Added
116
+ - `yamlock version` command prints the installed CLI version.
117
+ - `yamlock algorithms` lists all ciphers available in the current runtime.
118
+ - Integration tests cover the new commands.
119
+
120
+ ### Changed
121
+ - CLI help and README highlight the new commands.
122
+ - Moved shared fixtures under `fixtures/` so the test runner skips them as standalone suites.
123
+
124
+ ## [0.2.2] - 2025-11-27
125
+ ### Added
126
+ - CLI support for `--paths` (partial encryption/decryption) and `--output` (separate destination files).
127
+ - `processConfig` now accepts targeted path lists so API/CLI share the same behavior.
128
+
129
+ ### Changed
130
+ - README documents the new CLI options with examples.
131
+
132
+ ## [0.2.1] - 2025-11-26
133
+ ### Changed
134
+ - Unified unit tests around shared fixtures/helpers to remove duplication and ensure every supported algorithm is exercised consistently.
135
+
136
+ ## [0.2.0] - 2025-11-26
137
+ ### Added
138
+ - Algorithm presets with override support (e.g., `chacha20-poly1305` with auth tags, configurable key/IV lengths).
139
+ - API support for passing algorithm options through `encryptValue`, `decryptValue`, and `processConfig`.
140
+ - Additional unit coverage for presets and algorithm-aware config processing.
141
+
142
+ ## [0.1.2] - 2025-11-26
143
+ ### Fixed
144
+ - CLI binary now falls back to the source entry during development, ensuring `yamlock` works when installed globally or run via `node bin/yamlock`.
145
+ - Integration tests call the published bin, catching future regressions.
146
+
147
+ ## [0.1.1] - 2025-11-26
148
+ ### Added
149
+ - Key rotation guide under `docs/key-rotation.md` and README link.
150
+ - Documented CLI exit codes and reference to the example script.
151
+
152
+ ## [0.1.0] - 2025-11-26
153
+ ### Added
154
+ - Example script under `examples/basic.js` demonstrating encrypt/decrypt flows.
155
+ - GitHub Actions CI workflow to lint, test, and build on pushes/PRs.
156
+ - README now links to the example script.
157
+
158
+ ## [0.0.10] - 2025-11-26
159
+ ### Added
160
+ - Expanded README with usage examples, encrypted format description, and contributing link.
161
+ - CONTRIBUTING guide plus GitHub issue and pull request templates.
162
+
163
+ ## [0.0.9] - 2025-11-26
164
+ ### Added
165
+ - CLI now auto-detects YAML vs JSON, loads via js-yaml, and writes back preserving the original format.
166
+ - Integration tests covering YAML encrypt/decrypt flows.
167
+
168
+ ## [0.0.8] - 2025-11-26
169
+ ### Added
170
+ - CLI skeleton capable of encrypting/decrypting JSON files with `--key` and `--algorithm` options.
171
+ - Integration tests covering CLI encrypt/decrypt flows and key validation.
172
+
173
+ ## [0.0.7] - 2025-11-26
174
+ ### Added
175
+ - Public API exports for `encryptValue`, `decryptValue`, `processConfig`, and `getSupportedAlgorithms`.
176
+ - Unit tests ensuring the API surface is available and round-trips data correctly.
177
+
178
+ ## [0.0.6] - 2025-11-25
179
+ ### Added
180
+ - `processConfig` helper to walk objects/arrays and encrypt or decrypt every string value based on field paths.
181
+ - Unit tests covering nested traversal, arrays, round-trips, and validation errors.
182
+
183
+ ## [0.0.5] - 2025-11-25
184
+ ### Added
185
+ - Field path utilities that serialize nested object/array locations (`src/utils/path.js`).
186
+ - Unit tests covering dot/bracket formatting and invalid input handling.
187
+
188
+ ## [0.0.4] - 2025-11-25
189
+ ### Added
190
+ - `decryptValue` support with field-path validation and yamlock payload parsing.
191
+ - Unit tests for decrypt flow, mismatch handling, and invalid payload detection.
192
+
193
+ ## [0.0.3] - 2025-11-24
194
+ ### Added
195
+ - `encryptValue` function that encrypts string fields with per-path salts and random IVs.
196
+ - Unit tests covering payload structure, IV randomness, and validation checks.
197
+
198
+ ## [0.0.2] - 2025-11-24
199
+ ### Added
200
+ - Crypto utility helpers for key derivation, IV generation, salt encoding, and payload formatting/parsing.
201
+ - Unit tests covering the utility layer and yamlock payload parsing.
202
+
203
+ ## [0.0.1] - 2025-11-24
204
+ ### Added
205
+ - Initial project scaffold: package metadata, README baseline, ESLint config, CLI stub, and directory structure.
package/README.md CHANGED
@@ -40,6 +40,7 @@ yarn add yamlock # project dependency
40
40
  - CLI workflow that processes YAML or JSON files in place.
41
41
  - Safe CLI migration from legacy payloads to authenticated v2 payloads.
42
42
  - Recursively lock/unlock entire objects via `processConfig`.
43
+ - Select repeated fields or subtrees with structural path patterns.
43
44
  - Public API exports that mirror CLI behavior for programmatic use.
44
45
  - Focus on Node.js 22+, ESM modules, and a lightweight dependency set (`js-yaml`).
45
46
 
@@ -57,6 +58,9 @@ yamlock decrypt settings.json --key "super-secret"
57
58
  # Encrypt only selected fields into a new file
58
59
  yamlock encrypt config.json --key "$YAMLOCK_KEY" --paths "db.password,api.token" --output config.secure.json
59
60
 
61
+ # Encrypt repeated tokens and an entire subtree
62
+ yamlock encrypt config.json --key "$YAMLOCK_KEY" --path-patterns 'users[*].token,services.**'
63
+
60
64
  # Inspect CLI metadata
61
65
  yamlock --help
62
66
  yamlock version
@@ -90,6 +94,7 @@ and use `--dry-run` or `--output` when presentation details matter.
90
94
  Options of note:
91
95
  - `--output <file>` writes the result to a separate file instead of overwriting the input.
92
96
  - `--paths <path1,path2>` targets only the specified fields using the [escaped path syntax](#field-path-syntax).
97
+ - `--path-patterns <pattern1,pattern2>` selects structural paths with whole-segment `*`, `[*]`, and `**` wildcards.
93
98
  - `--dry-run` previews an operation without modifying files; encrypt/decrypt print content changes, while migrate prints only counts and target paths.
94
99
  - `migrate` decrypts selected legacy payloads and re-encrypts them as authenticated v2 payloads.
95
100
  - `migrate --allow-mixed` additionally authenticates and preserves selected values that are already v2.
@@ -138,6 +143,33 @@ readable through the default serializer's compatibility path; selecting those
138
143
  keys now requires the canonical escaped spelling. A custom `pathSerializer`
139
144
  keeps its own contract and does not use the default compatibility fallback.
140
145
 
146
+ ### Path patterns
147
+
148
+ Patterns are separate from exact `paths`; using both forms a union. They match
149
+ complete structural leaf paths:
150
+
151
+ - `services.*.token` matches one object-key segment, such as
152
+ `services.api.token`, but not `services[0].token`.
153
+ - `users[*].token` matches array elements such as `users[0].token`.
154
+ - `db.**` matches a leaf at `db` or any descendant below it, including arrays.
155
+
156
+ Wildcards must occupy a complete segment. Partial globs such as `service-*`
157
+ are rejected with `ERR_INVALID_PATH_PATTERNS` before the CLI reads the input
158
+ file. Literal reserved characters use the same escaping as exact paths, and
159
+ `\*` selects a literal asterisk key:
160
+
161
+ ```bash
162
+ yamlock encrypt config.json \
163
+ --key "$YAMLOCK_KEY" \
164
+ --paths 'root.literal' \
165
+ --path-patterns 'services.*.token,users[*].token,labels\,primary'
166
+ ```
167
+
168
+ Patterns only decide which leaves are selected. Encryption, decryption, and
169
+ migration continue to bind every payload to its exact canonical leaf path.
170
+ Node.js callers use `pathPatterns: string[]`; it cannot be combined with a
171
+ custom `pathSerializer`.
172
+
141
173
  ### Node.js API
142
174
 
143
175
  ```js
@@ -147,8 +179,17 @@ const encrypted = encryptValue('swordfish', process.env.YAMLOCK_KEY, 'db.passwor
147
179
  const decrypted = decryptValue(encrypted, process.env.YAMLOCK_KEY, 'db.password');
148
180
 
149
181
  const config = { db: { password: 'swordfish' } };
150
- const locked = processConfig(config, { mode: 'encrypt', key: process.env.YAMLOCK_KEY });
151
- const unlocked = processConfig(locked, { mode: 'decrypt', key: process.env.YAMLOCK_KEY });
182
+ const selectors = { pathPatterns: ['db.**'] };
183
+ const locked = processConfig(config, {
184
+ mode: 'encrypt',
185
+ key: process.env.YAMLOCK_KEY,
186
+ ...selectors
187
+ });
188
+ const unlocked = processConfig(locked, {
189
+ mode: 'decrypt',
190
+ key: process.env.YAMLOCK_KEY,
191
+ ...selectors
192
+ });
152
193
  ```
153
194
 
154
195
  Expected Node.js API failures extend `YamlockError` and expose stable `ERR_*`
@@ -303,10 +344,11 @@ yamlock migrate config.yaml --key "$YAMLOCK_KEY" --paths "db.password,api.token"
303
344
 
304
345
  Migration validates every selected value and builds the complete result before
305
346
  writing. Selected plaintext and non-string values are rejected, so use
306
- `--paths` for partially encrypted configs. Selected v2 values are rejected
307
- unless `--allow-mixed` is set; with that flag they are authenticated and kept
308
- unchanged. In-place writes are atomic, preserve the source file mode, and do
309
- not replace an existing backup. To roll back, verify the backup and then copy
347
+ `--paths` or `--path-patterns` for partially encrypted configs. Selected v2
348
+ values are rejected unless `--allow-mixed` is set; with that flag they are
349
+ authenticated and kept unchanged. In-place writes are atomic, preserve the
350
+ source file mode, and do not replace an existing backup. To roll back, verify
351
+ the backup and then copy
310
352
  `config.yaml.yamlock.bak` over `config.yaml`.
311
353
 
312
354
  Legacy AES-CBC payloads have no authentication, so migration can only validate
@@ -318,7 +360,7 @@ not received a third-party security audit.
318
360
 
319
361
  ## Advanced usage
320
362
 
321
- - **Selective encryption**: combine `--paths` on the CLI or a non-empty `paths: ['db.password']` array in `processConfig` to encrypt only sensitive fields.
363
+ - **Selective encryption**: use exact `--paths`/`paths` selectors, structural `--path-patterns`/`pathPatterns`, or their union to encrypt only sensitive fields.
322
364
  - **Repeated encryption**: valid selected payloads are authenticated and preserved; add `--error-on-encrypted` or `existingPayloadPolicy: 'error'` for strict workflows.
323
365
  - **Non-string handling**: use `nonStringPolicy: 'ignore' | 'stringify' | 'error'` to preserve opaque leaves, stringify finite JSON primitives, or reject selected non-string values; use `pathSerializer` to change path representation (e.g., `db/password` instead of dot notation).
324
366
  - **CI/CD flows**: see [examples/docs/ci-cd.md](examples/docs/ci-cd.md) for a GitHub Actions job that decrypts configs for builds and re-encrypts them before publishing artifacts.
@@ -341,11 +383,14 @@ default v2 writer for new data.
341
383
  ## Release information
342
384
 
343
385
  - The badges at the top show the latest npm version and the status of the full CI matrix.
344
- - See [CHANGELOG.md](CHANGELOG.md) for detailed release notes; install a specific tag via `npm install yamlock@<version>`.
386
+ - See [GitHub Releases](https://github.com/phoenixweiss/yamlock/releases) and
387
+ [CHANGELOG.md](CHANGELOG.md) for detailed release notes; install a specific
388
+ version via `npm install yamlock@<version>`.
345
389
  - yamlock versions are bumped with my own release utility,
346
390
  [Bumpster](https://github.com/phoenixweiss/Bumpster). It keeps the tracked
347
391
  `VERSION` file and `package.json` synchronized while publishing the `dev`,
348
- `main`, and `vX.Y.Z` Git refs atomically.
392
+ `main`, and `vX.Y.Z` Git refs atomically. Each stable tag must pass the full
393
+ CI and package preflight before its GitHub Release is published.
349
394
 
350
395
  ### Encrypted value formats
351
396
 
@@ -393,6 +438,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, available scrip
393
438
 
394
439
  ## Future work
395
440
 
441
+ - Safe subtree and wildcard selectors are specified in the
442
+ [path pattern design proposal](docs/design/path-patterns.md). Existing
443
+ `paths` remain exact; the proposed feature is not implemented yet.
396
444
  - An async encryption API with bounded scrypt concurrency for large configs.
397
445
  - Stricter file-format validation and preservation rules for advanced YAML features.
398
446
 
package/dist/cli/cli.js CHANGED
@@ -14,6 +14,7 @@ import yaml from 'js-yaml';
14
14
  import { processConfig } from '../utils/config.js';
15
15
  import { writeFileAtomically } from '../utils/file.js';
16
16
  import { migrateConfig } from '../utils/migrate.js';
17
+ import { compilePathPatterns } from '../utils/path-pattern.js';
17
18
  import { listSupportedAlgorithms, TESTED_ALGORITHMS } from '../crypto/utils.js';
18
19
 
19
20
  const require = createRequire(import.meta.url);
@@ -32,6 +33,7 @@ const OPTION_SPECS = [
32
33
  { key: 'algorithm', names: ['-a', '--algorithm'], takesValue: true },
33
34
  { key: 'output', names: ['-o', '--output'], takesValue: true },
34
35
  { key: 'paths', names: ['-p', '--paths'], takesValue: true },
36
+ { key: 'pathPatterns', names: ['--path-patterns'], takesValue: true },
35
37
  { key: 'dryRun', names: ['-d', '--dry-run'], takesValue: false },
36
38
  { key: 'allowMixed', names: ['--allow-mixed'], takesValue: false },
37
39
  { key: 'noBackup', names: ['--no-backup'], takesValue: false },
@@ -53,13 +55,22 @@ const COMMAND_OPTIONS = new Map([
53
55
  'algorithm',
54
56
  'output',
55
57
  'paths',
58
+ 'pathPatterns',
56
59
  'dryRun',
57
60
  'legacy',
58
61
  'errorOnEncrypted',
59
62
  'forceEncrypt'
60
63
  ])],
61
- ['decrypt', new Set(['key', 'output', 'paths', 'dryRun'])],
62
- ['migrate', new Set(['key', 'output', 'paths', 'dryRun', 'allowMixed', 'noBackup'])],
64
+ ['decrypt', new Set(['key', 'output', 'paths', 'pathPatterns', 'dryRun'])],
65
+ ['migrate', new Set([
66
+ 'key',
67
+ 'output',
68
+ 'paths',
69
+ 'pathPatterns',
70
+ 'dryRun',
71
+ 'allowMixed',
72
+ 'noBackup'
73
+ ])],
63
74
  ['keygen', new Set(['length', 'format'])],
64
75
  ['help', new Set()],
65
76
  ['version', new Set()],
@@ -87,6 +98,7 @@ Options:
87
98
  -a, --algorithm <value> Legacy cipher algorithm (encrypt --legacy only).
88
99
  -o, --output <file> Write the result to a different file (otherwise overwrites the input file).
89
100
  -p, --paths <p1,p2> Comma-separated escaped field paths to process (dot/bracket notation).
101
+ --path-patterns <p1,p2> Structural selectors using *, [*], and ** whole-segment wildcards.
90
102
  -d, --dry-run Preview the operation without modifying files.
91
103
  --allow-mixed (migrate) Authenticate and preserve selected v2 values.
92
104
  --no-backup (migrate) Replace the input without creating <file>.yamlock.bak.
@@ -104,6 +116,7 @@ YAML rewrite note:
104
116
  Path syntax:
105
117
  Object-key backslashes, dots, brackets, and commas must be backslash-escaped.
106
118
  Example: db\\.primary.token selects { "db.primary": { "token": ... } }.
119
+ Patterns are separate selectors; e.g. services.*.token or users[*].token.
107
120
  `;
108
121
  }
109
122
 
@@ -164,7 +177,7 @@ function serializeConfig(format, data) {
164
177
  return `${JSON.stringify(data, null, 2)}\n`;
165
178
  }
166
179
 
167
- function parsePaths(value) {
180
+ function splitPathList(value) {
168
181
  if (!value) {
169
182
  return [];
170
183
  }
@@ -189,9 +202,7 @@ function parsePaths(value) {
189
202
  }
190
203
  paths.push(current);
191
204
 
192
- return paths
193
- .map((segment) => segment.trim())
194
- .filter((segment) => segment.length > 0);
205
+ return paths.map((segment) => segment.trim());
195
206
  }
196
207
 
197
208
  function parseArgs(argv) {
@@ -242,15 +253,26 @@ function parseArgs(argv) {
242
253
  );
243
254
  }
244
255
 
245
- if (spec.key === 'paths') {
246
- const paths = parsePaths(next);
247
- if (paths.length === 0) {
256
+ if (spec.key === 'paths' || spec.key === 'pathPatterns') {
257
+ const splitSelectors = splitPathList(next);
258
+ const selectors = spec.key === 'pathPatterns'
259
+ ? splitSelectors
260
+ : splitSelectors.filter((selector) => selector.length > 0);
261
+ if (
262
+ selectors.length === 0 ||
263
+ (spec.key === 'pathPatterns' && selectors.some((selector) => selector.length === 0))
264
+ ) {
248
265
  throw cliError(
249
- 'ERR_INVALID_OPTION_VALUE',
250
- 'Option --paths requires at least one non-empty field path.'
266
+ spec.key === 'pathPatterns'
267
+ ? 'ERR_INVALID_PATH_PATTERNS'
268
+ : 'ERR_INVALID_OPTION_VALUE',
269
+ `Option ${OPTION_LABELS.get(spec.key)} requires at least one non-empty selector.`
251
270
  );
252
271
  }
253
- result.options.paths = paths;
272
+ if (spec.key === 'pathPatterns') {
273
+ compilePathPatterns(selectors);
274
+ }
275
+ result.options[spec.key] = selectors;
254
276
  } else {
255
277
  result.options[spec.key] = next;
256
278
  }
@@ -435,6 +457,7 @@ function handleMigration({ file, absolutePath, outputPath, config, key, options
435
457
  const result = migrateConfig(config.data, {
436
458
  key,
437
459
  paths: options.paths,
460
+ pathPatterns: options.pathPatterns,
438
461
  allowMixed: options.allowMixed
439
462
  });
440
463
  const serialized = serializeConfig(config.format, result.data);
@@ -629,7 +652,8 @@ export async function runCli(argv = process.argv) {
629
652
  : options.errorOnEncrypted
630
653
  ? 'error'
631
654
  : 'preserve',
632
- paths: options.paths
655
+ paths: options.paths,
656
+ pathPatterns: options.pathPatterns
633
657
  });
634
658
  if (outputPath === absolutePath && isDeepStrictEqual(result, config.data)) {
635
659
  print('No plaintext values required encryption. No files were modified.');
@@ -651,7 +675,8 @@ export async function runCli(argv = process.argv) {
651
675
  const result = processConfig(config.data, {
652
676
  mode: 'decrypt',
653
677
  key,
654
- paths: options.paths
678
+ paths: options.paths,
679
+ pathPatterns: options.pathPatterns
655
680
  });
656
681
  handleWrite({
657
682
  dryRun: options.dryRun,
@@ -671,8 +696,10 @@ export async function runCli(argv = process.argv) {
671
696
  const structuredCode = typeof error.code === 'string' && error.code.startsWith('ERR_')
672
697
  ? error.code
673
698
  : null;
699
+ const isMigrationCode = structuredCode === 'ERR_INVALID_PATH_PATTERNS' ||
700
+ structuredCode?.startsWith('ERR_MIGRATION_');
674
701
  const code = command === 'migrate'
675
- ? structuredCode?.startsWith('ERR_MIGRATION_')
702
+ ? isMigrationCode
676
703
  ? structuredCode
677
704
  : 'ERR_MIGRATION_FAILED'
678
705
  : structuredCode ?? 'ERR_PROCESS_FAILED';
package/dist/errors.js CHANGED
@@ -15,6 +15,7 @@ export const YAMLOCK_ERROR_CODES = Object.freeze({
15
15
  INVALID_NON_STRING_POLICY: 'ERR_INVALID_NON_STRING_POLICY',
16
16
  INVALID_OPTIONS: 'ERR_INVALID_OPTIONS',
17
17
  INVALID_PATH_SEGMENTS: 'ERR_INVALID_PATH_SEGMENTS',
18
+ INVALID_PATH_PATTERNS: 'ERR_INVALID_PATH_PATTERNS',
18
19
  INVALID_PATH_SERIALIZER: 'ERR_INVALID_PATH_SERIALIZER',
19
20
  INVALID_PATHS: 'ERR_INVALID_PATHS',
20
21
  INVALID_PAYLOAD: 'ERR_INVALID_PAYLOAD',
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface ProcessConfigCommonOptions {
25
25
  nonStringPolicy?: YamlockNonStringPolicy;
26
26
  pathSerializer?: (segments: YamlockPathSegment[]) => string;
27
27
  paths?: string[];
28
+ pathPatterns?: string[];
28
29
  parentPath?: YamlockPathSegment[];
29
30
  }
30
31
 
@@ -57,6 +58,7 @@ export const YAMLOCK_ERROR_CODES: Readonly<{
57
58
  INVALID_NON_STRING_POLICY: 'ERR_INVALID_NON_STRING_POLICY';
58
59
  INVALID_OPTIONS: 'ERR_INVALID_OPTIONS';
59
60
  INVALID_PATH_SEGMENTS: 'ERR_INVALID_PATH_SEGMENTS';
61
+ INVALID_PATH_PATTERNS: 'ERR_INVALID_PATH_PATTERNS';
60
62
  INVALID_PATH_SERIALIZER: 'ERR_INVALID_PATH_SERIALIZER';
61
63
  INVALID_PATHS: 'ERR_INVALID_PATHS';
62
64
  INVALID_PAYLOAD: 'ERR_INVALID_PAYLOAD';
@@ -4,6 +4,11 @@ import { detectPayloadVersion } from '../crypto/payload-v2.js';
4
4
  import { isYamlockPayload } from '../crypto/utils.js';
5
5
  import { YamlockConfigError } from '../errors.js';
6
6
  import { serializeLegacyPath, serializePath } from './path.js';
7
+ import {
8
+ compilePathPatterns,
9
+ matchesAnyPathPattern,
10
+ PathPatternSyntaxError
11
+ } from './path-pattern.js';
7
12
 
8
13
  const MODES = {
9
14
  ENCRYPT: 'encrypt',
@@ -13,8 +18,11 @@ const MODES = {
13
18
  const NON_STRING_POLICIES = new Set(['ignore', 'stringify', 'error']);
14
19
  const EXISTING_PAYLOAD_POLICIES = new Set(['preserve', 'error', 'encrypt']);
15
20
 
16
- function createConfigError(code, message) {
17
- return new YamlockConfigError(message, { code });
21
+ function createConfigError(code, message, cause) {
22
+ return new YamlockConfigError(message, {
23
+ code,
24
+ ...(cause === undefined ? {} : { cause })
25
+ });
18
26
  }
19
27
 
20
28
  function isConfigContainer(value) {
@@ -83,6 +91,22 @@ function normalizePaths(paths) {
83
91
  return new Set(normalized);
84
92
  }
85
93
 
94
+ function normalizePathPatterns(pathPatterns) {
95
+ try {
96
+ return compilePathPatterns(pathPatterns);
97
+ } catch (error) {
98
+ if (!(error instanceof PathPatternSyntaxError)) {
99
+ throw error;
100
+ }
101
+
102
+ throw createConfigError(
103
+ 'ERR_INVALID_PATH_PATTERNS',
104
+ error.message,
105
+ error
106
+ );
107
+ }
108
+ }
109
+
86
110
  function resolveCurrentPaths(segments, pathSerializer) {
87
111
  let currentPath;
88
112
  try {
@@ -150,6 +174,7 @@ function stringifyConfigLeaf(value, currentPath) {
150
174
  * @param {"preserve"|"error"|"encrypt"} [options.existingPayloadPolicy]
151
175
  * @param {(segments: Array<string|number>) => string} [options.pathSerializer]
152
176
  * @param {string[]} [options.paths]
177
+ * @param {string[]} [options.pathPatterns]
153
178
  * @param {Array<string|number>} [options.parentPath]
154
179
  * @returns {Object|Array}
155
180
  */
@@ -195,6 +220,13 @@ export function processConfig(node, options) {
195
220
  const parentPath = options.parentPath ?? [];
196
221
  validatePathSegments(parentPath, 'parentPath');
197
222
  const normalizedPaths = normalizePaths(options.paths);
223
+ const compiledPathPatterns = normalizePathPatterns(options.pathPatterns);
224
+ if (compiledPathPatterns.length > 0 && options.pathSerializer !== undefined) {
225
+ throw createConfigError(
226
+ 'ERR_INVALID_PATH_PATTERNS',
227
+ 'pathPatterns cannot be combined with pathSerializer.'
228
+ );
229
+ }
198
230
  const existingPayloadPolicy = options.existingPayloadPolicy ?? 'preserve';
199
231
  if (!EXISTING_PAYLOAD_POLICIES.has(existingPayloadPolicy)) {
200
232
  throw createConfigError(
@@ -208,6 +240,7 @@ export function processConfig(node, options) {
208
240
  mode,
209
241
  parentPath,
210
242
  normalizedPaths,
243
+ compiledPathPatterns,
211
244
  nonStringPolicy,
212
245
  existingPayloadPolicy,
213
246
  pathSerializer: options.pathSerializer,
@@ -216,7 +249,7 @@ export function processConfig(node, options) {
216
249
  });
217
250
  }
218
251
 
219
- function traverseConfig(node, { mode, key, algorithm, algorithmOptions, formatVersion, parentPath, normalizedPaths, nonStringPolicy, existingPayloadPolicy, pathSerializer, ancestors, seenPaths }) {
252
+ function traverseConfig(node, { mode, key, algorithm, algorithmOptions, formatVersion, parentPath, normalizedPaths, compiledPathPatterns, nonStringPolicy, existingPayloadPolicy, pathSerializer, ancestors, seenPaths }) {
220
253
  const isArrayNode = Array.isArray(node);
221
254
  const result = createResultContainer(node);
222
255
  const selectedCryptoOptions = algorithmOptions ?? algorithm;
@@ -249,6 +282,7 @@ function traverseConfig(node, { mode, key, algorithm, algorithmOptions, formatVe
249
282
  algorithmOptions: cryptoOptions,
250
283
  parentPath: pathSegments,
251
284
  normalizedPaths,
285
+ compiledPathPatterns,
252
286
  nonStringPolicy,
253
287
  existingPayloadPolicy,
254
288
  pathSerializer,
@@ -266,7 +300,10 @@ function traverseConfig(node, { mode, key, algorithm, algorithmOptions, formatVe
266
300
  }
267
301
  seenPaths.add(currentPath);
268
302
 
269
- const shouldProcess = !normalizedPaths || normalizedPaths.has(currentPath);
303
+ const hasSelectors = normalizedPaths !== null || compiledPathPatterns.length > 0;
304
+ const shouldProcess = !hasSelectors ||
305
+ normalizedPaths?.has(currentPath) ||
306
+ matchesAnyPathPattern(compiledPathPatterns, pathSegments);
270
307
  if (!shouldProcess) {
271
308
  setResultValue(result, targetKey, originalValue);
272
309
  continue;
@@ -6,6 +6,7 @@ import {
6
6
  } from '../crypto/payload-v2.js';
7
7
  import { isYamlockPayload } from '../crypto/utils.js';
8
8
  import { serializeLegacyPath, serializePath } from './path.js';
9
+ import { compilePathPatterns, matchesAnyPathPattern } from './path-pattern.js';
9
10
 
10
11
  function createMigrationError(code, message) {
11
12
  const error = new Error(message);
@@ -59,7 +60,10 @@ function traverse(node, context) {
59
60
  return;
60
61
  }
61
62
 
62
- const selected = !context.paths || context.paths.has(currentPath);
63
+ const hasSelectors = context.paths !== null || context.pathPatterns.length > 0;
64
+ const selected = !hasSelectors ||
65
+ context.paths?.has(currentPath) ||
66
+ matchesAnyPathPattern(context.pathPatterns, pathSegments);
63
67
  if (!selected) {
64
68
  setResultValue(result, targetKey, value);
65
69
  return;
@@ -77,7 +81,7 @@ function traverse(node, context) {
77
81
  if (!isYamlockPayload(value)) {
78
82
  throw createMigrationError(
79
83
  'ERR_MIGRATION_PLAINTEXT',
80
- `Selected value at ${currentPath} is not encrypted; narrow --paths to legacy payloads.`
84
+ `Selected value at ${currentPath} is not encrypted; narrow the path selectors to legacy payloads.`
81
85
  );
82
86
  }
83
87
 
@@ -124,6 +128,7 @@ function traverse(node, context) {
124
128
  * @param {Object} options
125
129
  * @param {string|Buffer} options.key
126
130
  * @param {string[]} [options.paths]
131
+ * @param {string[]} [options.pathPatterns]
127
132
  * @param {boolean} [options.allowMixed=false]
128
133
  * @returns {{ data: Object|Array, changed: boolean, stats: { selected: number, migrated: number, preservedV2: number } }}
129
134
  */
@@ -150,6 +155,7 @@ export function migrateConfig(node, options) {
150
155
  const data = traverse(node, {
151
156
  key: options.key,
152
157
  paths: normalizePaths(options.paths),
158
+ pathPatterns: compilePathPatterns(options.pathPatterns),
153
159
  allowMixed: options.allowMixed === true,
154
160
  parentPath: [],
155
161
  stats
@@ -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/docs/api.md CHANGED
@@ -45,12 +45,19 @@ options object with `algorithm`, `keyLength`, `ivLength`, and `authTagLength`.
45
45
  The algorithm stored in a payload is authoritative during decryption; sizing
46
46
  overrides exist only for low-level legacy compatibility.
47
47
 
48
- `processConfig` additionally accepts exact `paths`, `parentPath`, a custom
49
- `pathSerializer`, `nonStringPolicy`, and encrypt-only
48
+ `processConfig` additionally accepts exact `paths`, structural `pathPatterns`,
49
+ `parentPath`, a custom `pathSerializer`, `nonStringPolicy`, and encrypt-only
50
50
  `existingPayloadPolicy`. It returns a new config container and does not mutate
51
51
  the input. With `nonStringPolicy: 'stringify'`, selected finite JSON primitives
52
52
  may become strings, so the TypeScript return type is intentionally widened.
53
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
+
54
61
  See the [Node.js error contract](errors.md) and the
55
62
  [payload v2 design](design/payload-v2.md) for the security and serialization
56
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.
package/docs/errors.md CHANGED
@@ -53,6 +53,7 @@ repeat string literals.
53
53
  | `ERR_INVALID_FIELD_PATH` | The caller supplied an invalid field path. |
54
54
  | `ERR_INVALID_OPTIONS` | Crypto options have an invalid shape or unsupported override. |
55
55
  | `ERR_INVALID_MODE` | `processConfig` received an unknown mode. |
56
+ | `ERR_INVALID_PATH_PATTERNS` | A path pattern list or pattern syntax is invalid. |
56
57
  | `ERR_UNSUPPORTED_ALGORITHM` | The requested writer algorithm is unavailable or unsupported. |
57
58
  | `ERR_UNSUPPORTED_PAYLOAD_VERSION` | The payload or requested writer version is unsupported. |
58
59
  | `ERR_INVALID_PAYLOAD` | The payload is missing or malformed. |
@@ -63,7 +64,7 @@ repeat string literals.
63
64
  | `ERR_DECRYPTION_FAILED` | A legacy payload could not be decrypted. |
64
65
 
65
66
  `processConfig` also preserves its specific codes for invalid roots/options,
66
- policies, path serializers, path lists, circular input, path collisions,
67
+ policies, path serializers, exact path lists, path patterns, circular input, path collisions,
67
68
  non-string values, unsupported values, and already encrypted values. These are
68
69
  available through `YAMLOCK_ERROR_CODES` and use `YamlockConfigError`.
69
70
 
@@ -39,8 +39,9 @@ unchanged.
39
39
 
40
40
  1. Keep source YAML under version control or make a verified backup.
41
41
  2. Run with `--dry-run` to inspect the complete serialized result.
42
- 3. Use `--paths` to select every resolved path that should be encrypted,
43
- including values originally introduced through aliases or merge keys.
42
+ 3. Use exact `--paths` or structural `--path-patterns` to select every resolved
43
+ path that should be encrypted, including values originally introduced
44
+ through aliases or merge keys.
44
45
  4. Use `--output` when the source document's comments or formatting must remain
45
46
  untouched.
46
47
  5. Do not use unknown application-specific YAML tags in files processed by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yamlock",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "author": "PAVEL TKACHEV (phoenixweiss) <mail@phoenixweiss.me>",
5
5
  "description": "Value-level encryption for YAML/JSON configuration files with CLI + Node.js APIs.",
6
6
  "license": "MIT",