@agent-delivery-harness/cli 0.7.4 → 0.8.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.
- package/package.json +2 -2
- package/src/boundary.ts +16 -5
- package/src/check-snapshot.ts +297 -36
- package/src/command-activity.ts +15 -1
- package/src/commands/admit.ts +5 -4
- package/src/commands/emit-review-evidence.ts +1 -0
- package/src/commands/gate.ts +59 -24
- package/src/commands/prepare.ts +22 -13
- package/src/commands/record.ts +136 -122
- package/src/commands/resume.ts +3 -0
- package/src/commands/review-context.ts +1 -0
- package/src/commands/save-context.ts +9 -2
- package/src/commands/submit-evidence.ts +18 -4
- package/src/commands/verify.ts +5 -0
- package/src/declared-checks.ts +4 -4
- package/src/delivery-posture.ts +39 -0
- package/src/installed-data-resources.ts +57 -0
- package/src/installed-data-worker.ts +83 -0
- package/src/review-evidence.ts +1 -1
- package/src/scoped-attempts.ts +34 -8
- package/src/scoped-budget.ts +20 -0
- package/src/scoped-checks.ts +561 -59
- package/src/scoped-diagnostics.ts +82 -4
- package/src/scoped-installed-data.ts +8 -0
- package/src/snapshot-inventory-worker.ts +160 -18
|
@@ -5,7 +5,7 @@ import { CheckSnapshotError } from "./check-snapshot.ts";
|
|
|
5
5
|
import { projectScopedAttempt } from "./scoped-observations.ts";
|
|
6
6
|
|
|
7
7
|
const phases = ["snapshot-setup", "pre-command-verification", "command", "post-command-verification", "output-capture", "complete"] as const;
|
|
8
|
-
const failureCodes = ["check_snapshot_interrupted", "check_snapshot_timeout", "check_snapshot_unavailable", "check_snapshot_escape", "check_snapshot_drift", "check_snapshot_cleanup_failed", "check_dependency_source_overlap", "check_dependency_failed", "check_command_failed", "check_output_missing", "check_attempt_superseded", "check_artifact_unavailable", "check_evidence_rejected"] as const;
|
|
8
|
+
const failureCodes = ["check_snapshot_interrupted", "check_snapshot_timeout", "check_snapshot_unavailable", "check_snapshot_escape", "check_snapshot_drift", "check_snapshot_cleanup_failed", "check_dependency_source_overlap", "check_dependency_failed", "check_command_failed", "check_output_missing", "check_attempt_superseded", "check_artifact_unavailable", "check_evidence_rejected", "check_cancelled"] as const;
|
|
9
9
|
const executionCodes = ["ENOENT", "EACCES", "EPERM", "ABORT_ERR", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", "SIGKILL", "SIGTERM", "execution_failed"] as const;
|
|
10
10
|
export type ScopedDiagnosticPhase = typeof phases[number];
|
|
11
11
|
export type ScopedDiagnosticFailureCode = typeof failureCodes[number];
|
|
@@ -18,6 +18,21 @@ export interface ScopedDependencyDiagnostic {
|
|
|
18
18
|
readonly executionErrorCode?: ScopedDiagnosticExecutionCode;
|
|
19
19
|
readonly command: ScopedDiagnosticCommand;
|
|
20
20
|
}
|
|
21
|
+
const setupPhases = ["materialization", "baseline-inventory", "dependencies", "post-dependency-inventory", "warm-base-wait", "clone", "pre-command-verification"] as const;
|
|
22
|
+
/** How this attempt got the tree its command ran in, and what that cost up to the command.
|
|
23
|
+
* `executed`: this attempt ran the setup (a tree it built, or the warm base it then cloned);
|
|
24
|
+
* `reused`: the setup had already run (a clone of a warm base another check built, or the
|
|
25
|
+
* worker's tree retained from its previous check). `clone` names the copy-on-write method, or says
|
|
26
|
+
* why each worker built its own tree: cloning is unavailable here, or the setup wrote its own path
|
|
27
|
+
* into the tree (`path-bound`), or there is one worker (`not-used`). */
|
|
28
|
+
export interface ScopedSetupDiagnostic {
|
|
29
|
+
readonly action: "executed" | "reused";
|
|
30
|
+
readonly snapshot: "built" | "cloned" | "retained";
|
|
31
|
+
readonly clone: "clonefile" | "reflink" | "unavailable" | "path-bound" | "not-used";
|
|
32
|
+
/** `index`: Git's index and untracked listing; `inventory`: every immutable byte hashed. */
|
|
33
|
+
readonly verification: "index" | "inventory";
|
|
34
|
+
readonly phases: readonly { readonly phase: typeof setupPhases[number]; readonly durationMs: number }[];
|
|
35
|
+
}
|
|
21
36
|
export interface RecordedScopedAttemptDiagnostic {
|
|
22
37
|
readonly availability: "available";
|
|
23
38
|
readonly phase: ScopedDiagnosticPhase;
|
|
@@ -26,6 +41,8 @@ export interface RecordedScopedAttemptDiagnostic {
|
|
|
26
41
|
readonly command: ScopedDiagnosticCommand;
|
|
27
42
|
/** Present only when this attempt actually ran dependency setup; never borrowed from a reused snapshot. */
|
|
28
43
|
readonly dependency?: ScopedDependencyDiagnostic;
|
|
44
|
+
/** Present once this attempt began getting its tree, so a failed setup keeps its phases; absent on attempts recorded before setup was measured. */
|
|
45
|
+
readonly setup?: ScopedSetupDiagnostic;
|
|
29
46
|
}
|
|
30
47
|
export type ScopedAttemptDiagnostic = RecordedScopedAttemptDiagnostic
|
|
31
48
|
| { readonly availability: "unavailable"; readonly reason: "legacy" | "running" };
|
|
@@ -43,8 +60,55 @@ export function scopedDiagnosticFailure(error: unknown, executionErrorCode?: str
|
|
|
43
60
|
return { code: error.code as ScopedDiagnosticFailureCode,
|
|
44
61
|
...((executionCodes as readonly string[]).includes(executionErrorCode ?? "") ? { executionErrorCode: executionErrorCode as ScopedDiagnosticExecutionCode } : {}) };
|
|
45
62
|
}
|
|
63
|
+
/** Shortest credential prefix masked where later output interrupts it. Any prefix
|
|
64
|
+
* ending the text is masked; mid-text, a shorter run is indistinguishable from
|
|
65
|
+
* ordinary output sharing a credential's leading characters. */
|
|
66
|
+
export const INTERRUPTED_CREDENTIAL_PREFIX = 8;
|
|
67
|
+
/** Masks every occurrence of each credential, overlapping ones included, and the
|
|
68
|
+
* credential prefixes a writer that exits, is killed or is clipped mid-write
|
|
69
|
+
* leaves behind: at the end of the text, or interrupted by whatever writes next
|
|
70
|
+
* to the same stream (a wrapping shell's own output). Apply it to each captured
|
|
71
|
+
* stream before joining or bounding them. */
|
|
46
72
|
export function redactScopedOutput(text: string, secrets: readonly string[]): string {
|
|
47
|
-
|
|
73
|
+
// Ascending start order makes a union of overlapping spans one append-or-extend step.
|
|
74
|
+
const union = (spans: [number, number][], [start, end]: [number, number]) => {
|
|
75
|
+
const last = spans.at(-1);
|
|
76
|
+
if (last && last[1] >= start) last[1] = Math.max(last[1], end); else spans.push([start, end]);
|
|
77
|
+
return spans;
|
|
78
|
+
};
|
|
79
|
+
// A byte clip inside a multi-byte character decodes to U+FFFD after the prefix it cut. Counted
|
|
80
|
+
// backwards: an anchored /\uFFFD+$/ backtracks quadratically over a run that does not end the text.
|
|
81
|
+
let tail = text.length;
|
|
82
|
+
while (tail > 0 && text.charCodeAt(tail - 1) === 0xfffd) tail--;
|
|
83
|
+
const spans: [number, number][] = [];
|
|
84
|
+
for (const secret of new Set(secrets.filter(Boolean))) {
|
|
85
|
+
// Knuth-Morris-Pratt: linear in the output however self-similar the credential.
|
|
86
|
+
const border = [0];
|
|
87
|
+
for (let i = 1, k = 0; i < secret.length; i++) {
|
|
88
|
+
while (k > 0 && secret.charCodeAt(i) !== secret.charCodeAt(k)) k = border[k - 1]!;
|
|
89
|
+
border.push(k += secret.charCodeAt(i) === secret.charCodeAt(k) ? 1 : 0);
|
|
90
|
+
}
|
|
91
|
+
// The candidate match's start never moves backwards, so spans arrive in ascending start order.
|
|
92
|
+
const own: [number, number][] = [];
|
|
93
|
+
let matched = 0, atTail = 0;
|
|
94
|
+
for (let i = 0; i < text.length; i++) {
|
|
95
|
+
if (i === tail) atTail = matched;
|
|
96
|
+
if (matched >= INTERRUPTED_CREDENTIAL_PREFIX && text.charCodeAt(i) !== secret.charCodeAt(matched)) union(own, [i - matched, i]);
|
|
97
|
+
while (matched > 0 && text.charCodeAt(i) !== secret.charCodeAt(matched)) matched = border[matched - 1]!;
|
|
98
|
+
if (text.charCodeAt(i) === secret.charCodeAt(matched)) matched++;
|
|
99
|
+
if (matched === secret.length) { union(own, [i + 1 - matched, i + 1]); matched = border[matched - 1]!; }
|
|
100
|
+
}
|
|
101
|
+
if (tail === text.length) atTail = matched;
|
|
102
|
+
for (const span of own) spans.push(span);
|
|
103
|
+
// The matcher's state at the tail is the longest credential prefix ending the text.
|
|
104
|
+
if (atTail > 0) spans.push([tail - atTail, text.length]);
|
|
105
|
+
}
|
|
106
|
+
const merged = spans.sort((a, b) => a[0] - b[0]).reduce(union, []);
|
|
107
|
+
return merged.map(([start], i) => text.slice(i ? merged[i - 1]![1] : 0, start) + "[REDACTED]").join("") + text.slice(merged.at(-1)?.[1] ?? 0);
|
|
108
|
+
}
|
|
109
|
+
/** Each stream is redacted on its own: one can end inside a credential the join would hide mid-text. */
|
|
110
|
+
export function redactScopedStreams(result: Pick<ExecOutcome, "stdout" | "stderr">, secrets: readonly string[]): string {
|
|
111
|
+
return `${redactScopedOutput(result.stdout, secrets)}\n${redactScopedOutput(result.stderr, secrets)}`;
|
|
48
112
|
}
|
|
49
113
|
export function scopedCommandDiagnostic(result: ExecOutcome, secrets: readonly string[]): ScopedDiagnosticCommand {
|
|
50
114
|
// execFile can clip a fully emitted credential before full-value redaction.
|
|
@@ -52,7 +116,7 @@ export function scopedCommandDiagnostic(result: ExecOutcome, secrets: readonly s
|
|
|
52
116
|
if (result.errorCode === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return { unavailable: "not-completed" };
|
|
53
117
|
const numericExit = result.errorCode === undefined || result.errorCode === String(result.code);
|
|
54
118
|
if (!numericExit && result.stdout.length + result.stderr.length === 0) return { unavailable: ["ENOENT", "EACCES", "EPERM"].includes(result.errorCode!) ? "not-started" : "not-completed" };
|
|
55
|
-
const redacted =
|
|
119
|
+
const redacted = redactScopedStreams(result, secrets);
|
|
56
120
|
return { exitCode: numericExit ? result.code : null, outputTail: redacted.slice(-4000), truncated: redacted.length > 4000 };
|
|
57
121
|
}
|
|
58
122
|
|
|
@@ -91,8 +155,22 @@ function projectDiagnostic(value: unknown): RecordedScopedAttemptDiagnostic {
|
|
|
91
155
|
dependency = { durationMs: value["durationMs"], command: commandDiagnostic(value["command"]),
|
|
92
156
|
...("executionErrorCode" in value ? { executionErrorCode: value["executionErrorCode"] as ScopedDiagnosticExecutionCode } : {}) };
|
|
93
157
|
}
|
|
158
|
+
let setup: ScopedSetupDiagnostic | undefined;
|
|
159
|
+
if ("setup" in row) {
|
|
160
|
+
const value = object(row["setup"]);
|
|
161
|
+
const one = <T extends string>(field: string, allowed: readonly T[]): T => (allowed as readonly unknown[]).includes(value[field]) ? value[field] as T : corrupt();
|
|
162
|
+
const phasesValue = value["phases"];
|
|
163
|
+
if (!Array.isArray(phasesValue) || phasesValue.length > 16) return corrupt();
|
|
164
|
+
setup = { action: one("action", ["executed", "reused"]), snapshot: one("snapshot", ["built", "cloned", "retained"]),
|
|
165
|
+
clone: one("clone", ["clonefile", "reflink", "unavailable", "path-bound", "not-used"]), verification: one("verification", ["index", "inventory"]),
|
|
166
|
+
phases: phasesValue.map(entry => {
|
|
167
|
+
const phase = object(entry);
|
|
168
|
+
if (!(setupPhases as readonly unknown[]).includes(phase["phase"]) || typeof phase["durationMs"] !== "number" || !Number.isSafeInteger(phase["durationMs"]) || phase["durationMs"] < 0) return corrupt();
|
|
169
|
+
return { phase: phase["phase"] as typeof setupPhases[number], durationMs: phase["durationMs"] };
|
|
170
|
+
}) };
|
|
171
|
+
}
|
|
94
172
|
return { availability: "available", phase: row["phase"] as ScopedDiagnosticPhase, failure: safeFailure, command: commandDiagnostic(row["command"]),
|
|
95
|
-
...(dependency === undefined ? {} : { dependency }) };
|
|
173
|
+
...(dependency === undefined ? {} : { dependency }), ...(setup === undefined ? {} : { setup }) };
|
|
96
174
|
}
|
|
97
175
|
|
|
98
176
|
/** Read at most 100 explicit attempts. No logs from legacy payloads, no writes,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** CLI error projection over the shared immutable source binding. */
|
|
2
|
+
import { captureInstalledDataBinding as capture, InstalledDataSourceError } from "@agent-delivery-harness/kernel";
|
|
3
|
+
import { CheckSnapshotError } from "./check-snapshot.ts";
|
|
4
|
+
export type { InstalledDataSourceTree } from "@agent-delivery-harness/kernel";
|
|
5
|
+
export async function captureInstalledDataBinding(...args: Parameters<typeof capture>) {
|
|
6
|
+
try { return await capture(...args); }
|
|
7
|
+
catch (error) { if (error instanceof InstalledDataSourceError) throw new CheckSnapshotError(error.code, error.message); throw error; }
|
|
8
|
+
}
|
|
@@ -4,35 +4,177 @@ export const snapshotInventoryWorker = String.raw`
|
|
|
4
4
|
import { lstat, readFile, readdir, readlink, realpath } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
const { root, outputs, excludeDependencies, forbidGit } = JSON.parse(process.env.DELIVERY_SNAPSHOT_REQUEST);
|
|
7
|
+
const { root, outputs, excludeDependencies, forbidGit, includeSource, absentInputs = [], selfReferences = [] } = JSON.parse(process.env.DELIVERY_SNAPSHOT_REQUEST);
|
|
8
|
+
// A setup that writes this tree's own absolute path into a file binds the tree to where it is.
|
|
9
|
+
const references = selfReferences.map(reference => Buffer.from(reference));
|
|
10
|
+
let selfReferenced = false;
|
|
8
11
|
const entries = [];
|
|
12
|
+
const sourceEntries = [];
|
|
13
|
+
const record = (relative, tuple) => {
|
|
14
|
+
entries.push(tuple);
|
|
15
|
+
if (includeSource && !relative.split('/').includes('node_modules')) sourceEntries.push(tuple);
|
|
16
|
+
};
|
|
9
17
|
const inside = target => {
|
|
10
18
|
const relative = path.relative(root, target);
|
|
11
19
|
return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
|
|
12
20
|
};
|
|
13
21
|
const escape = () => { throw { code: 'check_snapshot_escape' }; };
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
// Eight workers bound filesystem operations, not just initial directory reads.
|
|
23
|
+
// Outstanding includes running directories: an empty queue is not completion
|
|
24
|
+
// while one of them may still discover children. Every started visit settles
|
|
25
|
+
// before an error is reported and the owner may clean up the private tree.
|
|
26
|
+
const drainQueue = async (queue, visit) => {
|
|
27
|
+
let cursor = 0, outstanding = queue.length, failed = false, failure;
|
|
28
|
+
const waiting = [];
|
|
29
|
+
const wake = () => { for (const resume of waiting.splice(0)) resume(); };
|
|
30
|
+
const worker = async () => {
|
|
31
|
+
while (true) {
|
|
32
|
+
while (!failed && cursor === queue.length && outstanding > 0) await new Promise(resolve => waiting.push(resolve));
|
|
33
|
+
if (failed || outstanding === 0) return;
|
|
34
|
+
const slot = queue[cursor++];
|
|
35
|
+
try {
|
|
36
|
+
const children = await visit(slot);
|
|
37
|
+
if (!failed && children?.length) {
|
|
38
|
+
outstanding += children.length;
|
|
39
|
+
for (const child of children) queue.push(child);
|
|
40
|
+
}
|
|
41
|
+
} catch (error) { if (!failed) { failed = true; failure = error; } }
|
|
42
|
+
finally { outstanding--; wake(); }
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await Promise.all(Array.from({ length: 8 }, worker));
|
|
46
|
+
if (failed) throw failure;
|
|
47
|
+
};
|
|
48
|
+
const boundedMap = async (values, visit) => {
|
|
49
|
+
const slots = values.map(value => ({ value }));
|
|
50
|
+
await drainQueue([...slots], async slot => { slot.result = await visit(slot.value); });
|
|
51
|
+
return slots.map(slot => slot.result);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Existing directory coordinates come only from the actual walk, with exact
|
|
55
|
+
// bigint filesystem identity. Requested spelling and realpath are not canonical.
|
|
56
|
+
const directories = new Map();
|
|
57
|
+
const identity = stat => stat.dev.toString() + ':' + stat.ino.toString();
|
|
58
|
+
const resolveMissing = async target => {
|
|
59
|
+
if (!inside(target)) escape();
|
|
60
|
+
let resolved = root, key = root, links = 0;
|
|
61
|
+
const pending = path.relative(root, target).split(path.sep);
|
|
62
|
+
while (pending.length) {
|
|
63
|
+
const segment = pending.shift();
|
|
64
|
+
if (!segment || segment === '.') continue;
|
|
65
|
+
if (segment === '..') { resolved = path.dirname(resolved); key = path.dirname(key); }
|
|
66
|
+
else {
|
|
67
|
+
const next = path.join(resolved, segment);
|
|
68
|
+
const info = await lstat(next, { bigint: true }).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
69
|
+
if (info?.isSymbolicLink()) {
|
|
70
|
+
if (++links > 32) escape();
|
|
71
|
+
const link = await readlink(next);
|
|
72
|
+
if (path.isAbsolute(link)) escape();
|
|
73
|
+
pending.unshift(...link.split(path.sep));
|
|
74
|
+
} else if (info?.isDirectory()) {
|
|
75
|
+
resolved = directories.get(identity(info));
|
|
76
|
+
if (resolved === undefined) escape();
|
|
77
|
+
key = resolved;
|
|
78
|
+
} else {
|
|
79
|
+
resolved = next;
|
|
80
|
+
// No object exists to establish Unicode equivalence. The finite port
|
|
81
|
+
// explicitly refuses non-ASCII missing suffixes rather than guessing a
|
|
82
|
+
// filesystem folding table. Existing distinct directories stay distinct.
|
|
83
|
+
if (info === null && /[^\x00-\x7f]/.test(segment)) throw { code: 'check_snapshot_drift' };
|
|
84
|
+
key = info === null ? path.join(key, segment.toLowerCase()) : resolved;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!inside(resolved)) escape();
|
|
88
|
+
}
|
|
89
|
+
return { absolute: resolved, key };
|
|
90
|
+
};
|
|
91
|
+
// Retain the already-walked links so output authority can follow directory
|
|
92
|
+
// referents without recursively following links or weakening inventory digests.
|
|
93
|
+
const aliases = [];
|
|
94
|
+
let protectedNames;
|
|
95
|
+
const protectOutputAlias = resolved => {
|
|
96
|
+
const name = path.relative(root, resolved.key);
|
|
97
|
+
for (const a of protectedNames) {
|
|
98
|
+
if (name === '' || name === a || name.startsWith(a + '/') || a.startsWith(name + '/')) throw { code: 'check_snapshot_drift' };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const protectOutputReachability = async () => {
|
|
102
|
+
protectedNames = await boundedMap(absentInputs, async absent => path.relative(root, (await resolveMissing(path.join(root, absent))).key));
|
|
103
|
+
const reachable = [];
|
|
104
|
+
for (const output of outputs) reachable.push(await resolveMissing(path.join(root, output.replace(/\/$/, ''))));
|
|
105
|
+
for (const target of reachable) protectOutputAlias(target);
|
|
106
|
+
// Each collected link is expanded at most once. Its fully resolved target
|
|
107
|
+
// is writable whenever its lexical location is below an output referent.
|
|
108
|
+
const pending = new Set(aliases);
|
|
109
|
+
for (let index = 0; index < reachable.length; index++) {
|
|
110
|
+
const directory = reachable[index].absolute;
|
|
111
|
+
for (const alias of pending) {
|
|
112
|
+
const relative = path.relative(directory, alias.absolute);
|
|
113
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) continue;
|
|
114
|
+
pending.delete(alias);
|
|
115
|
+
const resolved = await resolveMissing(alias.absolute);
|
|
116
|
+
protectOutputAlias(resolved);
|
|
117
|
+
reachable.push(resolved);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const walk = async () => {
|
|
122
|
+
const tree = { relative: '', mutable: false };
|
|
123
|
+
await drainQueue([tree], async slot => {
|
|
124
|
+
const { relative } = slot;
|
|
125
|
+
if (relative === '.git' || (excludeDependencies && relative.split('/').includes('node_modules'))) return;
|
|
126
|
+
// Mutable paths are excluded from the digest, never from the link/type walk.
|
|
127
|
+
// Carry the exclusion through directories, including an exact output path.
|
|
128
|
+
const mutable = slot.mutable || outputs.some(o => o.endsWith('/') ? relative === o.slice(0, -1) || relative.startsWith(o) : relative === o);
|
|
129
|
+
const absolute = path.join(root, relative), stat = await lstat(absolute, { bigint: true });
|
|
130
|
+
if (stat.isSymbolicLink()) {
|
|
131
|
+
const target = await readlink(absolute);
|
|
132
|
+
if (path.isAbsolute(target) || !inside(path.resolve(path.dirname(absolute), target))) escape();
|
|
133
|
+
try { if (!inside(await realpath(absolute))) escape(); }
|
|
134
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
135
|
+
if (absentInputs.length) slot.alias = { absolute };
|
|
136
|
+
if (!mutable) slot.tuple = [relative, 'link', target];
|
|
137
|
+
} else if (stat.isDirectory()) {
|
|
138
|
+
slot.directory = { identity: identity(stat), absolute };
|
|
139
|
+
slot.children = (await readdir(absolute)).sort().map(name => ({ relative: relative ? relative + '/' + name : name, mutable }));
|
|
140
|
+
return slot.children;
|
|
141
|
+
} else if (stat.isFile()) {
|
|
142
|
+
if (!mutable) {
|
|
143
|
+
const bytes = await readFile(absolute);
|
|
144
|
+
if (!selfReferenced && references.some(reference => bytes.includes(reference))) selfReferenced = true;
|
|
145
|
+
slot.tuple = [relative, Number(stat.mode & 0o111n), createHash('sha256').update(bytes).digest('hex')];
|
|
146
|
+
}
|
|
147
|
+
} else escape();
|
|
148
|
+
});
|
|
149
|
+
// Completion order grants no directory authority and cannot change digest
|
|
150
|
+
// bytes. Flatten exact sorted-child DFS, including a/child before a.c;
|
|
151
|
+
// globally sorting relative paths would silently change the old identity.
|
|
152
|
+
const ordered = [tree];
|
|
153
|
+
while (ordered.length) {
|
|
154
|
+
const slot = ordered.pop();
|
|
155
|
+
if (slot.directory) directories.set(slot.directory.identity, slot.directory.absolute);
|
|
156
|
+
if (slot.alias) aliases.push(slot.alias);
|
|
157
|
+
if (slot.tuple) record(slot.relative, slot.tuple);
|
|
158
|
+
if (slot.children) for (let index = slot.children.length - 1; index >= 0; index--) ordered.push(slot.children[index]);
|
|
159
|
+
}
|
|
29
160
|
};
|
|
30
161
|
try {
|
|
162
|
+
for (const absent of absentInputs) {
|
|
163
|
+
let relative = '';
|
|
164
|
+
for (const part of absent.split('/')) {
|
|
165
|
+
relative = relative ? relative + '/' + part : part;
|
|
166
|
+
const info = await lstat(path.join(root, relative)).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
167
|
+
if (info === null) break;
|
|
168
|
+
if (info.isSymbolicLink() || relative === absent || !info.isDirectory()) throw { code: 'check_snapshot_drift' };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
31
171
|
if (forbidGit && await lstat(path.join(root, '.git')).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })) throw { code: 'check_snapshot_drift' };
|
|
32
|
-
await walk(
|
|
172
|
+
await walk();
|
|
173
|
+
if (absentInputs.length) await protectOutputReachability();
|
|
33
174
|
// Tuples contain only arrays, strings and permission integers: JSON.stringify
|
|
34
175
|
// has exactly the same bytes as canonical JSON for this restricted shape.
|
|
35
|
-
|
|
176
|
+
const digest = tuples => createHash('sha256').update(JSON.stringify(tuples)).digest('hex');
|
|
177
|
+
process.stdout.write(JSON.stringify({ digest: digest(entries), ...(includeSource ? { sourceDigest: digest(sourceEntries) } : {}), ...(references.length ? { selfReferenced } : {}) }));
|
|
36
178
|
} catch (error) {
|
|
37
179
|
process.stdout.write(JSON.stringify({ code: ['check_snapshot_escape', 'check_snapshot_drift'].includes(error.code) ? error.code : 'check_snapshot_unavailable' }));
|
|
38
180
|
process.exitCode = 1;
|