freeharness 0.4.2 → 0.4.3
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/NOTICE +2 -1
- package/README.md +20 -16
- package/canonical/HARVEST.yaml +2 -2
- package/canonical/PROVENANCE.yaml +2 -2
- package/canonical/agents/security-reviewer.md +8 -2
- package/canonical/hooks/freeharness-config-guard.mjs +644 -4567
- package/canonical/hooks/freeharness-cost-tracker.mjs +14 -27
- package/canonical/hooks/freeharness-precompact.mjs +12 -23
- package/canonical/hooks/freeharness-sdd-context.mjs +12 -23
- package/canonical/hooks/hooks.config-guard.claude-code.json +2 -3
- package/canonical/hooks/hooks.memory-guard.claude-code.json +2 -3
- package/canonical/skills/code-review/SKILL.md +2 -2
- package/canonical/skills/code-review/evals/cases.yaml +1 -1
- package/canonical/skills/subagent-driven-development/SKILL.md +4 -4
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +149 -20
- package/canonical/skills/subagent-driven-development/reference/brief.md +15 -13
- package/canonical/skills/subagent-driven-development/reference/handoff.md +29 -15
- package/canonical/skills/subagent-driven-development/reference/loop.md +4 -5
- package/canonical/skills/subagent-driven-development/reference/workspace.md +2 -2
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +301 -1038
- package/dist/adapters/harness/claude-code.d.ts +7 -0
- package/dist/adapters/harness/claude-code.js +27 -21
- package/dist/adapters/harness/opencode.js +1 -1
- package/dist/adapters/harness/plugin.js +9 -9
- package/dist/adapters/memory/doctor.d.ts +11 -17
- package/dist/adapters/memory/doctor.js +55 -68
- package/dist/adapters/memory/paths.d.ts +3 -10
- package/dist/adapters/memory/paths.js +16 -54
- package/dist/adapters/memory/vendored.d.ts +2 -7
- package/dist/adapters/memory/vendored.js +12 -57
- package/dist/adapters/memory/worker-control.d.ts +9 -0
- package/dist/adapters/memory/worker-control.js +30 -0
- package/dist/cli/cleanup.d.ts +1 -1
- package/dist/cli/cleanup.js +29 -14
- package/dist/cli/cost.js +2 -2
- package/dist/cli/doctor.d.ts +9 -9
- package/dist/cli/doctor.js +21 -16
- package/dist/cli/hard-cleanup.js +1 -1
- package/dist/cli/install-facts.d.ts +4 -3
- package/dist/cli/install-facts.js +3 -1
- package/dist/cli/profile.d.ts +1 -1
- package/dist/cli/profile.js +10 -10
- package/dist/index.js +7 -5
- package/dist/infra/fsutil.d.ts +4 -5
- package/dist/infra/fsutil.js +5 -6
- package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
- package/node_modules/@freeharness/core/dist/registry.js +3 -3
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/package.json +3 -3
- package/provenance/canonical.PROVENANCE.yaml +2 -2
- package/provenance/memory.PROVENANCE.yaml +18 -4
- package/canonical/openspec/config.base.yaml +0 -89
- package/canonical/rules/safety.md +0 -41
|
@@ -51,10 +51,10 @@ const DIGEST_BUDGET = 15;
|
|
|
51
51
|
const CEILING_RE = /^[1-9]\d*$/;
|
|
52
52
|
/** Budget ceiling (cache-read tokens) of a task of no security, complex or final class. */
|
|
53
53
|
const DEFAULT_CEILING = 25000000;
|
|
54
|
-
/** Class ceilings (cache-read tokens)
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
export const CLASS_CEILINGS = Object.freeze({ security:
|
|
54
|
+
/** Class ceilings (cache-read tokens): security and complex tasks share the ordinary limit, so
|
|
55
|
+
* extra spend on them needs the owner's run authorization; only the final review, which reads
|
|
56
|
+
* the whole branch, gets more. */
|
|
57
|
+
export const CLASS_CEILINGS = Object.freeze({ security: DEFAULT_CEILING, complex: DEFAULT_CEILING, final: 50000000, other: DEFAULT_CEILING });
|
|
58
58
|
/** Fix rounds a task may open when the change's run authorization sets no `Rounds:`. */
|
|
59
59
|
const DEFAULT_ROUNDS = 3;
|
|
60
60
|
/** The run authorization's lines; anything else in its section is refused, never skipped. */
|
|
@@ -71,18 +71,12 @@ const TASK_LINE = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\]
|
|
|
71
71
|
const TASK_ANNOTATION_RE = /^ {2,}(?:Verify|Interfaces|Boundary|Depends|Disposition):/;
|
|
72
72
|
/** What `brief-init` leaves where only the lead's judgment can supply the value. */
|
|
73
73
|
export const PLACEHOLDER_RE = /<fill:/;
|
|
74
|
-
/** One short fragment (or a `pattern`, or a `phrases` list needing every member) per required
|
|
75
|
-
* `## Run rules` line; a brief missing one reports its `key`. `phrase` is a case-insensitive
|
|
76
|
-
* substring; `phrases` needs each entry as its own substring, any order; `pattern` tests the
|
|
77
|
-
* section text as-is. A brief may restate a rule in its own words around the match.
|
|
78
|
-
* `no-full-suite` needs one bullet with `never run` or `do not run` before `Suite:`, and verify:
|
|
79
|
-
* a bullet that only conditions the run, or fragments borrowed from other bullets, would otherwise pass. */
|
|
80
74
|
const SPACE_RE = /\s/;
|
|
81
75
|
const LINE_END = /[\n\r\p{Zl}\p{Zp}]/u;
|
|
82
|
-
/** The `no-full-suite` test as one pass over the text: a bullet opens at a
|
|
83
|
-
* text start before it and runs to the next ` - ` or line end.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
76
|
+
/** The `no-full-suite` test as one pass over the text, linear in its length: a bullet opens at a
|
|
77
|
+
* `- ` with a space or the text start before it and runs to the next ` - ` or line end. A bullet
|
|
78
|
+
* opening right inside the one before it is skipped when its text is a suffix of that one's, so
|
|
79
|
+
* the scan never rereads a character. */
|
|
86
80
|
function noFullSuiteBullet(text) {
|
|
87
81
|
const n = text.length;
|
|
88
82
|
const isSpace = (ch) => ch !== undefined && SPACE_RE.test(ch);
|
|
@@ -101,6 +95,12 @@ function noFullSuiteBullet(text) {
|
|
|
101
95
|
}
|
|
102
96
|
return false;
|
|
103
97
|
}
|
|
98
|
+
/** One short fragment (or a `pattern`, or a `phrases` list needing every member) per required
|
|
99
|
+
* `## Run rules` line; a brief missing one reports its `key`. `phrase` is a case-insensitive
|
|
100
|
+
* substring; `phrases` needs each entry as its own substring, any order; `pattern` tests the
|
|
101
|
+
* section text as-is. A brief may restate a rule in its own words around the match.
|
|
102
|
+
* `no-full-suite` needs `never run` or `do not run` before `Suite:`, and verify, in one bullet
|
|
103
|
+
* bounded by the next ` - ` or line end, so borrowed fragments never pass. */
|
|
104
104
|
export const REQUIRED_RUN_RULES = [
|
|
105
105
|
{ key: "build-before-tests", phrase: "build before" },
|
|
106
106
|
{ key: "comment-share", phrase: "comment share" },
|
|
@@ -136,8 +136,6 @@ export const PROBE_RE = /^(P[1-9]\d*): (.+?) -> (.+)$/;
|
|
|
136
136
|
export const PROBE_BOUND = 12;
|
|
137
137
|
/** An expected outcome that admits the behavior, so a fix that over-denies fails a probe. */
|
|
138
138
|
const ALLOW_SIDE_RE = /\b(?:allow|allowed|accept|accepted|accepts|pass|passes|passed|ok|exit 0)\b/i;
|
|
139
|
-
/** A probe that exercises the change over many inputs rather than one hand-picked case. */
|
|
140
|
-
const CORPUS_RE = /\b(?:corpus|replay|fixture|fixtures|generator|generated)\b/i;
|
|
141
139
|
/** Most non-blank lines a brief's `## Project rules` may hold: workers run without CLAUDE.md, so
|
|
142
140
|
* the section carries only the rules that bear on the write scope, never the whole file. */
|
|
143
141
|
export const PROJECT_RULES_BOUND = 20;
|
|
@@ -353,7 +351,7 @@ function parseProbes(lines) {
|
|
|
353
351
|
}
|
|
354
352
|
|
|
355
353
|
/** Probe problems of one brief: the section's own, then, for a security or complex class, a
|
|
356
|
-
* missing allow-side
|
|
354
|
+
* missing allow-side probe, judged only once the section exists. */
|
|
357
355
|
function probeRuleErrors(name, b) {
|
|
358
356
|
const errors = (b.probeErrors ?? []).map((e) => `${name}: ${e}`);
|
|
359
357
|
const probes = b.probes ?? [];
|
|
@@ -362,9 +360,6 @@ function probeRuleErrors(name, b) {
|
|
|
362
360
|
if (!probes.some((p) => ALLOW_SIDE_RE.test(p.expected))) {
|
|
363
361
|
errors.push(`${name}: Class ${cls} needs an allow-side probe (expected outcome an allow or a pass)`);
|
|
364
362
|
}
|
|
365
|
-
if (!probes.some((p) => CORPUS_RE.test(`${p.id}: ${p.check} -> ${p.expected}`))) {
|
|
366
|
-
errors.push(`${name}: Class ${cls} needs a corpus probe (a corpus, fixture replay or generator command)`);
|
|
367
|
-
}
|
|
368
363
|
return errors;
|
|
369
364
|
}
|
|
370
365
|
|
|
@@ -862,9 +857,12 @@ function pathEscapesOrSymlinks(file, cwd) {
|
|
|
862
857
|
return false;
|
|
863
858
|
}
|
|
864
859
|
|
|
865
|
-
/**
|
|
860
|
+
/** Bytes from the start of a file searched for a NUL byte, the test that marks it binary. */
|
|
861
|
+
const BINARY_SNIFF_BYTES = 8 * 1024;
|
|
862
|
+
|
|
863
|
+
/** True when the first 8 KiB of `buf` holds a NUL byte (binary content). */
|
|
866
864
|
function looksBinary(buf) {
|
|
867
|
-
const len = Math.min(buf.length,
|
|
865
|
+
const len = Math.min(buf.length, BINARY_SNIFF_BYTES);
|
|
868
866
|
for (let i = 0; i < len; i++) if (buf[i] === 0x00) return true;
|
|
869
867
|
return false;
|
|
870
868
|
}
|
|
@@ -1087,14 +1085,16 @@ function isSpace(c) {
|
|
|
1087
1085
|
}
|
|
1088
1086
|
|
|
1089
1087
|
/** End index of the quoted literal opening at `i` (`q` long, quote char `ch`), past `n` when
|
|
1090
|
-
* unterminated; a backslash always skips the next character.
|
|
1091
|
-
|
|
1088
|
+
* unterminated; a backslash always skips the next character. With `eol`, an unescaped line
|
|
1089
|
+
* terminator ends it, as in JavaScript. */
|
|
1090
|
+
function literalEnd(text, i, ch, q, eol = false) {
|
|
1092
1091
|
const n = text.length;
|
|
1093
1092
|
let k = i + q;
|
|
1094
1093
|
while (k < n) {
|
|
1095
1094
|
const c = text[k];
|
|
1096
|
-
if (c === "\\") k += 2;
|
|
1095
|
+
if (c === "\\") k += text[k + 1] === "\r" && text[k + 2] === "\n" ? 3 : 2;
|
|
1097
1096
|
else if (c === ch && (q === 1 || (text[k + 1] === ch && text[k + 2] === ch))) return k + q;
|
|
1097
|
+
else if (eol && (c === "\n" || c === "\r")) return k;
|
|
1098
1098
|
else k++;
|
|
1099
1099
|
}
|
|
1100
1100
|
return n;
|
|
@@ -1145,7 +1145,7 @@ function jsCommentChars(text) {
|
|
|
1145
1145
|
i = k + 1;
|
|
1146
1146
|
regexOk = false;
|
|
1147
1147
|
} else if (ch === "'" || ch === '"') {
|
|
1148
|
-
i = literalEnd(text, i, ch, 1);
|
|
1148
|
+
i = literalEnd(text, i, ch, 1, true);
|
|
1149
1149
|
regexOk = false;
|
|
1150
1150
|
} else if (ch === "`" || (ch === "}" && holes.length > 0 && holes[holes.length - 1] === 0)) {
|
|
1151
1151
|
if (ch === "}") holes.pop();
|
|
@@ -1675,7 +1675,7 @@ export function changedTermErrors(brief, cwd = process.cwd(), cache = new Map())
|
|
|
1675
1675
|
const buf = Buffer.alloc(st.size);
|
|
1676
1676
|
let n = 0;
|
|
1677
1677
|
for (let r = 1; n < st.size && r > 0; n += r) r = fs.readSync(fd, buf, n, st.size - n, n);
|
|
1678
|
-
if (!buf.subarray(0,
|
|
1678
|
+
if (!looksBinary(buf.subarray(0, n))) text = buf.subarray(0, n).toString("utf8");
|
|
1679
1679
|
}
|
|
1680
1680
|
} catch (err) {
|
|
1681
1681
|
// A deleted file or a symlink holds nothing to search; any other failure is said.
|
|
@@ -1724,10 +1724,11 @@ export function changedTermErrors(brief, cwd = process.cwd(), cache = new Map())
|
|
|
1724
1724
|
return errors;
|
|
1725
1725
|
}
|
|
1726
1726
|
|
|
1727
|
-
/** Problems of a batch: each brief's header, digest and tree, plus
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1727
|
+
/** Problems of a batch: each brief's header, digest and tree (tree-path, setup-missing), plus
|
|
1728
|
+
* scope overlap. `{ kind: "set", cap }` (named briefs) adds tree-required, resource-overlap,
|
|
1729
|
+
* manifest-parallel and `band` past `cap`; `{ kind: "dir" }` (landed briefs) skips those and
|
|
1730
|
+
* tree-absent; `single` skips the set checks. Another mode or a missing cap throws. A malformed
|
|
1731
|
+
* run authorization is one error per run. */
|
|
1731
1732
|
export function validateBriefs(briefs, mode = { kind: "single" }) {
|
|
1732
1733
|
const kind = mode && typeof mode === "object" ? mode.kind : undefined;
|
|
1733
1734
|
if (!["single", "set", "dir"].includes(kind)) throw new TypeError(`validateBriefs mode must be { kind: 'single' | 'set' | 'dir' }; got ${JSON.stringify(mode)}`);
|
|
@@ -2033,58 +2034,28 @@ function collectBriefs(args) {
|
|
|
2033
2034
|
/** Most bytes read of a transcript or a ledger: a worker transcript stays far below it, so a
|
|
2034
2035
|
* larger file is runaway or hostile and its tail is dropped rather than buffered. */
|
|
2035
2036
|
const SCAN_BOUND = 16 * 1024 * 1024;
|
|
2036
|
-
/** Most lines one scan kind prints; one `
|
|
2037
|
+
/** Most lines one scan kind prints; one `scan-capped` note counts the rest. */
|
|
2037
2038
|
const SCAN_CAP = 50;
|
|
2038
2039
|
/** Most characters of a command or path one scan line quotes. */
|
|
2039
2040
|
const SCAN_DETAIL = 200;
|
|
2040
2041
|
/** Longest path the scan resolves: past PATH_MAX no write can succeed. */
|
|
2041
2042
|
const SCAN_PATH_MAX = 4096;
|
|
2042
|
-
/** Filesystem lookups one scan may spend before it compares paths by text alone, so a run of
|
|
2043
|
-
* distinct targets cannot turn the scan into a syscall storm. */
|
|
2044
|
-
const SCAN_FS_BUDGET = 10000;
|
|
2045
|
-
/** Characters of relative path one scan may resolve against a directory, so a run of distinct
|
|
2046
|
-
* words under a directory near PATH_MAX cannot cost bytes times PATH_MAX. */
|
|
2047
|
-
const SCAN_PATH_BUDGET = 16 * 1024 * 1024;
|
|
2048
|
-
/** Reserved words and `!` a command may follow; each counts only unquoted. */
|
|
2049
|
-
const RESERVED_PREFIXES = new Set(["!", "{", "}", "if", "then", "else", "elif", "do", "while", "until"]);
|
|
2050
|
-
/** Wrappers that run the command after their own options, keyed by basename. `short` letters and
|
|
2051
|
-
* `long` names take a value, as the next word unless joined; any other option is a flag, so an
|
|
2052
|
-
* unknown one never eats the command word. `tail` counts the operands read before the command,
|
|
2053
|
-
* as the duration of `timeout`. The BSD letters (`env -LPU`, `xargs -JRS`) are absent from GNU,
|
|
2054
|
-
* so consuming their value there only reads a word GNU would refuse. `chdir` names the letter
|
|
2055
|
-
* and the shortest unambiguous `--chdir` prefix of the option that runs the command elsewhere. */
|
|
2056
|
-
const COMMAND_PREFIXES = new Map([
|
|
2057
|
-
["time", { short: "", long: [] }],
|
|
2058
|
-
["nohup", { short: "", long: [] }],
|
|
2059
|
-
["exec", { short: "a", long: [] }],
|
|
2060
|
-
["command", { short: "", long: [] }],
|
|
2061
|
-
["env", { short: "uCSLPU", long: ["--unset", "--chdir", "--split-string"], chdir: ["C", 3] }],
|
|
2062
|
-
["sudo", { short: "ughpCDrtUacRT", long: ["--user", "--group", "--host", "--prompt", "--close-from", "--chdir", "--role", "--type", "--other-user", "--chroot", "--command-timeout", "--login-class", "--auth-type"], chdir: ["D", 5] }],
|
|
2063
|
-
["nice", { short: "n", long: ["--adjustment"] }],
|
|
2064
|
-
["timeout", { short: "sk", long: ["--signal", "--kill-after"], tail: 1 }],
|
|
2065
|
-
["xargs", { short: "adEILnPsJRS", long: ["--arg-file", "--delimiter", "--max-args", "--max-procs", "--max-chars"] }],
|
|
2066
|
-
["stdbuf", { short: "ioe", long: ["--input", "--output", "--error"] }],
|
|
2067
|
-
]);
|
|
2068
|
-
const WRITE_REDIRECTS = new Set([">", ">>", ">&", "<>"]);
|
|
2069
|
-
const SHELLS = new Set(["bash", "sh", "zsh", "dash"]);
|
|
2070
|
-
const WRITE_CALLS = ["writeFile", "appendFile", "createWriteStream", "fs.write", "write_text", "write_bytes", "print >", "File.write", "IO.write"];
|
|
2071
2043
|
/** Git subcommands that write the index or HEAD whatever their arguments; matched by whole word,
|
|
2072
2044
|
* so `merge-base` and `cherry` stay reads. */
|
|
2073
2045
|
const INDEX_SUBCOMMANDS = new Set(["add", "rm", "mv", "commit", "reset", "switch", "merge", "rebase", "cherry-pick", "pull", "update-index"]);
|
|
2074
|
-
/** The operand an `xargs` writer builds from stdin: an expanded word, so it is only noted. */
|
|
2075
|
-
const XARGS_OPERAND = Object.freeze({ text: "xargs", expand: true, quoted: false });
|
|
2076
|
-
/** How many `find -exec` levels one command is unwrapped, so nested finds stay linear. */
|
|
2077
|
-
const FIND_DEPTH = 2;
|
|
2078
|
-
/** Commands that write a copy, a move, an install or a link of a file, with the `g` names
|
|
2079
|
-
* Homebrew gives GNU coreutils. */
|
|
2080
|
-
const COPY_TOOLS = new Set(["cp", "mv", "install", "ln", "gcp", "gmv", "ginstall", "gln"]);
|
|
2081
|
-
/** Long options of those commands whose value may be the next word, each with the shortest
|
|
2082
|
-
* prefix GNU getopt accepts for it unambiguously. */
|
|
2083
|
-
const COPY_LONG_VALUED = [["--target-directory", 3], ["--suffix", 4]];
|
|
2084
|
-
const CP_LONG_VALUED = [["--sparse", 4], ["--no-preserve", 6]];
|
|
2085
|
-
const INSTALL_LONG_VALUED = [["--mode", 3], ["--owner", 3], ["--group", 3], ["--strip-program", 8]];
|
|
2086
2046
|
/** Git global options whose value is the next word. */
|
|
2087
2047
|
const GIT_VALUED = new Set(["-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"]);
|
|
2048
|
+
/** Commands whose operands a Bash call writes or removes; `sed` counts only with `-i`. */
|
|
2049
|
+
const WRITE_WORDS = new Set(["tee", "cp", "mv", "install", "rm", "touch", "ln"]);
|
|
2050
|
+
/** Of those, the commands that take a `-t` target directory; all but `mv` write only the target. */
|
|
2051
|
+
const COPY_WORDS = new Set(["cp", "mv", "install", "ln"]);
|
|
2052
|
+
/** Words a command may run behind; only the bare word is skipped, never its options. */
|
|
2053
|
+
const WRAPPER_WORDS = new Set(["sudo", "env", "command", "nice", "nohup", "time", "exec", "xargs"]);
|
|
2054
|
+
/** The separators a Bash command is split on. Quotes are not parsed, so a separator inside a
|
|
2055
|
+
* quoted string splits there too: the scan reports, the diff guards decide. */
|
|
2056
|
+
const SEPARATOR_RE = /;|&&|\|\||\|/;
|
|
2057
|
+
/** A redirect word: its fd or `&`, the operator, an fd-duplicating `&` and a joined target. */
|
|
2058
|
+
const REDIRECT_RE = /^(?:\d*|&)>>?\|?(&?)(.*)$/;
|
|
2088
2059
|
|
|
2089
2060
|
/** Up to `bound` bytes of `file` (its tail when `fromEnd`) through one no-follow, non-blocking
|
|
2090
2061
|
* fd, so a symlinked leaf or a FIFO is refused rather than followed or waited on; null when
|
|
@@ -2190,289 +2161,6 @@ export function transcriptFor(agentId, changeDir) {
|
|
|
2190
2161
|
return { paths: [...paths], notes: [...notes] };
|
|
2191
2162
|
}
|
|
2192
2163
|
|
|
2193
|
-
/** The operator at `src[i]` (`&`, `>` or `<`) and its width; `&>` and `&>>` write like `>`. */
|
|
2194
|
-
function redirectOperator(src, i) {
|
|
2195
|
-
const [a, b, c] = [src[i], src[i + 1], src[i + 2]];
|
|
2196
|
-
if (a === "&") return c === ">" ? [">>", 3] : [">", 2];
|
|
2197
|
-
if (a === ">") return b === ">" ? [">>", 2] : b === "|" ? [">", 2] : b === "&" ? [">&", 2] : [">", 1];
|
|
2198
|
-
if (b === "<") return c === "<" ? ["<<<", 3] : c === "-" ? ["<<-", 3] : ["<<", 2];
|
|
2199
|
-
return b === ">" ? ["<>", 2] : b === "&" ? ["<&", 2] : ["<", 1];
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
/** Appends the `$…` or backquote expansion at `src[i]` to `w` and returns the index past it. A
|
|
2203
|
-
* substitution or `${…}` marks `w.nested`: its end is found by counting, not by parsing the shell
|
|
2204
|
-
* inside, so a comment or a case pattern there can end it at the wrong `)`. */
|
|
2205
|
-
function expansion(src, i, w) {
|
|
2206
|
-
w.expand = true;
|
|
2207
|
-
const n = src.length;
|
|
2208
|
-
let j = i + 1;
|
|
2209
|
-
if (src[i] === "`" || src[j] === "(" || src[j] === "{") w.nested = true;
|
|
2210
|
-
if (src[i] === "`") {
|
|
2211
|
-
while (j < n && src[j] !== "`") j += src[j] === "\\" ? 2 : 1;
|
|
2212
|
-
j = Math.min(j + 1, n);
|
|
2213
|
-
} else if (src[j] === "(" || src[j] === "{") {
|
|
2214
|
-
const open = src[j];
|
|
2215
|
-
const close = open === "(" ? ")" : "}";
|
|
2216
|
-
let depth = 0;
|
|
2217
|
-
while (j < n) {
|
|
2218
|
-
const ch = src[j];
|
|
2219
|
-
if (ch === "\\") j += 2;
|
|
2220
|
-
else if (ch === "'" || ch === '"') {
|
|
2221
|
-
let k = j + 1;
|
|
2222
|
-
while (k < n && src[k] !== ch) k += src[k] === "\\" && ch === '"' ? 2 : 1;
|
|
2223
|
-
j = k + 1;
|
|
2224
|
-
} else {
|
|
2225
|
-
j++;
|
|
2226
|
-
if (ch === open) depth++;
|
|
2227
|
-
else if (ch === close && --depth === 0) break;
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
j = Math.min(j, n);
|
|
2231
|
-
}
|
|
2232
|
-
w.text += src.slice(i, j);
|
|
2233
|
-
return j;
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
/** Appends the double-quoted run from `src[i]` (past the opening quote) to `w`; returns the
|
|
2237
|
-
* index past the closing quote. */
|
|
2238
|
-
function doubleQuoted(src, i, w) {
|
|
2239
|
-
const n = src.length;
|
|
2240
|
-
while (i < n) {
|
|
2241
|
-
const ch = src[i];
|
|
2242
|
-
if (ch === '"') return i + 1;
|
|
2243
|
-
if (ch === "$" || ch === "`") {
|
|
2244
|
-
i = expansion(src, i, w);
|
|
2245
|
-
continue;
|
|
2246
|
-
}
|
|
2247
|
-
if (ch === "\\" && i + 1 < n) {
|
|
2248
|
-
const nx = src[i + 1];
|
|
2249
|
-
if (nx !== "\n") w.text += '"\\$`'.includes(nx) ? nx : ch + nx;
|
|
2250
|
-
i += 2;
|
|
2251
|
-
continue;
|
|
2252
|
-
}
|
|
2253
|
-
w.text += ch;
|
|
2254
|
-
i++;
|
|
2255
|
-
}
|
|
2256
|
-
return n;
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
/** Characters the one-letter ANSI-C escapes stand for. */
|
|
2260
|
-
const ANSI_C_NAMED = new Map([
|
|
2261
|
-
["a", "\x07"], ["b", "\b"], ["e", "\x1b"], ["E", "\x1b"], ["f", "\f"], ["n", "\n"],
|
|
2262
|
-
["r", "\r"], ["t", "\t"], ["v", "\v"], ["\\", "\\"], ["'", "'"], ['"', '"'],
|
|
2263
|
-
]);
|
|
2264
|
-
/** Digit count and radix of the numeric ANSI-C escapes, keyed by the letter after the backslash. */
|
|
2265
|
-
const ANSI_C_NUMERIC = new Map([["x", [2, 16]], ["u", [4, 16]], ["U", [8, 16]]]);
|
|
2266
|
-
|
|
2267
|
-
/** The length of the run of up to `max` digits of `radix` at `src[i]`. */
|
|
2268
|
-
function digitRun(src, i, max, radix) {
|
|
2269
|
-
let k = i;
|
|
2270
|
-
while (k < src.length && k - i < max && !Number.isNaN(parseInt(src[k], radix))) k++;
|
|
2271
|
-
return k - i;
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
/** Appends the `$'...'` body from `src[i]` (past the opening quote), escapes decoded as bash
|
|
2275
|
-
* does, to `w`; returns the index past the closing quote. Bash keeps a decoded string up to its
|
|
2276
|
-
* first NUL, so the text after one is dropped: a path tail past it never runs. */
|
|
2277
|
-
function ansiC(src, i, w) {
|
|
2278
|
-
const n = src.length;
|
|
2279
|
-
let out = "";
|
|
2280
|
-
let cut = false;
|
|
2281
|
-
while (i < n && src[i] !== "'") {
|
|
2282
|
-
let ch = src[i++];
|
|
2283
|
-
if (ch === "\\" && i < n) {
|
|
2284
|
-
const e = src[i++];
|
|
2285
|
-
const numeric = ANSI_C_NUMERIC.get(e) ?? (e >= "0" && e <= "7" ? [3, 8] : null);
|
|
2286
|
-
if (ANSI_C_NAMED.has(e)) ch = ANSI_C_NAMED.get(e);
|
|
2287
|
-
else if (numeric === null) ch = `\\${e}`;
|
|
2288
|
-
else {
|
|
2289
|
-
const from = e === "x" || e === "u" || e === "U" ? i : i - 1;
|
|
2290
|
-
const len = digitRun(src, from, numeric[0], numeric[1]);
|
|
2291
|
-
const v = parseInt(src.slice(from, from + len), numeric[1]);
|
|
2292
|
-
if (len === 0 || v > 0x10ffff) ch = src.slice(i - 2, from + len);
|
|
2293
|
-
else ch = e === "u" || e === "U" ? String.fromCodePoint(v) : String.fromCharCode(v & 0xff);
|
|
2294
|
-
i = from + len;
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2297
|
-
if (ch === "\0") cut = true;
|
|
2298
|
-
else if (!cut) out += ch;
|
|
2299
|
-
}
|
|
2300
|
-
w.text += out;
|
|
2301
|
-
return Math.min(i + 1, n);
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
|
-
/** Attaches each pending heredoc's body, read line by line from `pos`, to its command; returns
|
|
2305
|
-
* the index past the last delimiter line. */
|
|
2306
|
-
function heredocBodies(src, pos, pending) {
|
|
2307
|
-
const n = src.length;
|
|
2308
|
-
for (const h of pending) {
|
|
2309
|
-
const start = pos;
|
|
2310
|
-
let end = n;
|
|
2311
|
-
while (pos < n) {
|
|
2312
|
-
const e = src.indexOf("\n", pos);
|
|
2313
|
-
const lineEnd = e < 0 ? n : e;
|
|
2314
|
-
let from = pos;
|
|
2315
|
-
if (h.strip) while (from < lineEnd && src[from] === "\t") from++;
|
|
2316
|
-
const next = e < 0 ? n : e + 1;
|
|
2317
|
-
if (lineEnd - from === h.delim.length && src.startsWith(h.delim, from)) {
|
|
2318
|
-
end = pos;
|
|
2319
|
-
pos = next;
|
|
2320
|
-
break;
|
|
2321
|
-
}
|
|
2322
|
-
pos = next;
|
|
2323
|
-
}
|
|
2324
|
-
h.cmd.bodies.push(src.slice(start, end));
|
|
2325
|
-
}
|
|
2326
|
-
pending.length = 0;
|
|
2327
|
-
return pos;
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
/** The entries `shellCommands` puts between commands where a subshell opens and closes; a close
|
|
2331
|
-
* read after a construct the tokenizer does not parse exactly is `SUBSHELL_CLOSE_UNSURE`. */
|
|
2332
|
-
const SUBSHELL_OPEN = Object.freeze({ scope: "open" });
|
|
2333
|
-
const SUBSHELL_CLOSE = Object.freeze({ scope: "close" });
|
|
2334
|
-
const SUBSHELL_CLOSE_UNSURE = Object.freeze({ scope: "close" });
|
|
2335
|
-
/** The frame an open `case` holds on the scope stack: a `)` under it ends a pattern. */
|
|
2336
|
-
const CASE_FRAME = Object.freeze({ case: true });
|
|
2337
|
-
/** Reserved words after which a command word is still in keyword position. */
|
|
2338
|
-
const LEAD_WORDS = new Set(["!", "{", "if", "then", "else", "elif", "do", "while", "until"]);
|
|
2339
|
-
|
|
2340
|
-
/** The keyword state after word `t` of a command whose state was `lead`: `time` keeps it through
|
|
2341
|
-
* `-p` and `--`, and `coproc` through one NAME, so a `case` after them is still the keyword. */
|
|
2342
|
-
function nextLead(lead, t) {
|
|
2343
|
-
if (t === "time" || t === "coproc") return t;
|
|
2344
|
-
if (lead === "time" && (t === "-p" || t === "--")) return "time";
|
|
2345
|
-
return LEAD_WORDS.has(t) || (lead === "coproc" && t !== "case" && /^[A-Za-z_]\w*$/.test(t));
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
/** Splits shell source into simple commands in one left-to-right pass that consumes every
|
|
2349
|
-
* quote, escape, expansion and heredoc body once and never backtracks, so hostile input costs
|
|
2350
|
-
* time linear in its length. A word records whether an expansion touched it. A matched `( ... )`
|
|
2351
|
-
* is bracketed by `SUBSHELL_OPEN` and `SUBSHELL_CLOSE`; a `((` reads as two of them until its
|
|
2352
|
-
* inner `)` meets a second `)`, which makes it arithmetic and drops what was read inside, as
|
|
2353
|
-
* bash decides. Once a substitution or a `case` word outside keyword position is read, a `)` may
|
|
2354
|
-
* be misread, so every later close is `SUBSHELL_CLOSE_UNSURE`. */
|
|
2355
|
-
function shellCommands(src) {
|
|
2356
|
-
const n = src.length;
|
|
2357
|
-
const commands = [];
|
|
2358
|
-
const pending = [];
|
|
2359
|
-
const frames = [];
|
|
2360
|
-
let unsure = false;
|
|
2361
|
-
let cmd = null;
|
|
2362
|
-
let word = null;
|
|
2363
|
-
let op = null;
|
|
2364
|
-
const current = (i) => {
|
|
2365
|
-
cmd ??= { start: i, end: i, words: [], redirects: [], bodies: [], lead: true };
|
|
2366
|
-
return cmd;
|
|
2367
|
-
};
|
|
2368
|
-
const endWord = (i) => {
|
|
2369
|
-
if (word === null) return;
|
|
2370
|
-
const c = current(i);
|
|
2371
|
-
c.end = i;
|
|
2372
|
-
if (word.nested) unsure = true;
|
|
2373
|
-
if (op === null) {
|
|
2374
|
-
c.words.push(word);
|
|
2375
|
-
const t = word.quoted || word.expand ? "" : word.text;
|
|
2376
|
-
if (c.lead) {
|
|
2377
|
-
if (t === "case") frames.push(CASE_FRAME);
|
|
2378
|
-
else if (t === "esac" && frames.at(-1) === CASE_FRAME) frames.pop();
|
|
2379
|
-
c.lead = nextLead(c.lead, t);
|
|
2380
|
-
} else if (t === "case") unsure = true;
|
|
2381
|
-
} else if (op === "<<" || op === "<<-") pending.push({ cmd: c, delim: word.text, strip: op === "<<-" });
|
|
2382
|
-
else c.redirects.push({ op, target: word });
|
|
2383
|
-
word = null;
|
|
2384
|
-
op = null;
|
|
2385
|
-
};
|
|
2386
|
-
const endCommand = (i) => {
|
|
2387
|
-
endWord(i);
|
|
2388
|
-
op = null;
|
|
2389
|
-
if (cmd !== null) commands.push(cmd);
|
|
2390
|
-
cmd = null;
|
|
2391
|
-
};
|
|
2392
|
-
let i = 0;
|
|
2393
|
-
while (i < n) {
|
|
2394
|
-
const ch = src[i];
|
|
2395
|
-
if (ch === "\n") {
|
|
2396
|
-
endCommand(i);
|
|
2397
|
-
i = heredocBodies(src, i + 1, pending);
|
|
2398
|
-
} else if (ch === " " || ch === "\t" || ch === "\r") {
|
|
2399
|
-
endWord(i);
|
|
2400
|
-
i++;
|
|
2401
|
-
} else if (ch === "(") {
|
|
2402
|
-
endCommand(i);
|
|
2403
|
-
const arith = src[i + 1] === "(" ? commands.length : undefined;
|
|
2404
|
-
commands.push(SUBSHELL_OPEN);
|
|
2405
|
-
frames.push({});
|
|
2406
|
-
if (arith !== undefined) {
|
|
2407
|
-
commands.push(SUBSHELL_OPEN);
|
|
2408
|
-
frames.push({ arith });
|
|
2409
|
-
}
|
|
2410
|
-
i += arith === undefined ? 1 : 2;
|
|
2411
|
-
} else if (ch === ")") {
|
|
2412
|
-
endCommand(i);
|
|
2413
|
-
const f = frames.at(-1);
|
|
2414
|
-
i++;
|
|
2415
|
-
if (f !== undefined && f !== CASE_FRAME) {
|
|
2416
|
-
frames.pop();
|
|
2417
|
-
if (f.arith !== undefined && src[i] === ")") {
|
|
2418
|
-
frames.pop();
|
|
2419
|
-
commands.length = f.arith;
|
|
2420
|
-
i++;
|
|
2421
|
-
} else commands.push(unsure ? SUBSHELL_CLOSE_UNSURE : SUBSHELL_CLOSE);
|
|
2422
|
-
}
|
|
2423
|
-
} else if (ch === ";") {
|
|
2424
|
-
endCommand(i);
|
|
2425
|
-
i++;
|
|
2426
|
-
} else if (ch === "|" || (ch === "&" && src[i + 1] !== ">")) {
|
|
2427
|
-
endCommand(i);
|
|
2428
|
-
i += src[i + 1] === "|" || src[i + 1] === "&" ? 2 : 1;
|
|
2429
|
-
} else if (ch === "&" || ch === ">" || ch === "<") {
|
|
2430
|
-
const fd = ch !== "&" && word !== null && !word.quoted && !word.expand && /^\d+$/.test(word.text);
|
|
2431
|
-
if (fd) word = null;
|
|
2432
|
-
else endWord(i);
|
|
2433
|
-
// A redirect ends keyword position: `>f case` runs a command named case.
|
|
2434
|
-
current(i).lead = false;
|
|
2435
|
-
const [token, width] = redirectOperator(src, i);
|
|
2436
|
-
op = token;
|
|
2437
|
-
i += width;
|
|
2438
|
-
} else if (ch === "#" && word === null) {
|
|
2439
|
-
const e = src.indexOf("\n", i);
|
|
2440
|
-
i = e < 0 ? n : e;
|
|
2441
|
-
} else if (ch === "\\" && src[i + 1] === "\n") {
|
|
2442
|
-
i += 2;
|
|
2443
|
-
} else {
|
|
2444
|
-
current(i);
|
|
2445
|
-
word ??= { text: "", expand: false, quoted: false };
|
|
2446
|
-
const w = word;
|
|
2447
|
-
if (ch === "\\") {
|
|
2448
|
-
w.quoted = true;
|
|
2449
|
-
w.text += src.slice(i + 1, i + 2);
|
|
2450
|
-
i += 2;
|
|
2451
|
-
} else if (ch === "'") {
|
|
2452
|
-
w.quoted = true;
|
|
2453
|
-
const e = src.indexOf("'", i + 1);
|
|
2454
|
-
const end = e < 0 ? n : e;
|
|
2455
|
-
w.text += src.slice(i + 1, end);
|
|
2456
|
-
i = end + 1;
|
|
2457
|
-
} else if (ch === '"') {
|
|
2458
|
-
w.quoted = true;
|
|
2459
|
-
i = doubleQuoted(src, i + 1, w);
|
|
2460
|
-
} else if (ch === "$" && src[i + 1] === "'") {
|
|
2461
|
-
w.quoted = true;
|
|
2462
|
-
i = ansiC(src, i + 2, w);
|
|
2463
|
-
} else if (ch === "$" || ch === "`") {
|
|
2464
|
-
i = expansion(src, i, w);
|
|
2465
|
-
} else {
|
|
2466
|
-
if (ch === "~" && w.text === "" && !w.quoted) w.expand = true;
|
|
2467
|
-
w.text += ch;
|
|
2468
|
-
i++;
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
endCommand(n);
|
|
2473
|
-
return commands;
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
2164
|
/** Whitespace-collapsed `text`, JSON-quoted when it holds a control character so it cannot forge
|
|
2477
2165
|
* an output line, bounded. */
|
|
2478
2166
|
function scanDetail(text) {
|
|
@@ -2484,28 +2172,6 @@ function shownPath(p) {
|
|
|
2484
2172
|
return quotedText(p).slice(0, SCAN_DETAIL);
|
|
2485
2173
|
}
|
|
2486
2174
|
|
|
2487
|
-
/** Spends `cost` of the scan's `kind` budget (`fs` lookups or path `chars`); false once that
|
|
2488
|
-
* budget is spent, the first refusal adding one `bash-write-budget` note, since past it the scan
|
|
2489
|
-
* decides by text alone or not at all. */
|
|
2490
|
-
function spend(ctx, kind, cost) {
|
|
2491
|
-
if (ctx.budget[kind] > 0) {
|
|
2492
|
-
ctx.budget[kind] -= cost;
|
|
2493
|
-
return true;
|
|
2494
|
-
}
|
|
2495
|
-
if (!ctx.notes.includes("bash-write-budget")) ctx.notes.push("bash-write-budget");
|
|
2496
|
-
return false;
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
/** The value `make()` gives for (`a`, `b`), computed once per pair. Nested maps keep the key the
|
|
2500
|
-
* string itself, whose hash the engine caches, so a lookup costs no pass over a 4 KB path. */
|
|
2501
|
-
function memo(map, a, b, make) {
|
|
2502
|
-
let inner = map.get(a);
|
|
2503
|
-
if (inner === undefined) map.set(a, (inner = new Map()));
|
|
2504
|
-
let v = inner.get(b);
|
|
2505
|
-
if (v === undefined) inner.set(b, (v = make()));
|
|
2506
|
-
return v;
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
2175
|
/** Records one scan line once; past the per-kind cap it only counts toward the capped note. */
|
|
2510
2176
|
function emit(ctx, kind, line, note = false) {
|
|
2511
2177
|
if (ctx.seen.has(line)) return;
|
|
@@ -2519,72 +2185,6 @@ function emit(ctx, kind, line, note = false) {
|
|
|
2519
2185
|
(note ? ctx.notes : ctx.failures).push(line);
|
|
2520
2186
|
}
|
|
2521
2187
|
|
|
2522
|
-
/** Real path of `dir`, or of its longest existing ancestor joined with the rest. A loop with one
|
|
2523
|
-
* lookup per ancestor, because a transcript's directory may hold thousands of components. */
|
|
2524
|
-
function realDir(dir, ctx) {
|
|
2525
|
-
const hit = ctx.dirs.get(dir);
|
|
2526
|
-
if (hit !== undefined) return hit;
|
|
2527
|
-
const abs = path.resolve(dir);
|
|
2528
|
-
const rootLen = path.parse(abs).root.length;
|
|
2529
|
-
let cut = abs.length;
|
|
2530
|
-
let real;
|
|
2531
|
-
for (;;) {
|
|
2532
|
-
const head = cut < rootLen ? abs.slice(0, rootLen) : abs.slice(0, cut);
|
|
2533
|
-
real = ctx.dirs.get(head);
|
|
2534
|
-
if (real !== undefined) break;
|
|
2535
|
-
if (!spend(ctx, "fs", 1)) {
|
|
2536
|
-
real = head;
|
|
2537
|
-
break;
|
|
2538
|
-
}
|
|
2539
|
-
try {
|
|
2540
|
-
real = fs.realpathSync(head);
|
|
2541
|
-
break;
|
|
2542
|
-
} catch {
|
|
2543
|
-
// Missing: try the parent.
|
|
2544
|
-
}
|
|
2545
|
-
if (cut < rootLen) {
|
|
2546
|
-
real = head;
|
|
2547
|
-
break;
|
|
2548
|
-
}
|
|
2549
|
-
cut = abs.lastIndexOf(path.sep, cut - 1);
|
|
2550
|
-
}
|
|
2551
|
-
const rest = cut >= abs.length ? "" : abs.slice(cut + 1);
|
|
2552
|
-
const out = rest === "" ? real : `${real.endsWith(path.sep) ? real : real + path.sep}${rest}`;
|
|
2553
|
-
ctx.dirs.set(dir, out);
|
|
2554
|
-
return out;
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
/** `abs` relative to the working tree after symlinks resolve, or null outside it; once per path.
|
|
2558
|
-
* A path inside only by folded case joins `ctx.folded`. */
|
|
2559
|
-
function insideTree(abs, ctx) {
|
|
2560
|
-
const hit = ctx.inside.get(abs);
|
|
2561
|
-
if (hit !== undefined) return hit;
|
|
2562
|
-
let rel = null;
|
|
2563
|
-
if (abs.length <= SCAN_PATH_MAX) {
|
|
2564
|
-
const cut = abs.lastIndexOf(path.sep);
|
|
2565
|
-
const dir = realDir(cut <= 0 ? path.sep : abs.slice(0, cut), ctx);
|
|
2566
|
-
let real = abs.length === 1 ? abs : `${dir.endsWith(path.sep) ? dir : dir + path.sep}${abs.slice(cut + 1)}`;
|
|
2567
|
-
if (spend(ctx, "fs", 1)) {
|
|
2568
|
-
try {
|
|
2569
|
-
const st = fs.lstatSync(real, { throwIfNoEntry: false });
|
|
2570
|
-
if (st && st.isSymbolicLink()) real = fs.realpathSync(real);
|
|
2571
|
-
} catch {
|
|
2572
|
-
// A dangling or unreachable leaf is written where its text says.
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
rel = treeRelative(real, ctx.tree);
|
|
2576
|
-
if (rel !== null && FOLD_CASE && treeRelative(real, ctx.tree, false) === null) ctx.folded.add(abs);
|
|
2577
|
-
}
|
|
2578
|
-
ctx.inside.set(abs, rel);
|
|
2579
|
-
return rel;
|
|
2580
|
-
}
|
|
2581
|
-
|
|
2582
|
-
/** `insideTree`, or with `exact` null for a path inside only by folded case. */
|
|
2583
|
-
function insideExactly(abs, ctx, exact) {
|
|
2584
|
-
const rel = insideTree(abs, ctx);
|
|
2585
|
-
return exact && ctx.folded.has(abs) ? null : rel;
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
2188
|
/** Whether tree containment ignores case: the default macOS file system does, so a case-variant
|
|
2589
2189
|
* path names the same file and must not read as outside the tree. */
|
|
2590
2190
|
const FOLD_CASE = process.platform === "darwin";
|
|
@@ -2599,63 +2199,28 @@ export function treeRelative(real, tree, fold = FOLD_CASE) {
|
|
|
2599
2199
|
return real.length > root.length && same(real.slice(0, root.length), root) ? real.slice(root.length) : null;
|
|
2600
2200
|
}
|
|
2601
2201
|
|
|
2602
|
-
/** The
|
|
2603
|
-
* the
|
|
2604
|
-
*
|
|
2605
|
-
function
|
|
2606
|
-
const
|
|
2607
|
-
if (hit !== undefined) return hit;
|
|
2608
|
-
const rel = insideTree(abs, ctx);
|
|
2609
|
-
const out = writable(rel);
|
|
2610
|
-
ctx.trees.set(abs, out);
|
|
2611
|
-
// A case-sensitive volume holds another directory at a folded match, so its exemptions do not
|
|
2612
|
-
// hold there and the leak an exact comparison finds still fails.
|
|
2613
|
-
const leak = !ctx.main ? null : rel === null ? treeWrite(abs, ctx.main) : ctx.folded.has(abs) ? writable(insideExactly(abs, ctx.main, true)) : null;
|
|
2614
|
-
if (leak !== null) emit(ctx, "tree-leak", `tree-leak ${shownPath(leak)}`);
|
|
2615
|
-
return out;
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
/** The change workspace `report` sits in, as a third tree sharing the scan's budgets and cache:
|
|
2619
|
-
* `{ tree, report, shown }`, the real directory, the report's name in it and the directory as
|
|
2620
|
-
* printed from the main root; null when the report is not under `.freeharness/sdd/<change>/`.
|
|
2621
|
-
* The name is null unless the report is a regular file with one link, since a symlink or a hard
|
|
2622
|
-
* link there would spare the trusted file it shares. */
|
|
2623
|
-
function changeWorkspace(report, ctx) {
|
|
2624
|
-
const abs = path.resolve(report);
|
|
2625
|
-
const dir = realDir(path.dirname(abs), ctx);
|
|
2626
|
-
const sdd = path.dirname(dir);
|
|
2627
|
-
const fold = (s) => (FOLD_CASE ? s.toLowerCase() : s);
|
|
2628
|
-
if (fold(path.basename(sdd)) !== "sdd" || fold(path.basename(path.dirname(sdd))) !== ".freeharness") return null;
|
|
2629
|
-
const ws = { tree: dir, budget: ctx.budget, notes: ctx.notes, dirs: ctx.dirs, inside: new Map(), folded: new Set(), planted: new Map() };
|
|
2630
|
-
let plain = false;
|
|
2202
|
+
/** The spellings of directory `dir` a transcript may print: as given, its real path and, on
|
|
2203
|
+
* macOS, the real path without `/private`, since `/tmp` and `/var` link into it. A fixed lookup
|
|
2204
|
+
* per root, so a target path is placed by its text alone. */
|
|
2205
|
+
function spellings(dir) {
|
|
2206
|
+
const out = new Set([path.resolve(dir)]);
|
|
2631
2207
|
try {
|
|
2632
|
-
const
|
|
2633
|
-
|
|
2208
|
+
const real = fs.realpathSync(dir);
|
|
2209
|
+
out.add(real);
|
|
2210
|
+
if (real.startsWith("/private/")) out.add(real.slice("/private".length));
|
|
2634
2211
|
} catch {
|
|
2635
|
-
//
|
|
2212
|
+
// A missing root is placed as written.
|
|
2636
2213
|
}
|
|
2637
|
-
|
|
2638
|
-
ws.shown = path.relative(path.dirname(path.dirname(sdd)), dir).split(path.sep).join("/");
|
|
2639
|
-
return ws;
|
|
2214
|
+
return [...out];
|
|
2640
2215
|
}
|
|
2641
2216
|
|
|
2642
|
-
/**
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
const ws = ctx.ws;
|
|
2648
|
-
if (!ws) return false;
|
|
2649
|
-
let hit = ws.planted.get(abs);
|
|
2650
|
-
if (hit === undefined) {
|
|
2651
|
-
const rel = insideTree(abs, ws);
|
|
2652
|
-
hit = rel === null ? null : { report: rel === ws.report && !ws.folded.has(abs), shown: shownPath(rel === "" ? ws.shown : `${ws.shown}/${rel.split(path.sep).join("/")}`) };
|
|
2653
|
-
ws.planted.set(abs, hit);
|
|
2217
|
+
/** `abs` relative to the first of `roots` it lies under ("" for a root itself), or null. */
|
|
2218
|
+
function under(abs, roots) {
|
|
2219
|
+
for (const root of roots) {
|
|
2220
|
+
const rel = treeRelative(abs, root);
|
|
2221
|
+
if (rel !== null) return rel;
|
|
2654
2222
|
}
|
|
2655
|
-
|
|
2656
|
-
const shown = hit.shown;
|
|
2657
|
-
if (kind !== null || ctx.main) emit(ctx, ctx.main ? "tree-leak" : kind, `${ctx.main ? "tree-leak" : kind} ${shown}`);
|
|
2658
|
-
return true;
|
|
2223
|
+
return null;
|
|
2659
2224
|
}
|
|
2660
2225
|
|
|
2661
2226
|
/** `rel` as a posix path, or null for no path, the root itself or a path under `.freeharness/`. */
|
|
@@ -2664,382 +2229,173 @@ function writable(rel) {
|
|
|
2664
2229
|
return p === null || p === ".freeharness" || p.startsWith(".freeharness/") ? null : p;
|
|
2665
2230
|
}
|
|
2666
2231
|
|
|
2667
|
-
/** `
|
|
2668
|
-
*
|
|
2669
|
-
*
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
/** Fails `bash-write` for a write target in the tree or the change workspace; an expanded one can
|
|
2681
|
-
* only be noted. */
|
|
2682
|
-
function writeTarget(word, here, ctx) {
|
|
2683
|
-
const t = word.text;
|
|
2684
|
-
if (word.expand || (here === null && !path.isAbsolute(t))) {
|
|
2685
|
-
emit(ctx, "bash-write-unresolved", `bash-write-unresolved ${shownPath(t)}`, true);
|
|
2686
|
-
return;
|
|
2232
|
+
/** The change workspace `report` sits in: `{ roots, report, shown }`, the directory's spellings,
|
|
2233
|
+
* the report's name in it and the directory as printed from the main root; null when the report
|
|
2234
|
+
* is not under `.freeharness/sdd/<change>/`. The name is null unless the report is a regular file
|
|
2235
|
+
* with one link, since a symlink or a hard link there would spare the trusted file it shares. */
|
|
2236
|
+
function changeWorkspace(report) {
|
|
2237
|
+
const abs = path.resolve(report);
|
|
2238
|
+
let dir = path.dirname(abs);
|
|
2239
|
+
const roots = spellings(dir);
|
|
2240
|
+
try {
|
|
2241
|
+
dir = fs.realpathSync(dir);
|
|
2242
|
+
} catch {
|
|
2243
|
+
// A missing workspace is placed as written.
|
|
2687
2244
|
}
|
|
2688
|
-
|
|
2689
|
-
const
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
/** Commands that delete their operands. A worker deletes its own files with plain `rm`, so only
|
|
2699
|
-
* a removal in the change workspace fails. */
|
|
2700
|
-
const REMOVERS = new Set(["rm", "rmdir", "unlink", "grm", "grmdir", "gunlink"]);
|
|
2701
|
-
|
|
2702
|
-
/** The operands of a removal: none of its options takes a value word, and from the first operand
|
|
2703
|
-
* on every word is one, as BSD getopt stops there. */
|
|
2704
|
-
function removeTargets(args) {
|
|
2705
|
-
let k = 1;
|
|
2706
|
-
while (k < args.length && args[k].text[0] === "-" && args[k].text !== "-") {
|
|
2707
|
-
if (args[k++].text === "--") break;
|
|
2245
|
+
const sdd = path.dirname(dir);
|
|
2246
|
+
const fold = (s) => (FOLD_CASE ? s.toLowerCase() : s);
|
|
2247
|
+
if (fold(path.basename(sdd)) !== "sdd" || fold(path.basename(path.dirname(sdd))) !== ".freeharness") return null;
|
|
2248
|
+
let plain = false;
|
|
2249
|
+
try {
|
|
2250
|
+
const st = fs.lstatSync(path.join(dir, path.basename(abs)), { throwIfNoEntry: false });
|
|
2251
|
+
plain = st !== undefined && st.isFile() && st.nlink === 1;
|
|
2252
|
+
} catch {
|
|
2253
|
+
// An unreadable leaf spares nothing.
|
|
2708
2254
|
}
|
|
2709
|
-
return
|
|
2255
|
+
return { roots, report: plain ? path.basename(abs) : null, shown: path.relative(path.dirname(path.dirname(sdd)), dir).split(path.sep).join("/") };
|
|
2710
2256
|
}
|
|
2711
2257
|
|
|
2712
|
-
/**
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
const t = word.text;
|
|
2716
|
-
if (word.expand || t === "" || t.length > SCAN_PATH_MAX || (here === null && !path.isAbsolute(t))) return;
|
|
2717
|
-
const abs = resolveDir(here, t, ctx);
|
|
2718
|
-
if (abs !== null) plantedWrite(abs, ctx, "bash-write");
|
|
2258
|
+
/** `rel` inside the change workspace as printed from the main root. */
|
|
2259
|
+
function workspacePath(ws, rel) {
|
|
2260
|
+
return shownPath(rel === "" ? ws.shown : `${ws.shown}/${rel.split(path.sep).join("/")}`);
|
|
2719
2261
|
}
|
|
2720
2262
|
|
|
2721
|
-
/**
|
|
2722
|
-
function
|
|
2723
|
-
|
|
2724
|
-
if (!w) return os.homedir();
|
|
2725
|
-
return w.expand || w.text === "-" ? null : resolveDir(here, w.text, ctx);
|
|
2263
|
+
/** `word` with one pair of surrounding quotes dropped. */
|
|
2264
|
+
function unquote(word) {
|
|
2265
|
+
return word.length >= 2 && (word[0] === "'" || word[0] === '"') && word[word.length - 1] === word[0] ? word.slice(1, -1) : word;
|
|
2726
2266
|
}
|
|
2727
2267
|
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2268
|
+
/** `word` as an absolute path from `here`, or null when it is no literal path: an expansion, a
|
|
2269
|
+
* glob, an option, a device, a URL, or a relative path from an unknown directory. */
|
|
2270
|
+
function literalPath(word, here) {
|
|
2271
|
+
const p = unquote(word);
|
|
2272
|
+
if (p === "" || p === "-" || p.length > SCAN_PATH_MAX || p[0] === "-" || p[0] === "~" || /[$`*?()<>]/.test(p)) return null;
|
|
2273
|
+
if (p.startsWith("/dev/") || p.includes("://") || (here === null && !path.isAbsolute(p))) return null;
|
|
2274
|
+
const abs = path.resolve(here ?? path.sep, p);
|
|
2275
|
+
return abs.length > SCAN_PATH_MAX ? null : abs;
|
|
2734
2276
|
}
|
|
2735
2277
|
|
|
2736
|
-
/**
|
|
2737
|
-
*
|
|
2738
|
-
*
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
if (
|
|
2746
|
-
|
|
2747
|
-
|
|
2278
|
+
/** The words of one command segment that name a written path: redirect targets, the operands of
|
|
2279
|
+
* `tee`, `rm`, `touch`, `mv` and `sed -i` (past the script and the `-e` or `-f` values), and
|
|
2280
|
+
* the last operand or `-t` directory of `cp`, `install` and `ln`, whose sources are only named.
|
|
2281
|
+
* An fd duplication such as `2>&1` writes no file. */
|
|
2282
|
+
function writeWords(words) {
|
|
2283
|
+
const out = [];
|
|
2284
|
+
for (let k = 0; k < words.length; k++) {
|
|
2285
|
+
const m = REDIRECT_RE.exec(words[k]);
|
|
2286
|
+
if (!m || (m[1] === "&" && /^(?:\d+|-)?$/.test(m[2]))) continue;
|
|
2287
|
+
if (m[2] !== "") out.push(m[2]);
|
|
2288
|
+
else if (k + 1 < words.length) out.push(words[++k]);
|
|
2289
|
+
}
|
|
2290
|
+
// The command word: past assignments and bare wrapper words, so `git rm` is no removal.
|
|
2291
|
+
let k = 0;
|
|
2292
|
+
while (k < words.length && (/^[A-Za-z_]\w*=/.test(words[k]) || WRAPPER_WORDS.has(words[k]))) k++;
|
|
2293
|
+
if (k < words.length) {
|
|
2294
|
+
const name = path.posix.basename(words[k]);
|
|
2295
|
+
const sed = name === "sed" && words.slice(k + 1).some((w) => /^(?:-[A-Za-z]*i|--in-place)/.test(w));
|
|
2296
|
+
if (!WRITE_WORDS.has(name) && !sed) return out;
|
|
2297
|
+
let ops = [];
|
|
2298
|
+
let target = null;
|
|
2299
|
+
let script = false;
|
|
2300
|
+
for (let j = k + 1; j < words.length; j++) {
|
|
2301
|
+
const w = words[j];
|
|
2302
|
+
const r = REDIRECT_RE.exec(w);
|
|
2303
|
+
if (r || w[0] === "<") {
|
|
2304
|
+
if ((r ? r[2] : w.replace(/^\d*<+-?/, "")) === "") j++;
|
|
2305
|
+
} else if (w[0] !== "-") ops.push(w);
|
|
2306
|
+
else if (sed && w === "-i" && (words[j + 1] === "''" || words[j + 1] === '""')) j++;
|
|
2307
|
+
else if (sed && /^(?:-e|-f|--expression|--file)$/.test(w)) {
|
|
2748
2308
|
script = true;
|
|
2749
|
-
|
|
2750
|
-
} else if (
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
if (t[p] !== "l") script = true;
|
|
2762
|
-
if (last) k++;
|
|
2763
|
-
break;
|
|
2764
|
-
}
|
|
2765
|
-
}
|
|
2766
|
-
} else operands.push(args[k]);
|
|
2767
|
-
}
|
|
2768
|
-
if (!inPlace) return [];
|
|
2769
|
-
return script ? operands : operands.slice(1);
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
/** Files a `perl` word list edits in place: none without `-i`; with `-e` every operand, else
|
|
2773
|
-
* the operands after the script file. */
|
|
2774
|
-
function perlTargets(args) {
|
|
2775
|
-
let inPlace = false;
|
|
2776
|
-
let code = false;
|
|
2777
|
-
const operands = [];
|
|
2778
|
-
let opts = true;
|
|
2779
|
-
for (let k = 1; k < args.length; k++) {
|
|
2780
|
-
const t = args[k].text;
|
|
2781
|
-
if (opts && t === "--") opts = false;
|
|
2782
|
-
else if (opts && t.length > 1 && t[0] === "-") {
|
|
2783
|
-
for (let p = 1; p < t.length; p++) {
|
|
2784
|
-
if (t[p] === "i") {
|
|
2785
|
-
inPlace = true;
|
|
2786
|
-
break;
|
|
2787
|
-
}
|
|
2788
|
-
if (t[p] === "e" || t[p] === "E") {
|
|
2789
|
-
code = true;
|
|
2790
|
-
if (p === t.length - 1) k++;
|
|
2791
|
-
break;
|
|
2792
|
-
}
|
|
2793
|
-
if ("MmIxlCdDF0".includes(t[p])) break;
|
|
2794
|
-
}
|
|
2795
|
-
} else operands.push(args[k]);
|
|
2796
|
-
}
|
|
2797
|
-
if (!inPlace) return [];
|
|
2798
|
-
return code ? operands : operands.slice(1);
|
|
2799
|
-
}
|
|
2800
|
-
|
|
2801
|
-
/** Paths a `cp`, `mv`, `install` or `ln` word list writes: every `-t`/`--target-directory`
|
|
2802
|
-
* value, else the last of two or more operands, or the basename of a lone `ln` operand in the
|
|
2803
|
-
* current directory; `install -d` creates every operand and `mv` deletes each source, so each
|
|
2804
|
-
* operand is one there. Options count anywhere before `--`, as GNU getopt permutes them, and
|
|
2805
|
-
* one taking a value consumes it, so a suffix, a mode or a sparse mode after the operands is no
|
|
2806
|
-
* false last operand. BSD getopt stops at the first operand instead, so once an option word
|
|
2807
|
-
* follows it every later operand is a target too, as is the last word (BSD's destination) even
|
|
2808
|
-
* when a GNU option took it as its value, and for `mv` the first operand and every word after it
|
|
2809
|
-
* (BSD's moved sources); a value taken before the last word stays out for the others, which BSD
|
|
2810
|
-
* only reads. The GNU result comes first and is never dropped. */
|
|
2811
|
-
function copyTargets(name, args) {
|
|
2812
|
-
const tool = name.length > 2 && name[0] === "g" ? name.slice(1) : name;
|
|
2813
|
-
const short = tool === "install" ? "mogSt" : "St";
|
|
2814
|
-
const extra = tool === "install" ? INSTALL_LONG_VALUED.concat([["--directory", 4]]) : tool === "cp" ? CP_LONG_VALUED : [];
|
|
2815
|
-
const long = COPY_LONG_VALUED.concat(extra);
|
|
2816
|
-
const dirs = [];
|
|
2817
|
-
const operands = [];
|
|
2818
|
-
// Words from the first operand on that BSD would read as operands.
|
|
2819
|
-
let bsd = [];
|
|
2820
|
-
let first = -1;
|
|
2821
|
-
let optionAfter = false;
|
|
2822
|
-
let mkdirs = false;
|
|
2823
|
-
let opts = true;
|
|
2824
|
-
for (let k = 1; k < args.length; k++) {
|
|
2825
|
-
const w = args[k];
|
|
2826
|
-
const t = w.text;
|
|
2827
|
-
const seen = operands.length > 0;
|
|
2828
|
-
if (!opts || t === "-" || t[0] !== "-") {
|
|
2829
|
-
if (!seen) first = k;
|
|
2830
|
-
operands.push(w);
|
|
2831
|
-
if (seen || tool === "mv") bsd.push(w);
|
|
2832
|
-
continue;
|
|
2833
|
-
}
|
|
2834
|
-
if (seen) {
|
|
2835
|
-
optionAfter = true;
|
|
2836
|
-
if (tool === "mv" || k === args.length - 1) bsd.push(w);
|
|
2837
|
-
}
|
|
2838
|
-
if (t === "--") opts = false;
|
|
2839
|
-
else if (t[1] === "-") {
|
|
2840
|
-
const eq = t.indexOf("=");
|
|
2841
|
-
const opt = eq < 0 ? t : t.slice(0, eq);
|
|
2842
|
-
const hit = long.find(([full, min]) => opt.length >= min && full.startsWith(opt));
|
|
2843
|
-
if (!hit) continue;
|
|
2844
|
-
if (hit[0] === "--directory") {
|
|
2845
|
-
mkdirs = true;
|
|
2846
|
-
continue;
|
|
2847
|
-
}
|
|
2848
|
-
const value = eq < 0 ? args[++k] : { ...w, text: t.slice(eq + 1) };
|
|
2849
|
-
if (hit[0] === "--target-directory" && value) dirs.push(value);
|
|
2850
|
-
} else {
|
|
2851
|
-
for (let p = 1; p < t.length; p++) {
|
|
2852
|
-
if (tool === "install" && t[p] === "d") mkdirs = true;
|
|
2853
|
-
if (!short.includes(t[p])) continue;
|
|
2854
|
-
const value = p === t.length - 1 ? args[++k] : { ...w, text: t.slice(p + 1) };
|
|
2855
|
-
if (t[p] === "t" && value) dirs.push(value);
|
|
2856
|
-
break;
|
|
2857
|
-
}
|
|
2858
|
-
}
|
|
2859
|
-
}
|
|
2860
|
-
let gnu;
|
|
2861
|
-
if (mkdirs || (dirs.length > 0 && tool === "mv")) gnu = dirs.concat(operands);
|
|
2862
|
-
else if (dirs.length > 0) gnu = dirs;
|
|
2863
|
-
else if (operands.length >= 2) gnu = tool === "mv" ? operands : [operands[operands.length - 1]];
|
|
2864
|
-
else if (tool === "ln" && operands.length === 1) gnu = [operands[0].expand ? operands[0] : { ...operands[0], text: path.posix.basename(operands[0].text) }];
|
|
2865
|
-
else gnu = [];
|
|
2866
|
-
if (!optionAfter) return gnu;
|
|
2867
|
-
if (tool === "mv") bsd = args.slice(first);
|
|
2868
|
-
else if (!bsd.includes(args[args.length - 1])) bsd.push(args[args.length - 1]);
|
|
2869
|
-
const kept = new Set(gnu);
|
|
2870
|
-
return gnu.concat(bsd.filter((w) => !kept.has(w)));
|
|
2871
|
-
}
|
|
2872
|
-
|
|
2873
|
-
/** Operands of a word list whose options may sit anywhere before `--`: a short option in
|
|
2874
|
-
* `short` or a long one in `long` takes the next word as its value unless joined, any other
|
|
2875
|
-
* option is a flag. */
|
|
2876
|
-
function permutedOperands(args, short, long) {
|
|
2877
|
-
const operands = [];
|
|
2878
|
-
let opts = true;
|
|
2879
|
-
for (let k = 1; k < args.length; k++) {
|
|
2880
|
-
const t = args[k].text;
|
|
2881
|
-
if (!opts || t === "-" || t[0] !== "-") operands.push(args[k]);
|
|
2882
|
-
else if (t === "--") opts = false;
|
|
2883
|
-
else if (t[1] === "-") {
|
|
2884
|
-
if (!t.includes("=") && long.includes(t)) k++;
|
|
2885
|
-
} else {
|
|
2886
|
-
for (let p = 1; p < t.length; p++) {
|
|
2887
|
-
if (!short.includes(t[p])) continue;
|
|
2888
|
-
if (p === t.length - 1) k++;
|
|
2889
|
-
break;
|
|
2890
|
-
}
|
|
2891
|
-
}
|
|
2892
|
-
}
|
|
2893
|
-
return operands;
|
|
2894
|
-
}
|
|
2895
|
-
|
|
2896
|
-
/** Files a `touch` word list creates or stamps: every operand; `-r`, `-d`, `-t` and BSD's `-A`
|
|
2897
|
-
* take a value. */
|
|
2898
|
-
function touchTargets(args) {
|
|
2899
|
-
return permutedOperands(args, "rdtA", ["--reference", "--date", "--time"]);
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
/** Files a `truncate` word list sizes: every operand; `-s` and `-r` take a value. */
|
|
2903
|
-
function truncateTargets(args) {
|
|
2904
|
-
return permutedOperands(args, "sr", ["--size", "--reference"]);
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
/** The files a `dd` word list writes: the value of each `of=`. */
|
|
2908
|
-
function ddTargets(args) {
|
|
2909
|
-
return args.slice(1).filter((w) => w.text.startsWith("of=")).map((w) => ({ ...w, text: w.text.slice(3) }));
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
/** Collectors of the paths each file-writing command writes, keyed by name, with the `g` names
|
|
2913
|
-
* Homebrew gives GNU coreutils for the coreutils ones. */
|
|
2914
|
-
const WRITERS = new Map([
|
|
2915
|
-
["tee", teeTargets],
|
|
2916
|
-
["sed", sedTargets],
|
|
2917
|
-
["perl", perlTargets],
|
|
2918
|
-
...["touch", "truncate", "dd"].flatMap((n) => {
|
|
2919
|
-
const f = n === "touch" ? touchTargets : n === "truncate" ? truncateTargets : ddTargets;
|
|
2920
|
-
return [[n, f], [`g${n}`, f]];
|
|
2921
|
-
}),
|
|
2922
|
-
...[...COPY_TOOLS].map((n) => [n, (args) => copyTargets(n, args)]),
|
|
2923
|
-
...["link", "glink"].map((n) => [n, linkTargets]),
|
|
2924
|
-
]);
|
|
2925
|
-
|
|
2926
|
-
/** The name `link FILE1 FILE2` creates: its second operand. */
|
|
2927
|
-
function linkTargets(args) {
|
|
2928
|
-
const ops = args.slice(1).filter((w) => w.text !== "--");
|
|
2929
|
-
return ops.slice(1, 2);
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
/** The index of the command word in `words` from `from` past every reserved word, assignment and
|
|
2933
|
-
* wrapper with its options, whether `xargs` was one of the wrappers, and each wrapper's chdir
|
|
2934
|
-
* directory word in order, every one read from the previous. Wrappers nest, as in `sudo env -i nice -n 5 cp`, and match quoted
|
|
2935
|
-
* or by path, as the shell runs them either way. */
|
|
2936
|
-
function unwrapCommand(words, from) {
|
|
2937
|
-
let k = from;
|
|
2938
|
-
let xargs = false;
|
|
2939
|
-
const chdirs = [];
|
|
2940
|
-
while (k < words.length) {
|
|
2941
|
-
const w = words[k];
|
|
2942
|
-
if (!w.quoted && RESERVED_PREFIXES.has(w.text)) k++;
|
|
2943
|
-
else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text)) k++;
|
|
2944
|
-
else {
|
|
2945
|
-
const name = w.expand ? "" : path.posix.basename(w.text);
|
|
2946
|
-
const spec = COMMAND_PREFIXES.get(name);
|
|
2947
|
-
if (spec === undefined) break;
|
|
2948
|
-
if (name === "xargs") xargs = true;
|
|
2949
|
-
k = prefixOptions(words, k + 1, spec, chdirs);
|
|
2950
|
-
}
|
|
2309
|
+
j++;
|
|
2310
|
+
} else if (sed && /^(?:-e|-f|--expression=|--file=)/.test(w)) script = true;
|
|
2311
|
+
else if (COPY_WORDS.has(name) && w === "-t") target = words[++j] ?? null;
|
|
2312
|
+
else if (COPY_WORDS.has(name) && w.startsWith("--target-directory=")) target = w.slice("--target-directory=".length);
|
|
2313
|
+
else if (name === "install" && /^-[mog]$/.test(w)) j++;
|
|
2314
|
+
}
|
|
2315
|
+
// Without -e or -f the first operand is the sed script, no path.
|
|
2316
|
+
if (sed && !script) ops.shift();
|
|
2317
|
+
// An mv removes its sources, so each is a written path; cp, install and ln only read theirs.
|
|
2318
|
+
if (COPY_WORDS.has(name) && target !== null) ops = name === "mv" ? [...ops, target] : [target];
|
|
2319
|
+
else if (COPY_WORDS.has(name) && name !== "mv") ops = ops.slice(-1);
|
|
2320
|
+
out.push(...ops);
|
|
2951
2321
|
}
|
|
2952
|
-
return
|
|
2322
|
+
return out;
|
|
2953
2323
|
}
|
|
2954
2324
|
|
|
2955
|
-
/**
|
|
2956
|
-
*
|
|
2957
|
-
*
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
const
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
k++;
|
|
2968
|
-
break;
|
|
2969
|
-
}
|
|
2970
|
-
if (t[0] !== "-") break;
|
|
2971
|
-
k++;
|
|
2972
|
-
if (t.startsWith("--")) {
|
|
2973
|
-
const eq = t.indexOf("=");
|
|
2974
|
-
const opt = eq < 0 ? t : t.slice(0, eq);
|
|
2975
|
-
if (min > 0 && opt.length >= min && "--chdir".startsWith(opt)) {
|
|
2976
|
-
dir = eq < 0 ? valueAt(k++) : { ...w, text: t.slice(eq + 1) };
|
|
2977
|
-
} else if (eq < 0 && spec.long.includes(t)) k++;
|
|
2978
|
-
continue;
|
|
2979
|
-
}
|
|
2980
|
-
for (let p = 1; p < t.length; p++) {
|
|
2981
|
-
if (!spec.short.includes(t[p])) continue;
|
|
2982
|
-
const last = p === t.length - 1;
|
|
2983
|
-
if (t[p] === letter) dir = last ? valueAt(k) : { ...w, text: t.slice(p + 1) };
|
|
2984
|
-
if (last) k++;
|
|
2985
|
-
break;
|
|
2986
|
-
}
|
|
2325
|
+
/** Notes `bash-path` for a path a Bash command names: any path in the change workspace or, in a
|
|
2326
|
+
* tree run, in the main root outside the tree, its admin dir and its `.freeharness/`; and a `write` target in
|
|
2327
|
+
* the tree outside the write scope and `.freeharness/`, or outside the tree. A note, never a
|
|
2328
|
+
* failure: the diff guards and the lead's diff read decide. */
|
|
2329
|
+
function bashPath(abs, write, ctx) {
|
|
2330
|
+
const inWs = ctx.ws ? under(abs, ctx.ws.roots) : null;
|
|
2331
|
+
if (inWs !== null) return emit(ctx, "bash-path", `bash-path ${workspacePath(ctx.ws, inWs)}`, true);
|
|
2332
|
+
const rel = under(abs, ctx.tree);
|
|
2333
|
+
if (rel !== null) {
|
|
2334
|
+
const p = writable(rel);
|
|
2335
|
+
if (write && p !== null && !ctx.scope.some((s) => scopeMatches(s, p))) emit(ctx, "bash-path", `bash-path ${shownPath(p)}`, true);
|
|
2336
|
+
return;
|
|
2987
2337
|
}
|
|
2988
|
-
|
|
2989
|
-
|
|
2338
|
+
const inMain = ctx.main ? under(abs, ctx.main) : null;
|
|
2339
|
+
if (inMain !== null) {
|
|
2340
|
+
const p = writable(inMain);
|
|
2341
|
+
// The tree's admin dir belongs to the tree: like a tree path, only a write there is noted.
|
|
2342
|
+
const own = !write && adminDir(ctx) !== null && under(abs, ctx.admin) !== null;
|
|
2343
|
+
if (p !== null && !own) emit(ctx, "bash-path", `bash-path ${shownPath(p)}`, true);
|
|
2344
|
+
} else if (write) emit(ctx, "bash-path", `bash-path ${shownPath(abs)}`, true);
|
|
2990
2345
|
}
|
|
2991
2346
|
|
|
2992
|
-
/** The
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
for (let k = 1; k < args.length; k++) {
|
|
2998
|
-
if (!/^-(?:exec|execdir|ok|okdir)$/.test(args[k].text)) continue;
|
|
2999
|
-
const seg = [];
|
|
3000
|
-
for (k++; k < args.length; k++) {
|
|
3001
|
-
const t = args[k].text;
|
|
3002
|
-
if (t === ";" || (t === "+" && seg.length > 0 && seg[seg.length - 1].text === "{}")) break;
|
|
3003
|
-
seg.push(t.includes("{}") ? { ...args[k], expand: true } : args[k]);
|
|
3004
|
-
}
|
|
3005
|
-
out.push(seg);
|
|
2347
|
+
/** The spellings of the tree's git admin dir, looked up once per scan; null when git names none. */
|
|
2348
|
+
function adminDir(ctx) {
|
|
2349
|
+
if (ctx.admin === undefined) {
|
|
2350
|
+
const info = treeInfo(ctx.mainDir, ctx.treeDir);
|
|
2351
|
+
ctx.admin = info.ok ? spellings(info.gitDir) : null;
|
|
3006
2352
|
}
|
|
3007
|
-
return
|
|
2353
|
+
return ctx.admin;
|
|
3008
2354
|
}
|
|
3009
2355
|
|
|
3010
2356
|
/** Fails `index-write` for a git command that writes the index or HEAD when its effective
|
|
3011
2357
|
* directory lies in the working tree or its admin dir, and `tree-leak` when it lies elsewhere in
|
|
3012
2358
|
* the main root; a temporary repository stays free. `--git-dir`, `--work-tree` and the `GIT_DIR`,
|
|
3013
|
-
* `GIT_WORK_TREE` and `GIT_INDEX_FILE` assignments
|
|
3014
|
-
* index a command writes away from its directory.
|
|
3015
|
-
|
|
2359
|
+
* `GIT_WORK_TREE` and `GIT_INDEX_FILE` assignments before `git` are judged too, since they move the
|
|
2360
|
+
* index a command writes away from its directory. `git` counts only as the command word: every
|
|
2361
|
+
* word before it is an assignment, a wrapper word, an option after a wrapper or that option's
|
|
2362
|
+
* value, so `sudo -u me git` counts while `echo remember to git add` does not. */
|
|
2363
|
+
function gitIndexWrite(words, here, text, ctx) {
|
|
2364
|
+
let g = 0;
|
|
2365
|
+
let wrapped = false;
|
|
2366
|
+
while (g < words.length && words[g] !== "git" && !words[g].endsWith("/git")) {
|
|
2367
|
+
const w = words[g];
|
|
2368
|
+
const value = wrapped && g > 0 && words[g - 1][0] === "-" && w[0] !== "-";
|
|
2369
|
+
if (WRAPPER_WORDS.has(w)) wrapped = true;
|
|
2370
|
+
else if (!/^[A-Za-z_]\w*=/.test(w) && !(wrapped && w[0] === "-") && !value) return;
|
|
2371
|
+
g++;
|
|
2372
|
+
}
|
|
2373
|
+
if (g >= words.length) return;
|
|
3016
2374
|
let dir = here;
|
|
3017
2375
|
// Values git resolves only after every -C, as it does.
|
|
3018
2376
|
const repo = [];
|
|
3019
|
-
for (const w of
|
|
3020
|
-
const m = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE)=/.exec(w
|
|
3021
|
-
if (m) repo.push(w.
|
|
2377
|
+
for (const w of words.slice(0, g)) {
|
|
2378
|
+
const m = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE)=/.exec(w);
|
|
2379
|
+
if (m) repo.push(w.slice(m[0].length));
|
|
3022
2380
|
}
|
|
3023
|
-
let k = 1;
|
|
3024
|
-
while (k <
|
|
3025
|
-
const t =
|
|
2381
|
+
let k = g + 1;
|
|
2382
|
+
while (k < words.length && words[k].startsWith("-")) {
|
|
2383
|
+
const t = words[k];
|
|
3026
2384
|
const eq = /^--(?:git-dir|work-tree)=/.exec(t);
|
|
3027
2385
|
if (t === "-C") {
|
|
3028
|
-
|
|
3029
|
-
dir = !d || d.expand ? null : resolveDir(dir, d.text, ctx);
|
|
2386
|
+
dir = words[k + 1] === undefined ? null : literalPath(words[k + 1], dir);
|
|
3030
2387
|
k += 2;
|
|
3031
2388
|
} else if (eq) {
|
|
3032
|
-
repo.push(
|
|
2389
|
+
repo.push(t.slice(eq[0].length));
|
|
3033
2390
|
k++;
|
|
3034
2391
|
} else if (t === "--git-dir" || t === "--work-tree") {
|
|
3035
|
-
|
|
3036
|
-
repo.push(!d || d.expand ? null : d.text);
|
|
2392
|
+
if (words[k + 1] !== undefined) repo.push(words[k + 1]);
|
|
3037
2393
|
k += 2;
|
|
3038
2394
|
} else k += GIT_VALUED.has(t) ? 2 : 1;
|
|
3039
2395
|
}
|
|
3040
|
-
if (k >=
|
|
3041
|
-
const sub =
|
|
3042
|
-
const rest =
|
|
2396
|
+
if (k >= words.length) return;
|
|
2397
|
+
const sub = words[k];
|
|
2398
|
+
const rest = words.slice(k + 1);
|
|
3043
2399
|
const writes =
|
|
3044
2400
|
INDEX_SUBCOMMANDS.has(sub) ||
|
|
3045
2401
|
(sub === "stash" && rest[0] !== "list" && rest[0] !== "show") ||
|
|
@@ -3049,182 +2405,73 @@ function gitIndexWrite(args, here, text, ctx, env = []) {
|
|
|
3049
2405
|
if (!writes) return;
|
|
3050
2406
|
const dirs = dir === null ? [] : [dir];
|
|
3051
2407
|
for (const v of repo) {
|
|
3052
|
-
const abs =
|
|
2408
|
+
const abs = literalPath(v, dir);
|
|
3053
2409
|
if (abs !== null) dirs.push(abs);
|
|
3054
2410
|
}
|
|
3055
|
-
|
|
3056
|
-
const info = treeInfo(ctx.main.tree, ctx.tree);
|
|
3057
|
-
ctx.admin = info.ok ? path.relative(ctx.main.tree, info.gitDir) : null;
|
|
3058
|
-
}
|
|
2411
|
+
const admin = ctx.main ? adminDir(ctx) : null;
|
|
3059
2412
|
// Only git reads the admin dir as the tree's index; a file write there stays a leak.
|
|
3060
|
-
const own = (d,
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
const t = args[k].text;
|
|
3077
|
-
if (flags.includes(t) || (/^-[A-Za-z]+$/.test(t) && flags.includes(`-${t[t.length - 1]}`))) bodies.push(args[++k].text);
|
|
3078
|
-
}
|
|
3079
|
-
for (const r of c.redirects) if (r.op === "<<<") bodies.push(r.target.text);
|
|
3080
|
-
return bodies.concat(c.bodies);
|
|
3081
|
-
}
|
|
3082
|
-
|
|
3083
|
-
/** Quoted string literals of one line each in `body`. */
|
|
3084
|
-
function quotedLiterals(body) {
|
|
3085
|
-
const out = [];
|
|
3086
|
-
const n = body.length;
|
|
3087
|
-
let i = 0;
|
|
3088
|
-
while (i < n) {
|
|
3089
|
-
const q = body[i];
|
|
3090
|
-
if (q !== "'" && q !== '"' && q !== "`") {
|
|
3091
|
-
i++;
|
|
2413
|
+
const own = (d) => under(d, ctx.tree) !== null || (admin !== null && under(d, admin) !== null);
|
|
2414
|
+
if (dirs.some(own)) emit(ctx, "index-write", `index-write ${scanDetail(text)}`);
|
|
2415
|
+
if (ctx.main && dirs.some((d) => !own(d) && under(d, ctx.main) !== null)) emit(ctx, "tree-leak", `tree-leak ${scanDetail(text)}`);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/** Applies the literal checks to one Bash command run from `here`. The command splits on the
|
|
2419
|
+
* separators alone and each segment on whitespace; a leading `cd` or `pushd` moves `here` for
|
|
2420
|
+
* the segments after it, and an expanded directory leaves it unknown. */
|
|
2421
|
+
function scanBash(command, here, ctx) {
|
|
2422
|
+
let cwdNoted = false;
|
|
2423
|
+
for (const segment of command.split(SEPARATOR_RE)) {
|
|
2424
|
+
const words = segment.trim().split(/\s+/).filter((w) => w !== "");
|
|
2425
|
+
if (words.length === 0) continue;
|
|
2426
|
+
if (words[0] === "cd" || words[0] === "pushd") {
|
|
2427
|
+
const arg = words.slice(1).find((w) => !/^-[A-Za-z@]+$/.test(w));
|
|
2428
|
+
here = arg === undefined ? null : literalPath(arg, here);
|
|
3092
2429
|
continue;
|
|
3093
2430
|
}
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
return m.startsWith(">") || m.startsWith("+<") || (m.length <= 4 && /^[rwaxbtU+]+$/.test(m) && /[wax+]/.test(m));
|
|
3106
|
-
}
|
|
3107
|
-
|
|
3108
|
-
/** Whether an `open(` call in `body` passes a write mode within its first 200 characters. */
|
|
3109
|
-
function opensForWrite(body) {
|
|
3110
|
-
for (let at = body.indexOf("open("); at >= 0; at = body.indexOf("open(", at + 5)) {
|
|
3111
|
-
const window = body.slice(at + 5, at + 205);
|
|
3112
|
-
const cut = window.indexOf(")");
|
|
3113
|
-
if (quotedLiterals(cut < 0 ? window : window.slice(0, cut)).some(isWriteMode)) return true;
|
|
3114
|
-
}
|
|
3115
|
-
return false;
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
/** Whether an interpreter body calls a file-write function and names a literal path that lands
|
|
3119
|
-
* in the working tree outside `.freeharness/`, or in the change workspace of a shared-tree run. */
|
|
3120
|
-
function writesInTree(body, here, ctx) {
|
|
3121
|
-
if (!WRITE_CALLS.some((s) => body.includes(s)) && !opensForWrite(body)) return false;
|
|
3122
|
-
for (const lit of quotedLiterals(body)) {
|
|
3123
|
-
let s = 0;
|
|
3124
|
-
while (s < lit.length && "<>+ ".includes(lit[s])) s++;
|
|
3125
|
-
const p = lit.slice(s);
|
|
3126
|
-
if (p === "" || p.length > SCAN_PATH_MAX || p[0] === "-" || /\s/.test(p) || p.includes("://") || !/[./]/.test(p)) continue;
|
|
3127
|
-
const abs = resolveDir(here, p, ctx);
|
|
3128
|
-
if (abs !== null && treeWrite(abs, ctx) !== null) return true;
|
|
3129
|
-
// A tree run fails the workspace path itself, so the interpreter line is the shared run's.
|
|
3130
|
-
if (abs !== null && plantedWrite(abs, ctx, null) && !ctx.main) return true;
|
|
3131
|
-
}
|
|
3132
|
-
return false;
|
|
3133
|
-
}
|
|
3134
|
-
|
|
3135
|
-
/** Applies every check to the simple commands of one shell source run from `here`; a `cd`
|
|
3136
|
-
* carries to the commands after it up to the end of its subshell, and a shell's own `-c` body
|
|
3137
|
-
* is scanned two levels deep. `flat` is the directory with every `cd` carried past its subshell;
|
|
3138
|
-
* after a close that may be misread, each target is judged from both, so a misread `)` can only
|
|
3139
|
-
* add failures. */
|
|
3140
|
-
function scanShell(src, here, ctx, depth) {
|
|
3141
|
-
let noted = false;
|
|
3142
|
-
const saved = [];
|
|
3143
|
-
let flat = here;
|
|
3144
|
-
let unsure = false;
|
|
3145
|
-
for (const c of shellCommands(src)) {
|
|
3146
|
-
if (c === SUBSHELL_OPEN) saved.push(here);
|
|
3147
|
-
else if (c === SUBSHELL_CLOSE || c === SUBSHELL_CLOSE_UNSURE) {
|
|
3148
|
-
unsure ||= c === SUBSHELL_CLOSE_UNSURE;
|
|
3149
|
-
if (saved.length > 0) here = saved.pop();
|
|
3150
|
-
}
|
|
3151
|
-
if (c.scope !== undefined) continue;
|
|
3152
|
-
const dirs = unsure && flat !== here ? [here, flat] : [here];
|
|
3153
|
-
const { k, xargs, chdirs } = unwrapCommand(c.words, 0);
|
|
3154
|
-
const args = c.words.slice(k);
|
|
3155
|
-
const name = args.length > 0 ? path.posix.basename(args[0].text) : "";
|
|
3156
|
-
const text = src.slice(c.start, c.end);
|
|
3157
|
-
if (name !== "[[") {
|
|
3158
|
-
for (const r of c.redirects) {
|
|
3159
|
-
if (WRITE_REDIRECTS.has(r.op) && !(r.op === ">&" && /^(?:\d+|-)$/.test(r.target.text))) for (const d of dirs) writeTarget(r.target, d, ctx);
|
|
2431
|
+
if (ctx.main && !cwdNoted && here !== null && under(here, ctx.tree) === null) {
|
|
2432
|
+
cwdNoted = true;
|
|
2433
|
+
emit(ctx, "tree-cwd", `tree-cwd ${scanDetail(command)}`, true);
|
|
2434
|
+
}
|
|
2435
|
+
gitIndexWrite(words, here, segment, ctx);
|
|
2436
|
+
const written = writeWords(words);
|
|
2437
|
+
const named = words.filter((w) => !written.includes(w) && unquote(w).includes("/"));
|
|
2438
|
+
for (const [list, write] of [[written, true], [named, false]]) {
|
|
2439
|
+
for (const w of list) {
|
|
2440
|
+
const abs = literalPath(w, here);
|
|
2441
|
+
if (abs !== null) bashPath(abs, write, ctx);
|
|
3160
2442
|
}
|
|
3161
2443
|
}
|
|
3162
|
-
// The note quotes the whole call, so it is built once per call, keeping the scan linear; a
|
|
3163
|
-
// leading cd places it.
|
|
3164
|
-
if (!noted && ctx.main && depth === 0 && name !== "" && name !== "cd" && name !== "pushd" && here !== null && insideTree(here, ctx) === null) {
|
|
3165
|
-
noted = true;
|
|
3166
|
-
emit(ctx, "tree-cwd", `tree-cwd ${scanDetail(src)}`, true);
|
|
3167
|
-
}
|
|
3168
|
-
if (name === "cd" || name === "pushd") {
|
|
3169
|
-
const was = here;
|
|
3170
|
-
here = changeDir(args, here, ctx);
|
|
3171
|
-
flat = flat === was ? here : changeDir(args, flat, ctx);
|
|
3172
|
-
}
|
|
3173
|
-
if (name !== "cd" && name !== "pushd") for (const d of dirs) judgeWrapped(args, c.words.slice(0, k), xargs, chdirs, { c, here: d, ctx, text, depth, finds: 0 });
|
|
3174
|
-
if (ctx.suite !== "" && text.replace(/\s+/g, " ").includes(ctx.suite)) emit(ctx, "suite-run", "suite-run");
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
|
|
3178
|
-
/** `judgeCommand` from the shell's directory `at.here` and, behind a wrapper chdir, also from an
|
|
3179
|
-
* unknown one: the chdir value is never resolved, so an absolute target is judged as from the
|
|
3180
|
-
* shell and a relative one is only noted. The redirects stay the shell's. */
|
|
3181
|
-
function judgeWrapped(args, env, xargs, chdirs, at) {
|
|
3182
|
-
judgeCommand(args, env, xargs, at);
|
|
3183
|
-
if (chdirs.length > 0) judgeCommand(args, env, xargs, { ...at, here: null });
|
|
3184
|
-
}
|
|
3185
|
-
|
|
3186
|
-
/** Applies the write, index and interpreter checks to one command word list `args`, run behind
|
|
3187
|
-
* the prefix words `env`, from `at.here`. Under `xargs` a writer also notes the operand stdin
|
|
3188
|
-
* supplies, and each `find -exec` command is judged in turn, `FIND_DEPTH` levels deep. */
|
|
3189
|
-
function judgeCommand(args, env, xargs, at) {
|
|
3190
|
-
const { c, here, ctx, text, depth } = at;
|
|
3191
|
-
const name = args.length > 0 ? path.posix.basename(args[0].text) : "";
|
|
3192
|
-
const collect = WRITERS.get(name);
|
|
3193
|
-
if (collect) {
|
|
3194
|
-
for (const w of collect(args)) writeTarget(w, here, ctx);
|
|
3195
|
-
if (xargs) writeTarget(XARGS_OPERAND, here, ctx);
|
|
3196
|
-
} else if (REMOVERS.has(name)) {
|
|
3197
|
-
if (ctx.ws) for (const w of removeTargets(args)) removedPath(w, here, ctx);
|
|
3198
|
-
} else if (name === "git") gitIndexWrite(args, here, text, ctx, env);
|
|
3199
|
-
else if (name === "find" && at.finds < FIND_DEPTH) {
|
|
3200
|
-
// The heredocs and here-strings stay with the outer command, so a run of -exec shells does
|
|
3201
|
-
// not rescan one body per segment.
|
|
3202
|
-
const inner = { ...at, c: { redirects: [], bodies: [] }, finds: at.finds + 1 };
|
|
3203
|
-
for (const seg of execSegments(args)) {
|
|
3204
|
-
const u = unwrapCommand(seg, 0);
|
|
3205
|
-
judgeWrapped(seg.slice(u.k), seg.slice(0, u.k), xargs || u.xargs, u.chdirs, inner);
|
|
3206
|
-
}
|
|
3207
2444
|
}
|
|
3208
|
-
if (
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
*
|
|
2445
|
+
if (ctx.suite !== "" && command.replace(/\s+/g, " ").includes(ctx.suite)) emit(ctx, "suite-run", "suite-run");
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** Judges one Edit, MultiEdit, Write or NotebookEdit path: in a tree run a path under the main
|
|
2449
|
+
* root outside the tree and its `.freeharness/` fails `tree-leak`; a path in the change workspace
|
|
2450
|
+
* other than a spared report fails `tree-leak` in a tree run, else `workspace-write`. An edit
|
|
2451
|
+
* inside the tree is the diff guards' to judge. */
|
|
2452
|
+
function toolWrite(abs, ctx) {
|
|
2453
|
+
if (ctx.main && under(abs, ctx.tree) === null) {
|
|
2454
|
+
const p = writable(under(abs, ctx.main));
|
|
2455
|
+
if (p !== null) emit(ctx, "tree-leak", `tree-leak ${shownPath(p)}`);
|
|
2456
|
+
}
|
|
2457
|
+
const rel = ctx.ws ? under(abs, ctx.ws.roots) : null;
|
|
2458
|
+
if (rel === null || rel === ctx.ws.report) return;
|
|
2459
|
+
const kind = ctx.main ? "tree-leak" : "workspace-write";
|
|
2460
|
+
emit(ctx, kind, `${kind} ${workspacePath(ctx.ws, rel)}`);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** Checks every tool call a worker transcript records (at most `SCAN_BOUND` bytes of it) by
|
|
2464
|
+
* literal rules: Bash commands for index writes, a run of the brief's `Suite:` command and the
|
|
2465
|
+
* paths they name, and tool writes for a tree leak or a planted change workspace. The working
|
|
2466
|
+
* tree is the git top-level of `cwd`, or `cwd` itself with a `transcript-tree-unresolved <git's
|
|
2467
|
+
* first error line>` note when git names none. Malformed lines are skipped. Returns
|
|
2468
|
+
* `{ failures, notes, truncated }`: the FAIL and NOTE line bodies in scan order and whether the
|
|
2469
|
+
* bound cut the transcript, which the caller notes with the agent id; an unreadable transcript
|
|
2470
|
+
* gives no failures and the one note `transcript-unavailable unreadable`. With `mainRoot`, `cwd`
|
|
2471
|
+
* is a task's tree: an index write or a tool write under the main root outside the tree fails
|
|
2472
|
+
* `tree-leak`, and a command run outside the tree is noted `tree-cwd`. With `report`, the path
|
|
2473
|
+
* of the review package in `<root>/.freeharness/sdd/<change>/`, a tool write in that directory
|
|
2474
|
+
* other than to the report fails too, since the guard trusts the ledger and round copies there. */
|
|
3228
2475
|
export function scanTranscript(file, brief, cwd, mainRoot, report) {
|
|
3229
2476
|
const read = readBoundedNoFollow(file, SCAN_BOUND, false);
|
|
3230
2477
|
if (read === null) return { failures: [], notes: ["transcript-unavailable unreadable"] };
|
|
@@ -3237,25 +2484,13 @@ export function scanTranscript(file, brief, cwd, mainRoot, report) {
|
|
|
3237
2484
|
notes.push(`transcript-tree-unresolved ${scanDetail(String(err instanceof Error ? err.message : err).split("\n")[0])}`);
|
|
3238
2485
|
}
|
|
3239
2486
|
const suite = brief && brief.header && typeof brief.header.Suite === "string" ? brief.header.Suite.replace(/\s+/g, " ").trim() : "";
|
|
3240
|
-
const
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
capped: 0,
|
|
3248
|
-
budget: { fs: SCAN_FS_BUDGET, chars: SCAN_PATH_BUDGET },
|
|
3249
|
-
dirs: new Map(),
|
|
3250
|
-
resolved: new Map(),
|
|
3251
|
-
inside: new Map(),
|
|
3252
|
-
folded: new Set(),
|
|
3253
|
-
trees: new Map(),
|
|
3254
|
-
targets: new Map(),
|
|
3255
|
-
};
|
|
3256
|
-
// The main root seen as a second working tree, sharing the budgets and the lookup cache.
|
|
3257
|
-
if (mainRoot !== undefined) ctx.main = { tree: realDir(mainRoot, ctx), budget: ctx.budget, notes, dirs: ctx.dirs, inside: new Map(), folded: new Set(), trees: new Map() };
|
|
3258
|
-
if (report !== undefined) ctx.ws = changeWorkspace(report, ctx);
|
|
2487
|
+
const scope = brief && Array.isArray(brief.scope) ? brief.scope : [];
|
|
2488
|
+
const ctx = { tree: spellings(tree), treeDir: tree, suite, scope, failures: [], notes, seen: new Set(), counts: new Map(), capped: 0 };
|
|
2489
|
+
if (mainRoot !== undefined) {
|
|
2490
|
+
ctx.main = spellings(mainRoot);
|
|
2491
|
+
ctx.mainDir = mainRoot;
|
|
2492
|
+
}
|
|
2493
|
+
if (report !== undefined) ctx.ws = changeWorkspace(report);
|
|
3259
2494
|
const text = read.cut ? read.text.slice(0, read.text.lastIndexOf("\n") + 1) : read.text;
|
|
3260
2495
|
for (const raw of text.split("\n")) {
|
|
3261
2496
|
let rec;
|
|
@@ -3266,25 +2501,19 @@ export function scanTranscript(file, brief, cwd, mainRoot, report) {
|
|
|
3266
2501
|
}
|
|
3267
2502
|
const content = rec && rec.type === "assistant" && rec.message ? rec.message.content : null;
|
|
3268
2503
|
if (!Array.isArray(content)) continue;
|
|
3269
|
-
const dir = typeof rec.cwd === "string" && path.isAbsolute(rec.cwd) ? rec.cwd : tree;
|
|
3270
|
-
|
|
3271
|
-
// with the real tree; one past PATH_MAX no shell could stand in is unknown.
|
|
3272
|
-
const here = dir.length > SCAN_PATH_MAX ? null : realDir(dir, ctx);
|
|
2504
|
+
const dir = typeof rec.cwd === "string" && path.isAbsolute(rec.cwd) ? path.resolve(rec.cwd) : tree;
|
|
2505
|
+
const here = dir.length > SCAN_PATH_MAX ? null : dir;
|
|
3273
2506
|
for (const part of content) {
|
|
3274
2507
|
if (!part || part.type !== "tool_use" || !part.input) continue;
|
|
3275
|
-
if (part.name === "Bash" && typeof part.input.command === "string")
|
|
2508
|
+
if (part.name === "Bash" && typeof part.input.command === "string") scanBash(part.input.command, here, ctx);
|
|
3276
2509
|
const target = part.name === "NotebookEdit" ? (part.input.notebook_path ?? part.input.file_path) : part.input.file_path;
|
|
3277
2510
|
if ((ctx.main || ctx.ws) && ["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(part.name) && typeof target === "string" && target.length <= SCAN_PATH_MAX) {
|
|
3278
|
-
|
|
3279
|
-
// Only for a leak or a planted workspace file: an edit inside the tree is the tool's job.
|
|
3280
|
-
if (abs !== null && ctx.main) treeWrite(abs, ctx);
|
|
3281
|
-
if (abs !== null) plantedWrite(abs, ctx, "workspace-write", true);
|
|
2511
|
+
if (path.isAbsolute(target) || here !== null) toolWrite(path.resolve(here ?? path.sep, target), ctx);
|
|
3282
2512
|
}
|
|
3283
2513
|
}
|
|
3284
2514
|
}
|
|
3285
|
-
if (ctx.capped > 0) ctx.notes.push(`
|
|
3286
|
-
|
|
3287
|
-
return { failures: ctx.failures, notes: ctx.notes };
|
|
2515
|
+
if (ctx.capped > 0) ctx.notes.push(`scan-capped ${ctx.capped}`);
|
|
2516
|
+
return { failures: ctx.failures, notes: ctx.notes, truncated: read.cut };
|
|
3288
2517
|
}
|
|
3289
2518
|
|
|
3290
2519
|
/** Usage text: one line per subcommand with its arguments, in order. `commit` runs the repo's
|
|
@@ -3518,6 +2747,25 @@ function leavesRoot(root, dir) {
|
|
|
3518
2747
|
return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
3519
2748
|
}
|
|
3520
2749
|
|
|
2750
|
+
/** The main worktree of the checkout at `top`, by its common git directory; `{ error }` when no
|
|
2751
|
+
* main tree holds that directory. */
|
|
2752
|
+
function mainWorktree(top) {
|
|
2753
|
+
let gitDir;
|
|
2754
|
+
let commonDir;
|
|
2755
|
+
try {
|
|
2756
|
+
[gitDir, commonDir] = runGit(["rev-parse", "--git-dir", "--git-common-dir"], top)
|
|
2757
|
+
.split("\n")
|
|
2758
|
+
.slice(0, 2)
|
|
2759
|
+
.map((p) => fs.realpathSync(path.resolve(top, p.trim())));
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
return { error: `common git directory unresolved (${quoted(String(err instanceof Error ? err.message : err).split("\n")[0])})` };
|
|
2762
|
+
}
|
|
2763
|
+
if (gitDir === commonDir) return { root: top };
|
|
2764
|
+
const main = path.dirname(commonDir);
|
|
2765
|
+
const info = treeInfo(main, fs.realpathSync(top));
|
|
2766
|
+
return info.ok ? { root: main } : { error: `no main worktree holds ${quoted(commonDir)} (${quoted(info.reason)})` };
|
|
2767
|
+
}
|
|
2768
|
+
|
|
3521
2769
|
/** Runs `brief-init`: the skeleton of one task's brief, never written over an existing brief. */
|
|
3522
2770
|
function runBriefInit(rest) {
|
|
3523
2771
|
if (rest.length !== 2) return null;
|
|
@@ -3525,12 +2773,21 @@ function runBriefInit(rest) {
|
|
|
3525
2773
|
const fail = (e) => report([e], "brief-init");
|
|
3526
2774
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(change)) return fail(`task-shape change ${quoted(change)} is not kebab-case`);
|
|
3527
2775
|
if (!BRIEF_TASK_RE.test(task)) return fail(`task-shape task ${quoted(task)} is not a dotted task id or final`);
|
|
3528
|
-
|
|
2776
|
+
// The lead and hooks read the main tree's workspace; Base stays this checkout's HEAD.
|
|
2777
|
+
let here = process.cwd();
|
|
2778
|
+
let root = here;
|
|
2779
|
+
let top = null;
|
|
3529
2780
|
try {
|
|
3530
|
-
|
|
2781
|
+
top = git(["rev-parse", "--show-toplevel"], here);
|
|
3531
2782
|
} catch {
|
|
3532
2783
|
// outside a repository the current directory is the project root
|
|
3533
2784
|
}
|
|
2785
|
+
if (top !== null) {
|
|
2786
|
+
const main = mainWorktree(top);
|
|
2787
|
+
if (main.error) return fail(`tree-path ${quoted(top)}: ${main.error}`);
|
|
2788
|
+
here = top;
|
|
2789
|
+
root = main.root;
|
|
2790
|
+
}
|
|
3534
2791
|
const tasksFile = path.join(root, "openspec", "changes", change, "tasks.md");
|
|
3535
2792
|
let tasks;
|
|
3536
2793
|
try {
|
|
@@ -3545,7 +2802,7 @@ function runBriefInit(rest) {
|
|
|
3545
2802
|
if (!template) return fail(`template-missing ${quoted(templateFile)}`);
|
|
3546
2803
|
let base = null;
|
|
3547
2804
|
try {
|
|
3548
|
-
base = git(["rev-parse", "HEAD"],
|
|
2805
|
+
base = git(["rev-parse", "HEAD"], here);
|
|
3549
2806
|
} catch {
|
|
3550
2807
|
// no commit yet: Base stays a placeholder
|
|
3551
2808
|
}
|
|
@@ -3734,6 +2991,7 @@ function runHandoff(rest) {
|
|
|
3734
2991
|
for (const p of found.paths) {
|
|
3735
2992
|
const scan = scanTranscript(p, briefInfo, work, main ?? undefined, file);
|
|
3736
2993
|
notes.push(...scan.notes);
|
|
2994
|
+
if (scan.truncated) notes.push(`transcript-truncated ${quoted(agent)}`);
|
|
3737
2995
|
for (const f of scan.failures) failures.add(f);
|
|
3738
2996
|
}
|
|
3739
2997
|
// The scan quotes what it shows; this pass keeps every transcript-derived line safe whatever
|
|
@@ -4419,7 +3677,8 @@ function treeRoundArgs(rest) {
|
|
|
4419
3677
|
}
|
|
4420
3678
|
|
|
4421
3679
|
/** Lines of a regular file read without following a symlink; 0 for anything else, so an
|
|
4422
|
-
* untracked link or FIFO never blocks or leaves the tree
|
|
3680
|
+
* untracked link or FIFO never blocks or leaves the tree; 0 when the first 8 KiB hold a NUL
|
|
3681
|
+
* byte, as numstat counts a binary file. */
|
|
4423
3682
|
function untrackedLines(abs) {
|
|
4424
3683
|
let fd;
|
|
4425
3684
|
try {
|
|
@@ -4431,8 +3690,12 @@ function untrackedLines(abs) {
|
|
|
4431
3690
|
if (!fs.fstatSync(fd).isFile()) return 0;
|
|
4432
3691
|
const buf = Buffer.allocUnsafe(64 * 1024);
|
|
4433
3692
|
let n = 0;
|
|
3693
|
+
let seen = 0;
|
|
4434
3694
|
let last = 0x0a;
|
|
4435
3695
|
for (let got; (got = fs.readSync(fd, buf, 0, buf.length, null)) > 0; last = buf[got - 1]) {
|
|
3696
|
+
const sniff = Math.min(got, Math.max(0, BINARY_SNIFF_BYTES - seen));
|
|
3697
|
+
if (sniff > 0 && buf.subarray(0, sniff).includes(0)) return 0;
|
|
3698
|
+
seen += got;
|
|
4436
3699
|
for (let i = 0; i < got; i++) if (buf[i] === 0x0a) n++;
|
|
4437
3700
|
}
|
|
4438
3701
|
return last === 0x0a ? n : n + 1;
|