flecto 3.0.2 → 4.0.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,672 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'fs';
10
+ import { createHash } from 'crypto';
11
+ import { execFileSync } from 'child_process';
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
13
+
14
+ import { containsSecret, looksLikeSecretPath } from './secrets.js';
15
+ import { documentKeysOf, withDocumentKeys } from './documents.js';
16
+
17
+ /**
18
+ * Where snapshot history lives (#141).
19
+ *
20
+ * Until now there was one answer: `.flecto-snapshots/` in the working
21
+ * directory, keyed by a hash of each file's **absolute** path. That is right for
22
+ * a laptop and useless everywhere else — an ephemeral CI runner starts with an
23
+ * empty directory on every run, and even a cached one would key `/home/runner/
24
+ * work/repo/config/prod.yaml` differently from the `/Users/me/repo/...` the same
25
+ * file has on the machine that wrote the snapshot. So `history` and `report` are
26
+ * local-only by construction, and `ci` has to be handed `--snapshot-ref`.
27
+ *
28
+ * This module makes the store pluggable and adds one shared backend:
29
+ *
30
+ * - **`local`** — unchanged, still the default. Absolute-path hash, one
31
+ * `<id>.json` baseline plus timestamped history entries, written byte-for-byte
32
+ * as before so an existing `.flecto-snapshots/` keeps working.
33
+ * - **`shared`** — a git-tracked store under `.flecto/snapshots/`, designed to be
34
+ * committed and read back on a runner that has never seen the file before.
35
+ *
36
+ * Four things make the shared store shareable, and each is a deliberate
37
+ * difference from `local`:
38
+ *
39
+ * 1. **Keyed by repo-relative path**, resolved from the git top level, so the
40
+ * same config file has the same key on every checkout and from any
41
+ * subdirectory.
42
+ * 2. **One file per config file**, holding that file's history oldest-first, so
43
+ * a new snapshot is an appended block in a diff rather than a new file with a
44
+ * hashed name nobody can review.
45
+ * 3. **Stable serialization** — keys sorted at every level, one scalar per line.
46
+ * Reordering keys in the source config produces no diff at all, and a real
47
+ * change produces exactly the lines that changed.
48
+ * 4. **Masking is a recorded property of the store**, defaulting to on, because
49
+ * committing snapshots writes config values into git history permanently.
50
+ *
51
+ * Masked values are stored as a digest rather than dropped or replaced with a
52
+ * constant: `flecto:sha256:…` still *changes* when the underlying value
53
+ * rotates, so a masked store detects "the production key was rotated" without
54
+ * ever recording either key. The honest limitation is that a digest of a
55
+ * low-entropy value can be brute-forced — it is a change detector, not a vault.
56
+ */
57
+
58
+ export const SNAPSHOT_STORE_IDS = /** @type {const} */ (['local', 'shared']);
59
+ export const SNAPSHOT_MASK_MODES = /** @type {const} */ (['hash', 'none']);
60
+
61
+ export const LOCAL_SNAPSHOT_DIR = '.flecto-snapshots';
62
+ export const SHARED_SNAPSHOT_DIR = join('.flecto', 'snapshots');
63
+
64
+ /** Schema version of a shared-store file, for a future reshaping. */
65
+ export const SHARED_STORE_VERSION = 1;
66
+
67
+ /**
68
+ * How many snapshots per file the shared store keeps. An append-forever store
69
+ * inside a repository becomes its own problem, and drift is a recent-history
70
+ * question — nobody reviews the 200th-oldest snapshot of `prod.yaml`.
71
+ * `local` stays unbounded by default: it is not committed, and changing what an
72
+ * existing directory holds is not this change's business.
73
+ */
74
+ export const DEFAULT_SHARED_RETENTION = 20;
75
+
76
+ const MASK_PREFIX = 'flecto:sha256:';
77
+ const MASK_DIGEST_LENGTH = 12;
78
+
79
+ /**
80
+ * @typedef {'local' | 'shared'} SnapshotStoreId
81
+ * @typedef {'hash' | 'none'} SnapshotMaskMode
82
+ *
83
+ * @typedef {{
84
+ * file: string,
85
+ * state: unknown,
86
+ * documents?: string[],
87
+ * createdAt: string
88
+ * }} SnapshotRecord
89
+ *
90
+ * @typedef {{
91
+ * id: SnapshotStoreId,
92
+ * root: string,
93
+ * label: string,
94
+ * maskMode: SnapshotMaskMode,
95
+ * retention: number,
96
+ * emptyHint: string,
97
+ * exists: () => boolean,
98
+ * readLatest: (absFile: string) => SnapshotRecord | null,
99
+ * readHistory: () => SnapshotRecord[],
100
+ * write: (absFile: string, record: { state: unknown, documents?: string[], createdAt?: string })
101
+ * => { path: string, pruned: number, warning?: string }
102
+ * }} SnapshotStore
103
+ */
104
+
105
+ /**
106
+ * Validate and resolve the store selection into a store instance.
107
+ * @param {{
108
+ * store?: unknown,
109
+ * dir?: unknown,
110
+ * mask?: unknown,
111
+ * retention?: unknown,
112
+ * cwd?: string
113
+ * }} [options]
114
+ * @returns {SnapshotStore}
115
+ */
116
+ export function resolveSnapshotStore(options = {}) {
117
+ const cwd = options.cwd ?? process.cwd();
118
+ const id = normalizeStoreId(options.store);
119
+ const maskMode = normalizeMaskMode(options.mask, id);
120
+ const retention = normalizeRetention(options.retention, id);
121
+
122
+ if (id === 'local') {
123
+ const root = resolve(cwd, options.dir ? String(options.dir) : LOCAL_SNAPSHOT_DIR);
124
+ return createLocalStore({ root, retention, maskMode, cwd });
125
+ }
126
+
127
+ const projectRoot = resolveProjectRoot(cwd);
128
+ const root = options.dir
129
+ ? resolve(cwd, String(options.dir))
130
+ : resolve(projectRoot, SHARED_SNAPSHOT_DIR);
131
+ return createSharedStore({ root, projectRoot, retention, maskMode });
132
+ }
133
+
134
+ /**
135
+ * @param {unknown} raw
136
+ * @returns {SnapshotStoreId}
137
+ */
138
+ function normalizeStoreId(raw) {
139
+ if (raw === undefined || raw === null || raw === '') return 'local';
140
+ const id = String(raw);
141
+ if (!SNAPSHOT_STORE_IDS.includes(/** @type {SnapshotStoreId} */ (id))) {
142
+ throw new Error(`--snapshot-store must be one of: ${SNAPSHOT_STORE_IDS.join(', ')} (got "${id}")`);
143
+ }
144
+ return /** @type {SnapshotStoreId} */ (id);
145
+ }
146
+
147
+ /**
148
+ * Masking defaults **on** for the shared store and **off** for the local one.
149
+ * The inversion is the point: a local store is a scratch directory on the
150
+ * author's own machine, a shared one is headed for a commit, and a default that
151
+ * writes plaintext secrets into git history is not a default worth having.
152
+ * @param {unknown} raw
153
+ * @param {SnapshotStoreId} storeId
154
+ * @returns {SnapshotMaskMode}
155
+ */
156
+ function normalizeMaskMode(raw, storeId) {
157
+ if (raw === undefined || raw === null || raw === '') {
158
+ return storeId === 'shared' ? 'hash' : 'none';
159
+ }
160
+ const mode = String(raw);
161
+ if (!SNAPSHOT_MASK_MODES.includes(/** @type {SnapshotMaskMode} */ (mode))) {
162
+ throw new Error(`--snapshot-mask must be one of: ${SNAPSHOT_MASK_MODES.join(', ')} (got "${mode}")`);
163
+ }
164
+ return /** @type {SnapshotMaskMode} */ (mode);
165
+ }
166
+
167
+ /**
168
+ * @param {unknown} raw
169
+ * @param {SnapshotStoreId} storeId
170
+ * @returns {number} 0 means "keep everything"
171
+ */
172
+ function normalizeRetention(raw, storeId) {
173
+ if (raw === undefined || raw === null || raw === '') {
174
+ return storeId === 'shared' ? DEFAULT_SHARED_RETENTION : 0;
175
+ }
176
+ const retention = Number.parseInt(String(raw), 10);
177
+ if (!Number.isInteger(retention) || retention < 0) {
178
+ throw new Error('--snapshot-retention must be a non-negative integer (0 keeps every snapshot)');
179
+ }
180
+ return retention;
181
+ }
182
+
183
+ /**
184
+ * The directory repo-relative snapshot keys are measured from: the git top
185
+ * level when there is one, else the working directory.
186
+ *
187
+ * Using the top level rather than `process.cwd()` is what lets `flecto history`
188
+ * run from `services/api/` and still find the snapshots `flecto watch` wrote
189
+ * from the repository root — the same reason `--snapshot-ref` resolves paths
190
+ * through `git rev-parse` (#79).
191
+ * @param {string} cwd
192
+ * @returns {string}
193
+ */
194
+ function resolveProjectRoot(cwd) {
195
+ try {
196
+ const top = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
197
+ encoding: 'utf8',
198
+ stdio: ['ignore', 'pipe', 'ignore'],
199
+ }).trim();
200
+ if (top) return resolve(top);
201
+ } catch {
202
+ // Not a git repository, or git is not installed. A shared store still works
203
+ // relative to the working directory; it is only *sharing* it that wants git.
204
+ }
205
+ return resolve(cwd);
206
+ }
207
+
208
+ /* ------------------------------------------------------------------ local -- */
209
+
210
+ function snapshotIdForPath(absPath) {
211
+ const normalized = absPath.replaceAll('\\', '/');
212
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
213
+ }
214
+
215
+ /**
216
+ * The `.flecto-snapshots/` store, unchanged in every observable way: same
217
+ * filenames, same JSON, same absolute-path keying. It is wrapped in the store
218
+ * interface so the consumers have exactly one code path.
219
+ * @param {{ root: string, retention: number, maskMode: SnapshotMaskMode, cwd: string }} options
220
+ * @returns {SnapshotStore}
221
+ */
222
+ function createLocalStore({ root, retention, maskMode, cwd }) {
223
+ const label = `${relative(cwd, root).split(sep).join('/') || root}/`;
224
+
225
+ const baselinePath = (absFile) => join(root, `${snapshotIdForPath(absFile)}.json`);
226
+
227
+ /**
228
+ * Timestamped history entries per snapshot id, listed **once** per run and
229
+ * then maintained in memory. Probing the directory per file made writing N
230
+ * baselines cost N listings of O(N) entries each, which is quadratic in the
231
+ * number of tracked files (see `docs/performance.md`); the store is the only
232
+ * writer during a run, so keeping the map in step is exact.
233
+ * @type {Map<string, string[]> | null}
234
+ */
235
+ let historyNames = null;
236
+
237
+ const historyNamesById = () => {
238
+ if (historyNames) return historyNames;
239
+ historyNames = new Map();
240
+ if (!existsSync(root)) return historyNames;
241
+ for (const name of readdirSync(root)) {
242
+ const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
243
+ if (!match) continue;
244
+ const names = historyNames.get(match[1]) ?? [];
245
+ names.push(name);
246
+ historyNames.set(match[1], names);
247
+ }
248
+ for (const names of historyNames.values()) {
249
+ names.sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
250
+ }
251
+ return historyNames;
252
+ };
253
+
254
+ const historyPath = (absFile) => {
255
+ const id = snapshotIdForPath(absFile);
256
+ const taken = new Set(historyNamesById().get(id) ?? []);
257
+ let timestamp = Date.now();
258
+ while (taken.has(`${id}.${timestamp}.json`) || existsSync(join(root, `${id}.${timestamp}.json`))) {
259
+ timestamp += 1;
260
+ }
261
+ const name = `${id}.${timestamp}.json`;
262
+ historyNamesById().set(id, [...(historyNamesById().get(id) ?? []), name]);
263
+ return join(root, name);
264
+ };
265
+
266
+ return {
267
+ id: 'local',
268
+ root,
269
+ label,
270
+ maskMode,
271
+ retention,
272
+ emptyHint: `${label} holds none. Save one with "flecto watch <file> --snapshot",`
273
+ + ' or pass --snapshot-ref <git-ref> to diff against a committed revision instead',
274
+
275
+ exists: () => existsSync(root),
276
+
277
+ readLatest(absFile) {
278
+ const path = baselinePath(absFile);
279
+ if (!existsSync(path)) return null;
280
+ const snapshot = JSON.parse(readFileSync(path, 'utf8'));
281
+ return {
282
+ file: typeof snapshot?.file === 'string' ? snapshot.file : absFile,
283
+ state: restoreDocumentKeys(snapshot?.state ?? snapshot, snapshot?.documents),
284
+ documents: Array.isArray(snapshot?.documents) ? snapshot.documents.map(String) : undefined,
285
+ createdAt: snapshot?.createdAt ?? statSync(path).mtime.toISOString(),
286
+ };
287
+ },
288
+
289
+ readHistory() {
290
+ if (!existsSync(root)) return [];
291
+ const entries = readdirSync(root, { withFileTypes: true })
292
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
293
+ const timestamped = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
294
+ const withHistory = new Set(timestamped.map((entry) => entry.name.slice(0, 16)));
295
+ // A `<id>.json` with no timestamped sibling is a store written before
296
+ // history existed; it is that file's only snapshot.
297
+ const legacy = entries.filter((entry) =>
298
+ /^[a-f0-9]{16}\.json$/.test(entry.name) && !withHistory.has(entry.name.slice(0, 16)));
299
+
300
+ return [...timestamped, ...legacy].map((entry) => {
301
+ const path = join(root, entry.name);
302
+ const snapshot = JSON.parse(readFileSync(path, 'utf8'));
303
+ if (typeof snapshot?.file !== 'string') {
304
+ throw new Error(`Invalid snapshot file: ${path}`);
305
+ }
306
+ return {
307
+ file: snapshot.file,
308
+ state: restoreDocumentKeys(snapshot?.state ?? snapshot, snapshot?.documents),
309
+ documents: Array.isArray(snapshot?.documents) ? snapshot.documents.map(String) : undefined,
310
+ createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
311
+ };
312
+ });
313
+ },
314
+
315
+ write(absFile, record) {
316
+ mkdirSync(root, { recursive: true });
317
+ const createdAt = record.createdAt ?? new Date().toISOString();
318
+ const documents = record.documents ?? [];
319
+ const state = maskMode === 'hash' ? maskState(record.state) : record.state;
320
+ // Field order is the order this file has always written, so an unmasked
321
+ // local snapshot is byte-for-byte what it was before the store existed.
322
+ const snapshot = {
323
+ file: absFile,
324
+ state,
325
+ ...(documents.length > 0 ? { documents: [...documents] } : {}),
326
+ createdAt,
327
+ };
328
+ const serialized = JSON.stringify(snapshot, null, 2);
329
+
330
+ const id = snapshotIdForPath(absFile);
331
+ const path = baselinePath(absFile);
332
+ // A store written before timestamped history existed holds a baseline and
333
+ // nothing else; promote it to a history entry before it is overwritten, so
334
+ // the first `--snapshot` after an upgrade does not silently drop it.
335
+ if (existsSync(path) && (historyNamesById().get(id) ?? []).length === 0) {
336
+ const legacy = JSON.parse(readFileSync(path, 'utf8'));
337
+ writeFileSync(historyPath(absFile), `${JSON.stringify({
338
+ file: legacy.file ?? absFile,
339
+ state: legacy.state ?? legacy,
340
+ ...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
341
+ createdAt: legacy.createdAt ?? statSync(path).mtime.toISOString(),
342
+ }, null, 2)}`, 'utf8');
343
+ }
344
+
345
+ writeFileSync(path, serialized, 'utf8');
346
+ writeFileSync(historyPath(absFile), serialized, 'utf8');
347
+
348
+ let pruned = 0;
349
+ if (retention > 0) {
350
+ const names = historyNamesById().get(id) ?? [];
351
+ const excess = names.slice(0, Math.max(0, names.length - retention));
352
+ for (const name of excess) {
353
+ rmSync(join(root, name), { force: true });
354
+ pruned += 1;
355
+ }
356
+ historyNamesById().set(id, names.slice(excess.length));
357
+ }
358
+ return { path, pruned };
359
+ },
360
+ };
361
+ }
362
+
363
+ /* ----------------------------------------------------------------- shared -- */
364
+
365
+ /**
366
+ * @param {{ root: string, projectRoot: string, retention: number, maskMode: SnapshotMaskMode }} options
367
+ * @returns {SnapshotStore}
368
+ */
369
+ function createSharedStore({ root, projectRoot, retention, maskMode }) {
370
+ const label = `${relative(projectRoot, root).split(sep).join('/') || root}/`;
371
+
372
+ /**
373
+ * The store key for a file: its path relative to the project root, POSIX
374
+ * slashed. A file outside the project has no such key, and inventing one (a
375
+ * hash, an absolute path) would produce a store entry that is meaningless on
376
+ * any other checkout — so it is refused rather than written somewhere useless.
377
+ *
378
+ * "Outside" includes a file `relative` cannot reach at all: on Windows a target
379
+ * on another drive or a UNC share comes back *absolute* (`D:\etc\hosts`), not
380
+ * `..`-prefixed, and would otherwise be accepted as a key.
381
+ * @param {string} absFile
382
+ * @returns {string}
383
+ */
384
+ const keyFor = (absFile) => {
385
+ const rel = relative(projectRoot, resolve(absFile));
386
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
387
+ throw new Error(
388
+ `The shared snapshot store keys snapshots by repo-relative path, and "${absFile}" is`
389
+ + ` outside ${projectRoot}. Run Flecto from the repository that holds the file, or use`
390
+ + ' --snapshot-store local for files outside a repository.',
391
+ );
392
+ }
393
+ return rel.split(sep).join('/');
394
+ };
395
+
396
+ const pathForKey = (key) => join(root, `${key.split('/').join(sep)}.json`);
397
+
398
+ /** @returns {string[]} every `*.json` under the store root, POSIX-keyed */
399
+ const storeFiles = () => {
400
+ if (!existsSync(root)) return [];
401
+ /** @type {string[]} */
402
+ const out = [];
403
+ const walk = (dir) => {
404
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
405
+ const full = join(dir, entry.name);
406
+ if (entry.isDirectory()) walk(full);
407
+ else if (entry.isFile() && entry.name.endsWith('.json')) out.push(full);
408
+ }
409
+ };
410
+ walk(root);
411
+ return out.sort();
412
+ };
413
+
414
+ /**
415
+ * @param {string} path
416
+ * @returns {{ version: number, file: string, masking: SnapshotMaskMode, snapshots: any[] }}
417
+ */
418
+ const readStoreFile = (path) => {
419
+ let parsed;
420
+ try {
421
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
422
+ } catch (err) {
423
+ throw new Error(`Snapshot store file is not valid JSON: ${path}: ${err.message}`);
424
+ }
425
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.snapshots)) {
426
+ throw new Error(`Snapshot store file is malformed (expected a "snapshots" array): ${path}`);
427
+ }
428
+ if (typeof parsed.file !== 'string') {
429
+ throw new Error(`Snapshot store file is missing its "file" key: ${path}`);
430
+ }
431
+ return parsed;
432
+ };
433
+
434
+ return {
435
+ id: 'shared',
436
+ root,
437
+ label,
438
+ maskMode,
439
+ retention,
440
+ emptyHint: `${label} holds none. Save one with "flecto watch <file> --snapshot`
441
+ + ' --snapshot-store shared" and commit it, so every runner reads the same history',
442
+
443
+ exists: () => existsSync(root),
444
+
445
+ readLatest(absFile) {
446
+ const path = pathForKey(keyFor(absFile));
447
+ if (!existsSync(path)) return null;
448
+ const stored = readStoreFile(path);
449
+ const latest = stored.snapshots.at(-1);
450
+ if (!latest) return null;
451
+ return {
452
+ file: resolve(projectRoot, stored.file),
453
+ state: restoreDocumentKeys(latest.state, latest.documents),
454
+ documents: Array.isArray(latest.documents) ? latest.documents.map(String) : undefined,
455
+ createdAt: String(latest.createdAt ?? statSync(path).mtime.toISOString()),
456
+ };
457
+ },
458
+
459
+ readHistory() {
460
+ /** @type {SnapshotRecord[]} */
461
+ const records = [];
462
+ for (const path of storeFiles()) {
463
+ const stored = readStoreFile(path);
464
+ // Absolute, because every consumer compares a snapshot's file against
465
+ // resolved CLI targets. The stored form stays repo-relative.
466
+ const file = resolve(projectRoot, stored.file);
467
+ for (const entry of stored.snapshots) {
468
+ records.push({
469
+ file,
470
+ state: restoreDocumentKeys(entry?.state, entry?.documents),
471
+ documents: Array.isArray(entry?.documents) ? entry.documents.map(String) : undefined,
472
+ createdAt: String(entry?.createdAt ?? statSync(path).mtime.toISOString()),
473
+ });
474
+ }
475
+ }
476
+ return records;
477
+ },
478
+
479
+ write(absFile, record) {
480
+ const key = keyFor(absFile);
481
+ const path = pathForKey(key);
482
+ const createdAt = record.createdAt ?? new Date().toISOString();
483
+ const documents = record.documents ?? [];
484
+ const state = maskMode === 'hash' ? maskState(record.state) : record.state;
485
+
486
+ let snapshots = [];
487
+ let warning;
488
+ if (existsSync(path)) {
489
+ const stored = readStoreFile(path);
490
+ snapshots = stored.snapshots;
491
+ const previousMask = stored.masking === 'hash' ? 'hash' : 'none';
492
+ if (previousMask !== maskMode) {
493
+ // Say it rather than let the next diff read as "every secret changed",
494
+ // which is what switching masking mid-history looks like downstream.
495
+ warning = `${label}${key}.json was written with masking "${previousMask}" and this run`
496
+ + ` uses "${maskMode}". Existing entries keep the form they were written in, so the`
497
+ + ' next diff will report masked values as changed.';
498
+ }
499
+ }
500
+
501
+ snapshots.push({
502
+ createdAt,
503
+ ...(documents.length > 0 ? { documents: [...documents] } : {}),
504
+ state,
505
+ });
506
+
507
+ let pruned = 0;
508
+ if (retention > 0 && snapshots.length > retention) {
509
+ pruned = snapshots.length - retention;
510
+ snapshots = snapshots.slice(pruned);
511
+ }
512
+
513
+ mkdirSync(dirname(path), { recursive: true });
514
+ writeFileSync(path, `${stableStringify({
515
+ version: SHARED_STORE_VERSION,
516
+ file: key,
517
+ masking: maskMode,
518
+ snapshots,
519
+ })}\n`, 'utf8');
520
+ return { path, pruned, warning };
521
+ },
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Put the parser's multi-document signal back on a state read out of a store.
527
+ * A stored snapshot is plain JSON, so the in-memory marking is gone; one written
528
+ * before the field existed leaves the provenance unknown, which is what it is.
529
+ * @param {unknown} state
530
+ * @param {unknown} documents
531
+ * @returns {unknown} the same state
532
+ */
533
+ function restoreDocumentKeys(state, documents) {
534
+ if (!Array.isArray(documents)) return state;
535
+ return withDocumentKeys(state, documents.map(String));
536
+ }
537
+
538
+ /* ------------------------------------------------------------ serialization */
539
+
540
+ /**
541
+ * JSON with object keys sorted at every level, two-space indent, one scalar per
542
+ * line — the serialization a committed store needs.
543
+ *
544
+ * `JSON.stringify` preserves insertion order, which is the parser's order, which
545
+ * is the file's order: moving a key in `prod.yaml` would rewrite the snapshot
546
+ * from that point down and bury the one line that actually changed. Sorting
547
+ * makes the diff of a snapshot the diff of the config's *meaning*, which is the
548
+ * whole premise of the tool.
549
+ * @param {unknown} value
550
+ * @returns {string}
551
+ */
552
+ export function stableStringify(value) {
553
+ return JSON.stringify(sortDeep(value), null, 2);
554
+ }
555
+
556
+ /**
557
+ * @param {unknown} value
558
+ * @returns {unknown}
559
+ */
560
+ function sortDeep(value) {
561
+ if (Array.isArray(value)) return value.map(sortDeep);
562
+ if (!isPlainObject(value)) return value;
563
+ const out = {};
564
+ for (const key of Object.keys(/** @type {object} */ (value)).sort()) {
565
+ // defineProperty so a config key literally named `__proto__` stays an
566
+ // ordinary own property instead of reaching Object.prototype, matching how
567
+ // the parser and the differ handle the same name.
568
+ Object.defineProperty(out, key, {
569
+ value: sortDeep(/** @type {Record<string, unknown>} */ (value)[key]),
570
+ enumerable: true,
571
+ writable: true,
572
+ configurable: true,
573
+ });
574
+ }
575
+ return out;
576
+ }
577
+
578
+ /**
579
+ * @param {unknown} value
580
+ * @returns {boolean}
581
+ */
582
+ function isPlainObject(value) {
583
+ if (!value || typeof value !== 'object') return false;
584
+ const proto = Object.getPrototypeOf(value);
585
+ return proto === Object.prototype || proto === null;
586
+ }
587
+
588
+ /* ---------------------------------------------------------------- masking -- */
589
+
590
+ /**
591
+ * The stored form of a secret value: a truncated digest, prefixed so it is
592
+ * obvious in a review what it is and where it came from.
593
+ * @param {string} value
594
+ * @returns {string}
595
+ */
596
+ export function maskedDigest(value) {
597
+ return MASK_PREFIX + createHash('sha256').update(value).digest('hex').slice(0, MASK_DIGEST_LENGTH);
598
+ }
599
+
600
+ /**
601
+ * True for a value this module produced, so masking is idempotent: re-masking a
602
+ * store that already holds digests must not digest the digests.
603
+ * @param {unknown} value
604
+ * @returns {boolean}
605
+ */
606
+ export function isMaskedDigest(value) {
607
+ return typeof value === 'string' && value.startsWith(MASK_PREFIX);
608
+ }
609
+
610
+ /**
611
+ * Replace every secret-like value in a parsed state with its digest.
612
+ *
613
+ * Two things make a value secret-like, and the store needs both:
614
+ *
615
+ * - **Its shape** — an opaque high-entropy string, a private key block, a URL
616
+ * with credentials in it. Whole values, not just the matched span: a
617
+ * connection string keeps its shape under `redactSecretString`, and a shape
618
+ * that stays constant while the embedded credential rotates is exactly the
619
+ * silent all-clear a drift store must not produce.
620
+ * - **Its key name** — `password: hunter2` is a credential and looks like
621
+ * nothing at all. This is the half the terminal has always masked, and it is
622
+ * the half that matters more here: `--mask-secrets` keeps a value out of a
623
+ * log that scrolls away, while this store is *committed*, so a value it
624
+ * records in plaintext is in git history permanently.
625
+ *
626
+ * Matching the two makes the store no more permissive than the renderer, which
627
+ * is the only defensible relationship between them.
628
+ *
629
+ * Containers are walked rather than collapsed. The renderer replaces a whole
630
+ * subtree under a secret-looking key with `***`, which is right for display and
631
+ * wrong for a store: a single digest standing in for an object would report
632
+ * "something under here changed" without ever saying what, and the point of the
633
+ * store is that a real change produces exactly the lines that changed.
634
+ * @param {unknown} state
635
+ * @param {string} [path] configuration path of `state`, for key-name matching
636
+ * @returns {unknown}
637
+ */
638
+ export function maskState(state, path = '') {
639
+ if (Array.isArray(state)) return state.map((entry, index) => maskState(entry, `${path}[${index}]`));
640
+
641
+ if (isPlainObject(state)) {
642
+ const documents = documentKeysOf(state);
643
+ const documentKeys = new Set(documents ?? []);
644
+ const masked = Object.fromEntries(
645
+ Object.entries(/** @type {Record<string, unknown>} */ (state)).map(([key, value]) => [
646
+ key,
647
+ // A document identity is a resource name the user chose, not a key
648
+ // name: a Deployment called `token-service` must not make every value
649
+ // inside it read as a secret. The renderer strips the same prefix via
650
+ // `secretMatchPath`; here the document keys are the root keys, so
651
+ // skipping them in the path is the same rule.
652
+ maskState(value, documentKeys.has(key) ? path : (path ? `${path}.${key}` : key)),
653
+ ]),
654
+ );
655
+ // Rebuilding the root drops the parser's multi-document marking, which the
656
+ // stripping above reads. Carry it across rather than making the masked tree
657
+ // look single-document.
658
+ return documents ? withDocumentKeys(masked, documents) : masked;
659
+ }
660
+
661
+ // Nothing to hide in an absent value, and digesting it would invent a secret
662
+ // where the config says there is none.
663
+ if (state === null || state === undefined) return state;
664
+ // Idempotent: re-masking a store that already holds digests must not digest
665
+ // the digests.
666
+ if (isMaskedDigest(state)) return state;
667
+ // `String(state)` because a credential is not always a string — `password:
668
+ // 12345` parses as a number, and it is still the password.
669
+ if (looksLikeSecretPath(path)) return maskedDigest(String(state));
670
+ if (typeof state === 'string') return containsSecret(state) ? maskedDigest(state) : state;
671
+ return state;
672
+ }