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/CHANGELOG.md +257 -1
- package/README.md +12 -1
- package/drift.js +226 -0
- package/index.js +672 -152
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +369 -3
- package/src/differ.js +76 -12
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1057 -0
- package/src/pr-comment.js +75 -1
- package/src/pr-providers.js +8 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +27 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +672 -0
package/src/config.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|