flecto 2.1.0 → 3.0.1

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,573 @@
1
+ /**
2
+ * Structural awareness of SOPS- and age-encrypted files.
3
+ *
4
+ * Flecto **never decrypts**. It never shells out to `sops` or `age`, never
5
+ * reads a key file, never touches an agent socket, and never sends ciphertext
6
+ * anywhere. That is the whole security proposition: pointing Flecto at an
7
+ * encrypted file must be exactly as safe as pointing `git diff` at it, minus
8
+ * the ciphertext noise.
9
+ *
10
+ * What it does instead is treat the *shape* of the file as the signal. An
11
+ * encrypted config still tells you a great deal without being opened:
12
+ *
13
+ * - which keys exist (added / removed / moved)
14
+ * - which encrypted values changed, and which did not
15
+ * - who can decrypt it — the recipient list in the `sops` metadata block
16
+ * - whether the file is still encrypted at all
17
+ *
18
+ * The mechanism is a normalization pass applied at parse time, before any
19
+ * other part of Flecto sees the tree. Every ciphertext-bearing string is
20
+ * replaced with an opaque sentinel, `<encrypted:SCHEME:DIGEST>`, where DIGEST
21
+ * is a truncated SHA-256 of the ciphertext. Because the substitution happens in
22
+ * the parser, ciphertext cannot reach a diff, a snapshot file, a webhook body,
23
+ * a PR comment, or an HTML report — there is no code path that carries it. The
24
+ * digest is what makes "this encrypted value changed" observable without
25
+ * revealing the value or even its length.
26
+ *
27
+ * Detection is content-based. Filenames are a hint at best: teams commit
28
+ * `secrets.yaml`, `values.prod.yaml`, and `db.json` that are fully encrypted,
29
+ * and `.sops.yaml` — which is the *creation-rules config*, not an encrypted
30
+ * file at all.
31
+ */
32
+
33
+ import { createHash } from 'crypto';
34
+
35
+ /**
36
+ * @typedef {'sops' | 'age' | 'pgp'} EncryptionScheme
37
+ * @typedef {EncryptionScheme | 'plaintext'} EncryptionState
38
+ */
39
+
40
+ /** Synthetic diff path carrying the file-level encryption state. */
41
+ export const ENCRYPTION_STATE_PATH = '<encryption>';
42
+
43
+ /** Synthetic diff path for the "MAC moved on its own" integrity signal. */
44
+ export const ENCRYPTION_MAC_PATH = '<encryption.mac>';
45
+
46
+ /** What an encrypted value collapses to in human-facing output. */
47
+ export const ENCRYPTED_DISPLAY = '<encrypted value>';
48
+
49
+ /** What a value that used to be encrypted collapses to once it is not. */
50
+ export const DECRYPTED_DISPLAY = '<no longer encrypted>';
51
+
52
+ /** Long enough that two versions of one value practically never collide. */
53
+ const DIGEST_CHARS = 12;
54
+
55
+ /**
56
+ * A SOPS-encrypted leaf, e.g.
57
+ * `ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]`. The cipher name and the
58
+ * `data:` field are both required, which is specific enough that a hand-written
59
+ * config value has essentially no chance of matching.
60
+ */
61
+ const SOPS_ENC_TOKEN_RE = /^ENC\[[A-Z0-9_]+,data:[^\]]*\]$/;
62
+
63
+ /** Armored age blob — a whole file, or a wrapped data key inside `sops.age`. */
64
+ const AGE_ARMOR_HEADER = '-----BEGIN AGE ENCRYPTED FILE-----';
65
+
66
+ /** Armored PGP blob — the wrapped data key inside `sops.pgp`. */
67
+ const PGP_MESSAGE_HEADER = '-----BEGIN PGP MESSAGE-----';
68
+
69
+ /** Matches exactly what `sentinel()` produces, and nothing else. */
70
+ const SENTINEL_RE = /^<encrypted:(sops|age|pgp):[0-9a-f]{12}>$/;
71
+
72
+ /** Key-provider groups SOPS writes into its metadata block, in its own order. */
73
+ const RECIPIENT_GROUPS = ['kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'age', 'pgp'];
74
+
75
+ /**
76
+ * The fields that identify a recipient within its group. Every one of these is
77
+ * a public identifier — a key ARN, an age public key, a PGP fingerprint — so
78
+ * they stay visible in the diff. They are the answer to "who can read this?",
79
+ * which is the single most useful thing an encrypted file can tell you.
80
+ * @type {Record<string, string[]>}
81
+ */
82
+ const RECIPIENT_ID_FIELDS = {
83
+ kms: ['arn'],
84
+ gcp_kms: ['resource_id'],
85
+ azure_kv: ['vault_url', 'name', 'version'],
86
+ hc_vault: ['vault_address', 'engine_path', 'key_name'],
87
+ age: ['recipient'],
88
+ pgp: ['fp'],
89
+ };
90
+
91
+ /**
92
+ * Paths whose change means "the SOPS MAC moved", relative to the document that
93
+ * owns the metadata block — the file root for an ordinary file, one document
94
+ * down for a multi-document one.
95
+ */
96
+ const MAC_PATHS = new Set(['sops.mac', 'sops_mac']);
97
+
98
+ /**
99
+ * @param {unknown} value
100
+ * @returns {value is Record<string, unknown>}
101
+ */
102
+ function isPlainObject(value) {
103
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
104
+ const proto = Object.getPrototypeOf(value);
105
+ return proto === Object.prototype || proto === null;
106
+ }
107
+
108
+ /**
109
+ * Build the opaque stand-in for one ciphertext.
110
+ * @param {EncryptionScheme} scheme
111
+ * @param {string} ciphertext
112
+ * @returns {string}
113
+ */
114
+ function sentinel(scheme, ciphertext) {
115
+ const digest = createHash('sha256').update(String(ciphertext), 'utf8').digest('hex');
116
+ return `<encrypted:${scheme}:${digest.slice(0, DIGEST_CHARS)}>`;
117
+ }
118
+
119
+ /**
120
+ * True when a value is one of Flecto's encrypted-value sentinels.
121
+ * @param {unknown} value
122
+ * @returns {boolean}
123
+ */
124
+ export function isEncryptedSentinel(value) {
125
+ return typeof value === 'string' && SENTINEL_RE.test(value);
126
+ }
127
+
128
+ /**
129
+ * The scheme recorded in a sentinel, or null when the value is not one.
130
+ * @param {unknown} value
131
+ * @returns {EncryptionScheme | null}
132
+ */
133
+ export function sentinelScheme(value) {
134
+ if (typeof value !== 'string') return null;
135
+ const match = SENTINEL_RE.exec(value);
136
+ return match ? /** @type {EncryptionScheme} */ (match[1]) : null;
137
+ }
138
+
139
+ /**
140
+ * Classify a raw string as ciphertext, without decrypting it.
141
+ * @param {unknown} value
142
+ * @returns {EncryptionScheme | null}
143
+ */
144
+ export function encryptedValueScheme(value) {
145
+ if (typeof value !== 'string') return null;
146
+ const trimmed = value.trim();
147
+ if (!trimmed) return null;
148
+ if (SOPS_ENC_TOKEN_RE.test(trimmed)) return 'sops';
149
+ if (trimmed.includes(AGE_ARMOR_HEADER)) return 'age';
150
+ if (trimmed.includes(PGP_MESSAGE_HEADER)) return 'pgp';
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * True when the whole file is one armored age blob rather than a config
156
+ * document. Such a file is not YAML or JSON in any useful sense, so the parser
157
+ * short-circuits it rather than letting js-yaml guess at the armor.
158
+ * @param {string} raw
159
+ * @returns {boolean}
160
+ */
161
+ export function isArmoredAgeFile(raw) {
162
+ return String(raw).trimStart().startsWith(AGE_ARMOR_HEADER);
163
+ }
164
+
165
+ /**
166
+ * The parsed form of a file that is nothing but ciphertext: a single sentinel.
167
+ * Diffing two versions reports that the blob changed, and nothing else — which
168
+ * is genuinely all an opaque file can tell you.
169
+ * @param {string} raw
170
+ * @returns {string}
171
+ */
172
+ export function opaqueFileState(raw) {
173
+ return sentinel('age', String(raw).trim());
174
+ }
175
+
176
+ /**
177
+ * True when a `sops:` value is really a SOPS metadata block.
178
+ *
179
+ * SOPS always writes `version` plus a MAC, a modification stamp, and at least
180
+ * one key group. Requiring `version` *and two of the other three* is what keeps
181
+ * an ordinary config that happens to pin `sops: { version: "3.9.0" }` from
182
+ * being mistaken for an encrypted file. Both the on-disk array shape and the
183
+ * normalized keyed-object shape are accepted, so this holds for a tree read
184
+ * back out of a snapshot too.
185
+ * @param {unknown} block
186
+ * @returns {boolean}
187
+ */
188
+ export function looksLikeSopsMetadata(block) {
189
+ if (!isPlainObject(block)) return false;
190
+ if (typeof block.version !== 'string' && typeof block.version !== 'number') return false;
191
+
192
+ let signals = 0;
193
+ if (typeof block.mac === 'string' && block.mac.trim()) signals += 1;
194
+ if (typeof block.lastmodified === 'string' && block.lastmodified.trim()) signals += 1;
195
+ if (RECIPIENT_GROUPS.some((group) => hasRecipientEntries(block[group]))) signals += 1;
196
+ return signals >= 2;
197
+ }
198
+
199
+ /**
200
+ * @param {unknown} group
201
+ * @returns {boolean}
202
+ */
203
+ function hasRecipientEntries(group) {
204
+ if (Array.isArray(group)) return group.some(isPlainObject);
205
+ if (isPlainObject(group)) return Object.values(group).some(isPlainObject);
206
+ return false;
207
+ }
208
+
209
+ /**
210
+ * SOPS's flat-format metadata, used for dotenv and INI files, where the block
211
+ * is spelled as `sops_version` / `sops_mac` / `sops_lastmodified` top-level
212
+ * keys instead of a nested map.
213
+ * @param {Record<string, unknown>} tree
214
+ * @returns {boolean}
215
+ */
216
+ function hasFlatSopsMetadata(tree) {
217
+ if (typeof tree.sops_version !== 'string' || !tree.sops_version.trim()) return false;
218
+ return typeof tree.sops_mac === 'string' || typeof tree.sops_lastmodified === 'string';
219
+ }
220
+
221
+ /**
222
+ * Top-level keys whose value carries a SOPS metadata block of its own.
223
+ *
224
+ * This is what a multi-document file looks like once `parseYamlStream` has
225
+ * wrapped its documents: the `sops` block of each `kind: Secret` sits one level
226
+ * down, under the document's identity key. Detection is by content —
227
+ * {@link looksLikeSopsMetadata}, which wants a version plus two more signals —
228
+ * and never by the shape of the key, so a config whose top-level keys happen to
229
+ * read like `Kind/ns/name` is not affected unless it genuinely contains a SOPS
230
+ * metadata block, in which case calling it encrypted is the right answer.
231
+ *
232
+ * Content rather than the parser's own multi-document signal, because this runs
233
+ * at diff time on trees that may have been read back out of a JSON snapshot,
234
+ * where no in-memory signal can have survived. Both sides of every diff are
235
+ * therefore judged by the same rule, which is what keeps a snapshot baseline
236
+ * from disagreeing with a freshly parsed file about whether a file is
237
+ * encrypted.
238
+ * @param {unknown} tree
239
+ * @returns {string[]}
240
+ */
241
+ function sopsDocumentKeys(tree) {
242
+ if (!isPlainObject(tree)) return [];
243
+ const keys = [];
244
+ for (const [key, doc] of Object.entries(tree)) {
245
+ if (isPlainObject(doc) && looksLikeSopsMetadata(doc.sops)) keys.push(key);
246
+ }
247
+ return keys;
248
+ }
249
+
250
+ /**
251
+ * The encryption state of an already-normalized tree.
252
+ *
253
+ * Cheap by construction: the root checks are O(1) and the per-document scan is
254
+ * one property read per top-level key, each of which bails out immediately
255
+ * unless that key holds a genuine SOPS metadata block. Every diff runs this on
256
+ * both sides, and an ordinary config must not pay for a feature it does not use.
257
+ *
258
+ * A multi-document file counts as encrypted when *any* of its documents is —
259
+ * the same reading as the single-document case, where one `sops` block makes
260
+ * the file encrypted. Losing it everywhere is what raises `<encryption>`.
261
+ * @param {unknown} tree
262
+ * @returns {EncryptionState}
263
+ */
264
+ export function encryptionState(tree) {
265
+ if (typeof tree === 'string') return sentinelScheme(tree) ?? 'plaintext';
266
+ if (!isPlainObject(tree)) return 'plaintext';
267
+ if (looksLikeSopsMetadata(tree.sops)) return 'sops';
268
+ if (hasFlatSopsMetadata(tree)) return 'sops';
269
+ return sopsDocumentKeys(tree).length > 0 ? 'sops' : 'plaintext';
270
+ }
271
+
272
+ /**
273
+ * Recursively map a tree, returning the *original reference* when nothing
274
+ * changed. That identity is load-bearing: it is how an unencrypted file proves
275
+ * it came out of the encryption pass untouched.
276
+ * @param {unknown} value
277
+ * @param {(leaf: string) => string} mapString
278
+ * @returns {unknown}
279
+ */
280
+ function mapStrings(value, mapString) {
281
+ if (typeof value === 'string') return mapString(value);
282
+
283
+ if (Array.isArray(value)) {
284
+ let changed = false;
285
+ const out = value.map((item) => {
286
+ const next = mapStrings(item, mapString);
287
+ if (next !== item) changed = true;
288
+ return next;
289
+ });
290
+ return changed ? out : value;
291
+ }
292
+
293
+ if (isPlainObject(value)) {
294
+ let changed = false;
295
+ /** @type {Record<string, unknown>} */
296
+ const out = {};
297
+ for (const [key, item] of Object.entries(value)) {
298
+ const next = mapStrings(item, mapString);
299
+ if (next !== item) changed = true;
300
+ out[key] = next;
301
+ }
302
+ return changed ? out : value;
303
+ }
304
+
305
+ return value;
306
+ }
307
+
308
+ /**
309
+ * Replace every ciphertext-bearing string in a tree with its sentinel.
310
+ * @param {unknown} tree
311
+ * @returns {unknown}
312
+ */
313
+ function redactCiphertext(tree) {
314
+ return mapStrings(tree, (leaf) => {
315
+ const scheme = encryptedValueScheme(leaf);
316
+ return scheme ? sentinel(scheme, leaf) : leaf;
317
+ });
318
+ }
319
+
320
+ /**
321
+ * The stable identity of one recipient entry, built from its public fields.
322
+ * @param {string} group
323
+ * @param {Record<string, unknown>} entry
324
+ * @returns {string | null}
325
+ */
326
+ function recipientIdentity(group, entry) {
327
+ const parts = [];
328
+ for (const field of RECIPIENT_ID_FIELDS[group] ?? []) {
329
+ const value = entry[field];
330
+ if (value == null || typeof value === 'object') continue;
331
+ const text = String(value).trim();
332
+ if (text) parts.push(text);
333
+ }
334
+ return parts.length > 0 ? parts.join('|') : null;
335
+ }
336
+
337
+ /**
338
+ * Redact the wrapped data key on a recipient entry. `enc` is the data key
339
+ * sealed to that recipient — ciphertext, and useless to a reader — while every
340
+ * other field on the entry identifies *who* the recipient is and stays.
341
+ * @param {string} group
342
+ * @param {Record<string, unknown>} entry
343
+ * @returns {Record<string, unknown>}
344
+ */
345
+ function redactRecipientEntry(group, entry) {
346
+ const enc = entry.enc;
347
+ if (typeof enc !== 'string' || !enc.trim() || isEncryptedSentinel(enc)) return entry;
348
+ const scheme = group === 'age' || group === 'pgp' ? group : 'sops';
349
+ return { ...entry, enc: sentinel(/** @type {EncryptionScheme} */ (scheme), enc) };
350
+ }
351
+
352
+ /**
353
+ * Re-key a recipient array by recipient identity.
354
+ *
355
+ * On disk these are arrays, so index diffing would report a recipient inserted
356
+ * at the front as "every recipient changed" — the opposite of the truth, and
357
+ * exactly the case where a reviewer must not be misled. Recipient order carries
358
+ * no meaning, so keying by identity turns the real event into a single `added`
359
+ * or `removed` at a path that names the key involved.
360
+ *
361
+ * All-or-nothing: if any entry lacks an identity or two share one, the array
362
+ * shape is kept so the diff stays faithful to the file.
363
+ * @param {string} group
364
+ * @param {unknown} value
365
+ * @returns {unknown}
366
+ */
367
+ function normalizeRecipientGroup(group, value) {
368
+ if (!Array.isArray(value) || value.length === 0 || !value.every(isPlainObject)) return value;
369
+
370
+ const redacted = value.map((entry) => redactRecipientEntry(group, entry));
371
+ const identities = value.map((entry) => recipientIdentity(group, entry));
372
+ const usable = identities.every((id) => id != null && id !== '__proto__')
373
+ && new Set(identities).size === identities.length;
374
+
375
+ if (!usable) {
376
+ return redacted.some((entry, index) => entry !== value[index]) ? redacted : value;
377
+ }
378
+ return Object.fromEntries(identities.map((id, index) => [id, redacted[index]]));
379
+ }
380
+
381
+ /**
382
+ * Normalize the recipient groups inside a SOPS metadata block.
383
+ * @param {Record<string, unknown>} block
384
+ * @returns {Record<string, unknown>}
385
+ */
386
+ function normalizeSopsBlock(block) {
387
+ let changed = false;
388
+ /** @type {Record<string, unknown>} */
389
+ const out = {};
390
+ for (const [key, value] of Object.entries(block)) {
391
+ const next = RECIPIENT_GROUPS.includes(key) ? normalizeRecipientGroup(key, value) : value;
392
+ if (next !== value) changed = true;
393
+ out[key] = next;
394
+ }
395
+ return changed ? out : block;
396
+ }
397
+
398
+ /**
399
+ * Replace `owner.sops` with its recipient-normalized form, or return `owner`
400
+ * itself when nothing needed re-keying.
401
+ * @param {Record<string, unknown>} owner
402
+ * @returns {Record<string, unknown>}
403
+ */
404
+ function normalizeSopsOwner(owner) {
405
+ if (!looksLikeSopsMetadata(owner.sops)) return owner;
406
+ const sops = normalizeSopsBlock(/** @type {Record<string, unknown>} */ (owner.sops));
407
+ // Spreading first keeps `sops` in its original position among the keys.
408
+ return sops === owner.sops ? owner : { ...owner, sops };
409
+ }
410
+
411
+ /**
412
+ * The parser's entry point: make a freshly parsed tree safe to carry.
413
+ *
414
+ * Returns the argument itself when the file holds no ciphertext, so an ordinary
415
+ * config is not merely equal to what it used to be — it is the same object.
416
+ *
417
+ * `documentKeys` are the synthetic keys `parseYamlStream` invented for a
418
+ * multi-document file. They are threaded in rather than rediscovered because
419
+ * this pass *rewrites diff paths* — it re-keys recipient arrays by identity —
420
+ * and that must happen for a document the parser really produced and nowhere
421
+ * else. A single-document config that merely embeds something SOPS-shaped keeps
422
+ * the paths it has always had.
423
+ * @param {unknown} tree
424
+ * @param {readonly string[]} [documentKeys]
425
+ * @returns {unknown}
426
+ */
427
+ export function normalizeEncrypted(tree, documentKeys = []) {
428
+ const redacted = redactCiphertext(tree);
429
+ if (!isPlainObject(redacted)) return redacted;
430
+
431
+ let out = normalizeSopsOwner(redacted);
432
+ for (const key of documentKeys) {
433
+ if (!Object.prototype.hasOwnProperty.call(out, key)) continue;
434
+ const doc = out[key];
435
+ if (!isPlainObject(doc)) continue;
436
+ const normalized = normalizeSopsOwner(doc);
437
+ if (normalized === doc) continue;
438
+ if (out === redacted) out = { ...redacted };
439
+ out[key] = normalized;
440
+ }
441
+ return out;
442
+ }
443
+
444
+ /**
445
+ * Collapse sentinels to human-readable text for display. Used by the terminal
446
+ * renderer; machine-readable outputs keep the sentinel, which is stable across
447
+ * runs and therefore useful to correlate.
448
+ * @param {unknown} value
449
+ * @returns {unknown}
450
+ */
451
+ export function displayEncrypted(value) {
452
+ return mapStrings(value, (leaf) => (isEncryptedSentinel(leaf) ? ENCRYPTED_DISPLAY : leaf));
453
+ }
454
+
455
+ /**
456
+ * @param {string} path
457
+ * @returns {boolean}
458
+ */
459
+ function isSopsMetadataPath(path) {
460
+ return path === 'sops'
461
+ || path.startsWith('sops.')
462
+ || path.startsWith('sops[')
463
+ || path.startsWith('sops_');
464
+ }
465
+
466
+ /**
467
+ * The metadata-relative form of a diff path: the path as the document that owns
468
+ * the `sops` block sees it. `sops.mac` for an ordinary file,
469
+ * `ConfigMap/prod/billing.sops.mac` → `sops.mac` for a multi-document one.
470
+ *
471
+ * `prefixes` are the top-level keys that carry their own metadata block, so a
472
+ * document without one never has its paths rewritten.
473
+ * @param {string} path
474
+ * @param {string[]} prefixes
475
+ * @returns {string}
476
+ */
477
+ function metadataRelativePath(path, prefixes) {
478
+ for (const prefix of prefixes) {
479
+ if (path.startsWith(prefix) && path[prefix.length] === '.') {
480
+ return path.slice(prefix.length + 1);
481
+ }
482
+ }
483
+ return path;
484
+ }
485
+
486
+ /**
487
+ * @param {EncryptionState} before
488
+ * @param {EncryptionState} after
489
+ * @returns {string}
490
+ */
491
+ function stateNote(before, after) {
492
+ if (after === 'plaintext') return 'file is no longer encrypted';
493
+ if (before === 'plaintext') return 'file is now encrypted';
494
+ return 'encryption scheme changed';
495
+ }
496
+
497
+ /**
498
+ * Add the change events that only exist for an encrypted file, and strip a
499
+ * value that stopped being encrypted.
500
+ *
501
+ * Called by `diffTrees` for every diff. When neither side is encrypted it
502
+ * returns the exact array it was given, so an ordinary config sees no new
503
+ * events, no copied objects, and no measurable cost. "Encrypted" here covers a
504
+ * multi-document file in which any single document is — otherwise a `kind:
505
+ * Secret` shipped alongside a plain ConfigMap would lose every protection
506
+ * below.
507
+ *
508
+ * Three things are derived that a key-by-key walk cannot express on its own:
509
+ *
510
+ * 1. `<encryption>` — the file gained or lost encryption. Losing it means
511
+ * plaintext secrets were committed, which is why the `sops` pack raises it
512
+ * as an error rather than a note.
513
+ * 2. `<encryption.mac>` — the SOPS MAC moved while every value it covers,
514
+ * encrypted or not, stayed put. Normal edits move both together, so this
515
+ * combination is a hand-edited or tampered metadata block.
516
+ * 3. A value that was ciphertext and is now a plain scalar is replaced with
517
+ * a marker. The point of the diff is that the value was exposed; printing
518
+ * it into a CI log would expose it again, to a wider audience.
519
+ *
520
+ * @param {unknown} before
521
+ * @param {unknown} after
522
+ * @param {import('./differ.js').ChangeEvent[]} events
523
+ * @returns {import('./differ.js').ChangeEvent[]}
524
+ */
525
+ export function annotateEncryptedChanges(before, after, events) {
526
+ const beforeState = encryptionState(before);
527
+ const afterState = encryptionState(after);
528
+ if (beforeState === 'plaintext' && afterState === 'plaintext') return events;
529
+
530
+ const out = events.map((event) => {
531
+ if (
532
+ event.type === 'changed'
533
+ && isEncryptedSentinel(event.before)
534
+ && !isEncryptedSentinel(event.after)
535
+ && (event.after == null || typeof event.after !== 'object')
536
+ ) {
537
+ return { ...event, after: DECRYPTED_DISPLAY, note: 'value is no longer encrypted' };
538
+ }
539
+ return event;
540
+ });
541
+
542
+ if (beforeState !== afterState) {
543
+ out.unshift({
544
+ type: 'changed',
545
+ path: ENCRYPTION_STATE_PATH,
546
+ before: beforeState,
547
+ after: afterState,
548
+ note: stateNote(beforeState, afterState),
549
+ });
550
+ }
551
+
552
+ if (beforeState !== 'plaintext' && afterState !== 'plaintext') {
553
+ // In a multi-document file the metadata blocks live under the document
554
+ // keys, so read every path relative to whichever document owns one.
555
+ const prefixes = [...new Set([...sopsDocumentKeys(before), ...sopsDocumentKeys(after)])];
556
+ const relative = (path) => metadataRelativePath(path, prefixes);
557
+ const macChanged = events.some(
558
+ (event) => event.type === 'changed' && MAC_PATHS.has(relative(event.path)),
559
+ );
560
+ const payloadChanged = events.some((event) => !isSopsMetadataPath(relative(event.path)));
561
+ if (macChanged && !payloadChanged) {
562
+ out.push({
563
+ type: 'changed',
564
+ path: ENCRYPTION_MAC_PATH,
565
+ before: 'consistent',
566
+ after: 'mac-only',
567
+ note: 'MAC changed but no value did',
568
+ });
569
+ }
570
+ }
571
+
572
+ return out;
573
+ }