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.
package/src/config.js CHANGED
@@ -1,5 +1,14 @@
1
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'fs';
2
- import { basename, dirname, join, relative, resolve, sep } from 'path';
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ readlinkSync,
7
+ realpathSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'fs';
11
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
3
12
  import fg from 'fast-glob';
4
13
  import yaml from 'js-yaml';
5
14
  import { isEnvFilename, parseContent } from './parser.js';
@@ -152,6 +161,22 @@ function isInside(candidate, root) {
152
161
  return candidate === root || candidate.startsWith(root + sep);
153
162
  }
154
163
 
164
+ /**
165
+ * Containment decided on the paths as written, with no symlink resolution and
166
+ * no canonicalization.
167
+ *
168
+ * Used for "was this named inside the project?", where following a link is
169
+ * exactly the thing that must not happen -- and where canonicalizing one side
170
+ * but not the other silently breaks on Windows.
171
+ * @param {string} candidate
172
+ * @param {string} root both already absolute, from the same base
173
+ * @returns {boolean}
174
+ */
175
+ function isLexicallyInside(candidate, root) {
176
+ const rel = relative(root, candidate);
177
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
178
+ }
179
+
155
180
  /**
156
181
  * Refuse a path that lives inside the project but reads from outside it through
157
182
  * a symlink.
@@ -202,7 +227,25 @@ export function assertTargetContained(file, cwd = process.cwd()) {
202
227
  // there.
203
228
  const nominal = join(canonical(dirname(given)), basename(given));
204
229
  // Named from outside the project: nothing was escaped, it was never inside.
205
- if (!isInside(nominal, root)) return;
230
+ //
231
+ // That judgement must also be made on the path *as given*, not only on a
232
+ // canonicalized parent -- the parent is a component an untrusted pull request
233
+ // controls. With a directory symlink (`repo/b -> /outside`),
234
+ // `canonical(dirname(given))` already points outside, so `nominal` looked
235
+ // externally-named and this returned without checking anything:
236
+ // `repo/b/baseline.json` read straight out of the project. Replacing the
237
+ // *file* with a link was refused; replacing its *directory* was the same
238
+ // effort and was not.
239
+ //
240
+ // That second judgement is deliberately **lexical** -- `resolve(cwd)` against
241
+ // `resolve(file)`, neither canonicalized. Comparing an as-given path against
242
+ // a *canonicalized* root is what made the first attempt at this fail on
243
+ // Windows and leak: `process.cwd()` reports the 8.3 short form
244
+ // (`C:\Users\RUNNER~1\...`) while `canonical()` returns the long one, so the
245
+ // two never shared a prefix, the check decided the path was externally named,
246
+ // and returned. Both sides here derive from the same `process.cwd()` spelling,
247
+ // so no normalization is needed and none can go wrong.
248
+ if (!isInside(nominal, root) && !isLexicallyInside(given, resolve(cwd))) return;
206
249
 
207
250
  const real = canonical(given);
208
251
  if (isInside(real, root)) return;
@@ -216,6 +259,329 @@ export function assertTargetContained(file, cwd = process.cwd()) {
216
259
  );
217
260
  }
218
261
 
262
+ /**
263
+ * Has the operator explicitly opted in to write destinations that `.flectorc`
264
+ * points outside the project?
265
+ * @returns {boolean}
266
+ */
267
+ function rcWritesAllowed() {
268
+ const raw = process.env.FLECTO_ALLOW_RC_WRITES;
269
+ return raw === '1' || String(raw).toLowerCase() === 'true';
270
+ }
271
+
272
+ /**
273
+ * Refuse a *write* destination that leaves the project.
274
+ *
275
+ * The read rule ({@link assertTargetContained}) is about escape, and treats a
276
+ * path named from outside the project as operator intent. A write cannot be read
277
+ * that way, because the path is not always the operator's: `output` and
278
+ * `baseline` can be declared in `.flectorc`, and on an untrusted pull request
279
+ * `.flectorc` is attacker-controlled. Both destinations carry content the
280
+ * attacker partly controls — the report embeds config values and file names, a
281
+ * baseline embeds rule ids, paths, and messages — so an unconstrained
282
+ * destination is an arbitrary file overwrite with partly chosen content, which
283
+ * on a CI runner is a shell profile, a workflow file, or an SSH config.
284
+ *
285
+ * Two rules, matching the provenance:
286
+ *
287
+ * - **Declared in `.flectorc`** — must resolve inside the project.
288
+ * `FLECTO_ALLOW_RC_WRITES=1` opts out, for a repository that genuinely
289
+ * configures a destination elsewhere.
290
+ * - **Any source** — must not leave the project through a symlink, on the path
291
+ * itself or on the directory it is written into. That is the shape a pull
292
+ * request can author without touching the workflow, and it is refused for the
293
+ * same reason a symlinked *target* is; `FLECTO_ALLOW_SYMLINK_TARGETS=1` opts
294
+ * out of this half, as it does for reads.
295
+ *
296
+ * Refusing loudly rather than falling back to a default path is deliberate: a
297
+ * report that silently went somewhere else is worse than one that was not
298
+ * written.
299
+ * @param {string} file destination path, already resolved against the cwd
300
+ * @param {{ option: string, fromCli?: boolean, cwd?: string }} context
301
+ * @throws {Error} when the destination escapes the project
302
+ */
303
+ export function assertWriteDestinationContained(file, context) {
304
+ const { option, fromCli = false } = context;
305
+ const cwd = context.cwd ?? process.cwd();
306
+ const lexicalRoot = resolve(cwd);
307
+ const root = canonical(lexicalRoot);
308
+ const given = resolve(cwd, file);
309
+ const shown = relative(lexicalRoot, given).split(sep).join('/') || given;
310
+
311
+ // A destination need not exist yet, and neither need the directories above it,
312
+ // so normalize from the nearest ancestor that does: canonical() cannot resolve
313
+ // a path that is not there, and its fallback leaves the spelling as written —
314
+ // which on Windows is an 8.3 short name that compares as a different directory
315
+ // from the long form `root` carries, making an in-project path look external.
316
+ const { base, rest } = nearestExistingDir(dirname(given));
317
+ const nominal = join(canonical(base), ...rest, basename(given));
318
+ // Either spelling counts as inside: the lexical form covers a path derived
319
+ // from the cwd, the canonical form a path named some other way.
320
+ const insideProject = isInside(given, lexicalRoot) || isInside(nominal, root);
321
+
322
+ if (!fromCli && !rcWritesAllowed() && !insideProject) {
323
+ throw new Error(
324
+ `Refusing to write "${option}" to ${given}: .flectorc points it outside the project.\n`
325
+ + '.flectorc is attacker-controlled on an untrusted pull request, and this file carries '
326
+ + 'config values and file names into whatever it overwrites.\n'
327
+ + `Pass ${option} on the command line for a destination outside the project, or set `
328
+ + 'FLECTO_ALLOW_RC_WRITES=1 if this repository genuinely configures one.',
329
+ );
330
+ }
331
+
332
+ if (symlinkTargetsAllowed()) return;
333
+
334
+ // Two ways a write leaves through a link: the destination is itself a link, or
335
+ // the nearest directory that exists on the way to it is. The second covers a
336
+ // path several levels deep whose parents have not been created yet.
337
+ for (const candidate of [given, base]) {
338
+ const real = linkDestination(candidate);
339
+ // Nothing there at all: no link to follow, so nothing to refuse.
340
+ if (real === null) continue;
341
+ // Named from outside the project: nothing escaped, it was never inside.
342
+ if (!isInside(candidate, lexicalRoot)) continue;
343
+ if (isInside(real, root)) continue;
344
+ throw new Error(
345
+ `Refusing to write "${option}" to "${shown}": it is a link out of the project, `
346
+ + `resolving to ${real}.\n`
347
+ + 'File names and links are attacker-controlled on an untrusted pull request, and '
348
+ + 'writing through one would overwrite a file outside the repository.\n'
349
+ + 'Set FLECTO_ALLOW_SYMLINK_TARGETS=1 if this link is intentional.',
350
+ );
351
+ }
352
+ }
353
+
354
+ /**
355
+ * How many links to walk before giving up. The OS gives up around 40; this only
356
+ * needs to outlast any chain a repository would legitimately contain.
357
+ */
358
+ const MAX_LINK_HOPS = 32;
359
+
360
+ /**
361
+ * Where a path actually lands, following links even when the chain ends
362
+ * somewhere that does not exist yet.
363
+ *
364
+ * `existsSync` follows links, so it reports *nothing* for a link whose target is
365
+ * missing — and `realpathSync` fails on one, leaving {@link canonical} to fall
366
+ * back to the path as written, which is still inside the project. So a link to a
367
+ * file the runner does not have yet reads as contained twice over. That is not
368
+ * the weaker half of this attack but the stronger one: overwriting needs the
369
+ * file to already be there, while a link to a file the runner does *not* have
370
+ * yet has Flecto create it. `~/.ssh/authorized_keys` is the one that stings,
371
+ * because sshd skips lines it cannot parse, so HTML wrapped around one
372
+ * attacker-authored line is still a usable key file.
373
+ *
374
+ * So the link is resolved by hand, hop by hop, and judged wherever it ends up.
375
+ * @param {string} path
376
+ * @param {number} [depth]
377
+ * @returns {string | null} null when there is nothing at `path` at all
378
+ */
379
+ function linkDestination(path, depth = 0) {
380
+ const stats = lstatSync(path, { throwIfNoEntry: false });
381
+ if (!stats) return null;
382
+ if (!stats.isSymbolicLink()) return canonical(path);
383
+
384
+ const target = resolve(dirname(path), readlinkSync(path));
385
+ // A cycle, or a chain long enough that the write would fail with ELOOP
386
+ // anyway: judge the hop we have rather than walking forever.
387
+ if (depth >= MAX_LINK_HOPS) return target;
388
+ // The next hop may not exist; normalize what is there and keep the rest as
389
+ // written, the same way an ordinary destination is normalized above.
390
+ const { base, rest } = nearestExistingDir(dirname(target));
391
+ return linkDestination(target, depth + 1) ?? join(canonical(base), ...rest, basename(target));
392
+ }
393
+
394
+ /**
395
+ * The nearest ancestor of a path that exists on disk, plus the segments below it
396
+ * that do not.
397
+ * @param {string} path
398
+ * @returns {{ base: string, rest: string[] }}
399
+ */
400
+ function nearestExistingDir(path) {
401
+ let current = resolve(path);
402
+ /** @type {string[]} */
403
+ const rest = [];
404
+ while (!existsSync(current)) {
405
+ const parent = dirname(current);
406
+ if (parent === current) break;
407
+ rest.unshift(basename(current));
408
+ current = parent;
409
+ }
410
+ return { base: current, rest };
411
+ }
412
+
413
+ /**
414
+ * Has the operator explicitly opted in to alert actions declared in `.flectorc`?
415
+ * @returns {boolean}
416
+ */
417
+ function rcAlertsAllowed() {
418
+ const raw = process.env.FLECTO_ALLOW_RC_ALERTS;
419
+ return raw === '1' || String(raw).toLowerCase() === 'true';
420
+ }
421
+
422
+ /**
423
+ * Options a pull request can turn into an action or corrupt an existing one,
424
+ * once it controls `.flectorc`, together with the reason and the CLI flag that
425
+ * names each one.
426
+ *
427
+ * `onAlertFailure` and `deliveryMode` are deliberately not here: `flecto init`
428
+ * writes them into the generated `.flectorc` itself, they only ever tune how
429
+ * an alert *already chosen by the operator* responds to failure, and they draw
430
+ * the same line `failOn` does elsewhere — a setting a repository is meant to
431
+ * delegate, not an action a settings file grants itself.
432
+ */
433
+ const ALERT_ACTION_OPTIONS = [
434
+ ['command', '--command', 'it spawns a shell command on every change'],
435
+ ['webhook', '--webhook', 'it sends change data to a URL'],
436
+ ['webhookHeader', '--webhook-header', 'it rides along on every request an operator-approved --webhook makes, and can override headers such as Content-Type'],
437
+ ];
438
+
439
+ /**
440
+ * Refuse an alert action declared in `.flectorc` rather than on the command
441
+ * line.
442
+ *
443
+ * `watch --command` spawns a shell command on every change, with diff data
444
+ * injected as `FLECTO_*` environment variables; `--webhook` POSTs that same
445
+ * data to a URL; `--webhook-header` rides along on that request. All three are
446
+ * actions, not settings — the same distinction `--update-baseline` draws — and
447
+ * all three merge through `resolveEffectiveOptions` with no other gate, so a
448
+ * `.flectorc` (or profile) naming one runs it on the next `flecto watch`. On an
449
+ * untrusted pull request `.flectorc` is a file the attacker wrote, which makes
450
+ * an rc-declared `command` unconstrained shell execution, an rc-declared
451
+ * `webhook` an exfiltration destination the attacker chose, and an rc-declared
452
+ * `webhookHeader` a way to override what an *operator-approved* webhook call
453
+ * sends — reachable even when `command`/`webhook` themselves came from the CLI.
454
+ *
455
+ * Any of the three named on the CLI is operator intent and unrestricted:
456
+ * `flecto watch config.yaml --command './notify.sh'` still works exactly as
457
+ * before. `FLECTO_ALLOW_RC_ALERTS=1` opts out for a repository that genuinely
458
+ * configures one in `.flectorc`. Refusing loudly rather than skipping silently
459
+ * is deliberate, for the same reason a stripped plugin or write destination is
460
+ * loud: an alert that quietly stopped firing would look like "nothing
461
+ * happened" instead of "this was refused".
462
+ * @param {Record<string, unknown>} effective
463
+ * @param {Record<string, unknown>} cliOverrides
464
+ * @throws {Error} when an alert action came from `.flectorc`/a profile
465
+ */
466
+ export function assertAlertActionsFromCli(effective, cliOverrides) {
467
+ if (rcAlertsAllowed()) return;
468
+ for (const [option, flag, reason] of ALERT_ACTION_OPTIONS) {
469
+ if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
470
+ const declared = Array.isArray(effective[option]) ? effective[option].join(', ') : effective[option];
471
+ throw new Error(
472
+ `Refusing "${option}" declared in .flectorc: ${reason}, `
473
+ + 'and .flectorc is attacker-controlled on an untrusted pull request.\n'
474
+ + `Declared: ${declared}\n`
475
+ + `Pass ${flag} on the command line instead, or set FLECTO_ALLOW_RC_ALERTS=1 `
476
+ + 'if this config is trusted.',
477
+ );
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Has the operator explicitly opted in to a baseline selected by `.flectorc`?
483
+ * @returns {boolean}
484
+ */
485
+ function rcBaselineAllowed() {
486
+ const raw = process.env.FLECTO_ALLOW_RC_BASELINE;
487
+ return raw === '1' || String(raw).toLowerCase() === 'true';
488
+ }
489
+
490
+ /**
491
+ * Refuse a `snapshotRef` declared in `.flectorc` rather than on the command
492
+ * line.
493
+ *
494
+ * `snapshotRef` names the baseline every change is measured against, so
495
+ * whoever sets it decides what "changed" means. That makes it an action in the
496
+ * same sense `--update-baseline` is one: a pull request that controls it can
497
+ * turn its own failing gate green without touching a policy, a suppression, or
498
+ * the gate's own `--fail-on`.
499
+ *
500
+ * The cheapest exploit needs no crafted value at all. A committed `.flectorc`
501
+ * carrying `{"defaults": {"snapshotRef": "HEAD"}}` points the baseline at the
502
+ * pull request's own tip, so every file is compared against itself, every diff
503
+ * is empty, and `flecto ci` exits 0 no matter what the change did. Confirmed
504
+ * end to end: a config that disables TLS and raises a pool size 100x fails the
505
+ * gate against the operator's ref and passes against an rc-declared `HEAD`.
506
+ *
507
+ * A ref named on the command line is operator intent and unrestricted, so
508
+ * `flecto ci config.yaml --snapshot-ref origin/main` is untouched. A repository
509
+ * that genuinely wants its baseline in the rc file sets
510
+ * `FLECTO_ALLOW_RC_BASELINE=1`, which is a statement that `.flectorc` is
511
+ * trusted there. Refusing loudly rather than falling back to a default baseline
512
+ * is deliberate, for the reason every other gate here refuses loudly: a gate
513
+ * that quietly measured against something else would still print a clean run.
514
+ * @param {Record<string, unknown>} effective
515
+ * @param {Record<string, unknown>} cliOverrides
516
+ * @throws {Error} when the baseline ref came from `.flectorc`/a profile
517
+ */
518
+ export function assertSnapshotRefFromCli(effective, cliOverrides) {
519
+ if (rcBaselineAllowed()) return;
520
+ // `snapshotFile` picks the baseline just as directly, so it is gated with it
521
+ // rather than left as the way around it.
522
+ for (const option of ['snapshotRef', 'snapshotFile']) {
523
+ if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
524
+ throw new Error(
525
+ `Refusing "${option}" declared in .flectorc: it chooses the baseline every change is`
526
+ + ' measured against, so a pull request that edits it decides what counts as changed --'
527
+ + ' pointing it at "HEAD", or at a file it committed, compares every file against itself'
528
+ + ' and exits 0.\n'
529
+ + `Declared: ${JSON.stringify(effective[option])}\n`
530
+ + `Pass --${option === 'snapshotRef' ? 'snapshot-ref' : 'snapshot-file'} on the command line`
531
+ + ' instead, or set FLECTO_ALLOW_RC_BASELINE=1 if this config is trusted.',
532
+ );
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Refuse a baseline ref that git would read as an option rather than a
538
+ * revision.
539
+ *
540
+ * The ref reaches `git show <rev>:<path>` as a single argv entry. `execFile`
541
+ * keeps it out of a shell, so there is no command injection here, but argv is
542
+ * not the same boundary as a shell: a ref beginning with `-` is still parsed by
543
+ * git as one of *its* options. `--output=pwned` turns the baseline read into a
544
+ * file write, and because the read then yields nothing, the baseline parses as
545
+ * an empty document, every key looks newly `added`, and the default
546
+ * `--fail-on changed,policy,error` never fires — an arbitrary write and a
547
+ * silent gate bypass from one string.
548
+ *
549
+ * Callers also pass `--end-of-options` so git itself refuses to read any
550
+ * operand as an option. This check is the belt to that suspenders: it names the
551
+ * problem in a message a human can act on, and it holds on a git too old for
552
+ * `--end-of-options` (added in 2.24).
553
+ * @param {string} ref
554
+ * @returns {string} the ref, unchanged, when it is safe to pass to git
555
+ * @throws {Error} when git would read the ref as an option
556
+ */
557
+ export function assertSafeGitRef(ref) {
558
+ if (typeof ref !== 'string') {
559
+ throw new Error('Refusing snapshot ref: it must be a string.');
560
+ }
561
+ if (ref.includes('..')) {
562
+ // `git show A..B` is a *range*: it succeeds and prints nothing, so the
563
+ // baseline parses empty, every key reads as `added`, and the default
564
+ // `--fail-on` never fires. Callers resolve refs through
565
+ // `rev-parse --verify <ref>^{commit}`, which already refuses a range; this
566
+ // refuses it by name, because "Needed a single revision" explains less. A
567
+ // valid ref cannot contain `..` in any case (git-check-ref-format).
568
+ throw new Error(
569
+ `Refusing snapshot ref "${ref}": ".." makes it a commit range rather than a single`
570
+ + ' revision, which git reads as an empty diff.',
571
+ );
572
+ }
573
+ if (ref.startsWith('-')) {
574
+ throw new Error(
575
+ `Refusing snapshot ref "${ref}": it starts with "-", so git would read it as an option`
576
+ + ' rather than a revision.',
577
+ );
578
+ }
579
+ if (/[\0\n\r]/.test(ref)) {
580
+ throw new Error('Refusing snapshot ref: it contains a newline or NUL byte.');
581
+ }
582
+ return ref;
583
+ }
584
+
219
585
  /**
220
586
  * Split a policy list that may arrive as an array or a comma-separated string.
221
587
  * @param {unknown} raw
package/src/differ.js CHANGED
@@ -179,6 +179,49 @@ function matchParts(patternParts, pathParts) {
179
179
  return true;
180
180
  }
181
181
 
182
+ /**
183
+ * The containers on the path currently being compared, one set per side.
184
+ *
185
+ * A cyclic tree makes the walk reach a node it is already inside. Descending
186
+ * again cannot find anything the in-progress comparison will not already report
187
+ * at a shorter path, so the back-edge is dropped rather than followed. Same
188
+ * ancestor-tracking pattern as `normalizeParsedValue` in parser.js and
189
+ * `collectLeaves` in policy.js: added on the way in, removed on the way out, so
190
+ * a value merely reached twice by separate branches (a DAG, not a cycle) is
191
+ * still compared in full on each branch.
192
+ *
193
+ * Either side repeating is enough to stop. Tracking the *pair* instead would be
194
+ * more precise -- it would keep descending where one side cycles and the other
195
+ * does not -- but two cyclic trees have |before| x |after| distinct pairs, so
196
+ * precision there buys a quadratic worst case. This is a guard against a walk
197
+ * that will not end; bounding it by the size of the input is the property worth
198
+ * having.
199
+ *
200
+ * Nothing downstream of the parser holds a cycle -- `normalizeParsedValue`
201
+ * replaces one with its circular sentinel before a tree ever gets here -- so
202
+ * this is a second line rather than the first. Without it the walk recurses
203
+ * until the stack overflows, which costs real time on the way down (the path
204
+ * string is rebuilt at every level) before throwing.
205
+ *
206
+ * This covers the structural walk only. `arraySignature` canonicalizes without
207
+ * it -- that runs per array element, where the same tracking cost ~15% on a
208
+ * 5000-element diff, and its `catch` already contains a cyclic element by
209
+ * falling back to identity comparison.
210
+ *
211
+ * It is not free: the set writes cost ~20% of `diffTrees`, which the benchmark
212
+ * puts at ~17% of a run, so ~3% end to end. Gating it behind a depth threshold
213
+ * removes that, and was tried -- but then a cycle is followed as far as the
214
+ * threshold, and every level of it emits a duplicate event under a
215
+ * `self.self.self...` path. Paying 3% to keep the output right is the better
216
+ * trade for a tool whose output is read on a pull request.
217
+ * @typedef {{ before: Set<object>, after: Set<object> }} Ancestors
218
+ */
219
+
220
+ /** @returns {Ancestors} */
221
+ function newAncestors() {
222
+ return { before: new Set(), after: new Set() };
223
+ }
224
+
182
225
  /**
183
226
  * Recursively diff two values at a given key path.
184
227
  * @param {unknown} before
@@ -186,8 +229,9 @@ function matchParts(patternParts, pathParts) {
186
229
  * @param {string} path
187
230
  * @param {ChangeEvent[]} events accumulator
188
231
  * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
232
+ * @param {Ancestors} ancestors containers on the current path
189
233
  */
190
- function diffValues(before, after, path, events, options = {}) {
234
+ function diffValues(before, after, path, events, options = {}, ancestors = newAncestors()) {
191
235
  const beforeIsObj = isPlainObject(before);
192
236
  const afterIsObj = isPlainObject(after);
193
237
  const beforeIsArr = Array.isArray(before);
@@ -195,13 +239,23 @@ function diffValues(before, after, path, events, options = {}) {
195
239
 
196
240
  // Both plain objects → recurse into keys
197
241
  if (beforeIsObj && afterIsObj) {
198
- diffObjects(before, after, path, events, options);
242
+ if (ancestors.before.has(before) || ancestors.after.has(after)) return;
243
+ ancestors.before.add(before);
244
+ ancestors.after.add(after);
245
+ diffObjects(before, after, path, events, options, ancestors);
246
+ ancestors.before.delete(before);
247
+ ancestors.after.delete(after);
199
248
  return;
200
249
  }
201
250
 
202
251
  // Both arrays → diff by index or identity key
203
252
  if (beforeIsArr && afterIsArr) {
204
- diffArrays(before, after, path, events, options);
253
+ if (ancestors.before.has(before) || ancestors.after.has(after)) return;
254
+ ancestors.before.add(before);
255
+ ancestors.after.add(after);
256
+ diffArrays(before, after, path, events, options, ancestors);
257
+ ancestors.before.delete(before);
258
+ ancestors.after.delete(after);
205
259
  return;
206
260
  }
207
261
 
@@ -247,8 +301,9 @@ function diffValues(before, after, path, events, options = {}) {
247
301
  * @param {string} basePath
248
302
  * @param {ChangeEvent[]} events
249
303
  * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
304
+ * @param {Ancestors} [ancestors]
250
305
  */
251
- function diffObjects(before, after, basePath, events, options = {}) {
306
+ function diffObjects(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
252
307
  const beforeKeys = new Set(Object.keys(before));
253
308
  const afterKeys = new Set(Object.keys(after));
254
309
 
@@ -272,7 +327,7 @@ function diffObjects(before, after, basePath, events, options = {}) {
272
327
  for (const key of beforeKeys) {
273
328
  if (afterKeys.has(key)) {
274
329
  const childPath = basePath ? `${basePath}.${key}` : key;
275
- diffValues(before[key], after[key], childPath, events, options);
330
+ diffValues(before[key], after[key], childPath, events, options, ancestors);
276
331
  }
277
332
  }
278
333
  }
@@ -366,8 +421,9 @@ function arraySignature(value) {
366
421
  * @param {string} basePath
367
422
  * @param {ChangeEvent[]} events
368
423
  * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
424
+ * @param {Ancestors} [ancestors]
369
425
  */
370
- function diffArrays(before, after, basePath, events, options = {}) {
426
+ function diffArrays(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
371
427
  const idKey = resolveArrayIdKey(before, after, options);
372
428
 
373
429
  if (idKey) {
@@ -380,7 +436,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
380
436
  if (!beforeMap.has(key)) {
381
437
  events.push({ type: 'added', path: childPath, after: afterItem.value });
382
438
  } else {
383
- diffValues(beforeMap.get(key).value, afterItem.value, childPath, events, options);
439
+ diffValues(beforeMap.get(key).value, afterItem.value, childPath, events, options, ancestors);
384
440
  }
385
441
  }
386
442
  for (const [key, beforeItem] of beforeMap) {
@@ -424,7 +480,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
424
480
  } else if (i >= after.length) {
425
481
  events.push({ type: 'removed', path: childPath, before: before[i] });
426
482
  } else {
427
- diffValues(before[i], after[i], childPath, events, options);
483
+ diffValues(before[i], after[i], childPath, events, options, ancestors);
428
484
  }
429
485
  }
430
486
  }
@@ -447,14 +503,22 @@ export function diffTrees(before, after, options = {}) {
447
503
  arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
448
504
  };
449
505
 
450
- // Root handling: avoid assuming object roots.
506
+ // Root handling: avoid assuming object roots. The roots are recorded here
507
+ // because these two calls bypass `diffValues`, which is where every other
508
+ // container is recorded -- without it a cycle back to the root would be
509
+ // caught one level deeper than it should be.
510
+ const ancestors = newAncestors();
451
511
  if (isPlainObject(before) && isPlainObject(after)) {
452
- diffObjects(before, after, '', events, diffOpts);
512
+ ancestors.before.add(before);
513
+ ancestors.after.add(after);
514
+ diffObjects(before, after, '', events, diffOpts, ancestors);
453
515
  } else if (Array.isArray(before) && Array.isArray(after)) {
454
- diffArrays(before, after, '', events, diffOpts);
516
+ ancestors.before.add(before);
517
+ ancestors.after.add(after);
518
+ diffArrays(before, after, '', events, diffOpts, ancestors);
455
519
  } else {
456
520
  // Compare as a single root value. Use "<root>" so we can still ignore it if desired.
457
- diffValues(before, after, '<root>', events, diffOpts);
521
+ diffValues(before, after, '<root>', events, diffOpts, ancestors);
458
522
  }
459
523
 
460
524
  // Encrypted files carry signals a key-by-key walk cannot express: the file