@titan-design/egress-scan 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,8 +23,26 @@ notices and errors to stderr.
23
23
 
24
24
  - **Ranges.** `pre-push` skips ref deletions, scans `remote..local` for an existing branch,
25
25
  and for a new branch scans only the commits no ref of that remote has. `range` with an
26
- all-zero base scans the head commit alone. Each commit is read with `git show -c`, so a
27
- merge commit's combined diff is scanned too.
26
+ all-zero base scans the head commit alone. A merge commit is diffed against each parent
27
+ in turn (`--diff-merges=separate`), because git's combined diff ignores `--text`; a path
28
+ both diffs name is reported once. The message is read with `--encoding=UTF-8`, so
29
+ `i18n.logOutputEncoding` cannot re-encode it past the rules.
30
+ - **Idents and ref names.** Each scanned commit's raw author and committer name and email are
31
+ scanned too, and a finding names the field (`commit <sha> author.name`, `author.email`,
32
+ `committer.name`, `committer.email`). `pre-push` also scans the local and remote ref names on
33
+ each stdin line, so a branch or tag named after a private term is refused as
34
+ `push line <n> local ref` or `push line <n> remote ref`. A ref deletion is not scanned, so a
35
+ leaked ref can still be deleted. Idents and ref names are never allowable.
36
+ - **Binary files are scanned as text.** Every `git show` and `git diff` passes `--text`, so a
37
+ file git calls binary (one NUL byte is enough) still yields its lines, and the report's
38
+ `binary files skipped` stays 0; if git ever prints a file as binary anyway, the scan exits 2.
39
+ There is no opt-out, since an opt-out would be a bypass.
40
+ Matching is on the bytes decoded as UTF-8: a term written in UTF-16 or another encoding is
41
+ not matched. **UTF-16 text is not scanned**: a UTF-16LE or UTF-16BE file passes even
42
+ with `--text`, because its NUL-interleaved bytes never decode to the term.
43
+ - **Size limit.** A commit whose patch text is over 128 MiB (`MAX_PATCH_BYTES`) exits 2 with
44
+ one line naming its short sha and the limit. `tree` has the same limit on the whole tree's
45
+ patch text and exits 2 with one line naming `tree` and the limit. GitHub itself refuses a file over 100 MiB.
28
46
  - **Arguments.** Shas on pre-push stdin must be full hex shas, a `range` base or head must be
29
47
  a hex sha or a ref name, and a remote name must not start with a dash or hold whitespace.
30
48
  A bad value exits 2 with its position, never its value. Every revision reaches git after
@@ -59,7 +77,7 @@ notices and errors to stderr.
59
77
  ```ts
60
78
  import { formatReport, parseAllow, parseCommit, parseTerms, scan } from "@titan-design/egress-scan";
61
79
 
62
- // Each commit's text from `git show -c -U0 --format=%B%x00 --no-color --no-ext-diff <sha>`.
80
+ // Each commit's text from `git show --text -U0 --encoding=UTF-8 --format=%B%x00 --no-color --no-ext-diff <sha>`.
63
81
  const sources = commits.map(({ sha, text }) => parseCommit(sha, text));
64
82
  const result = scan(sources, { terms: parseTerms(termFileText), allow: parseAllow(allowFileText) });
65
83
  for (const line of formatReport(result.findings, { ...result, termsLoaded: true })) console.log(line);
@@ -67,7 +85,10 @@ process.exitCode = result.findings.length > 0 ? 1 : 0;
67
85
  ```
68
86
 
69
87
  `scan` reads added lines, the paths of new, renamed and copied files, and commit messages.
70
- Removed lines are ignored and binary files are counted and skipped. Scan each commit, not
88
+ Removed lines are ignored. A patch made without `--text` shows a binary file as
89
+ `Binary files ... differ`, which `scan` counts and skips, so pass `--text` as the bin does. A merge's combined diff
90
+ ignores `--text`, so for a merge read the message with `--no-patch --format=%B` and the patch
91
+ with `--diff-merges=separate --format=`, and pass the patch to `parseDiff`. Scan each commit, not
71
92
  only the range endpoints: a leak added and then removed is still in the pushed history.
72
93
 
73
94
  ## Rules
package/dist/bin.js CHANGED
@@ -5,11 +5,10 @@ import {
5
5
  TermFileError,
6
6
  formatReport,
7
7
  parseAllow,
8
- parseCommit,
9
8
  parseDiff,
10
9
  parseTerms,
11
10
  scan
12
- } from "./chunk-FZDZ52KS.js";
11
+ } from "./chunk-YQBLQ32X.js";
13
12
 
14
13
  // src/bin.ts
15
14
  import * as fs3 from "fs";
@@ -110,7 +109,19 @@ var GitError = class extends Error {
110
109
  this.name = "GitError";
111
110
  }
112
111
  };
112
+ var MAX_PATCH_BYTES = 128 * 1024 * 1024;
113
+ var PatchTooLargeError = class extends Error {
114
+ constructor(sha, limit) {
115
+ super(`${sha === void 0 ? "tree" : `commit ${sha.slice(0, 7)}`}: patch text is over the scan limit of ${formatBytes(limit)}; refusing it`);
116
+ this.name = "PatchTooLargeError";
117
+ }
118
+ };
119
+ function formatBytes(bytes) {
120
+ const mib = 1024 * 1024;
121
+ return bytes % mib === 0 ? `${bytes / mib} MiB` : `${bytes} bytes`;
122
+ }
113
123
  var PATCH_FLAGS = [
124
+ "--text",
114
125
  "-U0",
115
126
  "--no-color",
116
127
  "--no-ext-diff",
@@ -119,6 +130,9 @@ var PATCH_FLAGS = [
119
130
  "--src-prefix=a/",
120
131
  "--dst-prefix=b/"
121
132
  ];
133
+ var MESSAGE_FLAGS = ["--no-patch", "--encoding=UTF-8", "--format=%an%x00%ae%x00%cn%x00%ce%x00%B"];
134
+ var IDENT_FIELDS = ["author.name", "author.email", "committer.name", "committer.email"];
135
+ var COMMIT_PATCH_FLAGS = ["--diff-merges=separate", "--format=", ...PATCH_FLAGS];
122
136
  var MAX_BUFFER = 1024 * 1024 * 1024;
123
137
  var END_OF_OPTIONS = "--end-of-options";
124
138
  var FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
@@ -136,12 +150,17 @@ function isRemoteName(value) {
136
150
  function requireRevision(value, what) {
137
151
  if (!isRevision(value)) throw new ConfigError(`${what} is not a sha or ref name`);
138
152
  }
139
- function git(cwd, args, input) {
140
- const result = spawnSync("git", args, { cwd, input, encoding: "utf-8", maxBuffer: MAX_BUFFER });
153
+ function runGit(cwd, args, maxBuffer, input) {
154
+ return spawnSync("git", args, { cwd, input, encoding: "utf-8", maxBuffer });
155
+ }
156
+ function stdoutOf(result, subcommand) {
141
157
  if (result.error) throw result.error;
142
- if (result.status !== 0) throw new GitError(args[0] ?? "", result.status);
158
+ if (result.status !== 0) throw new GitError(subcommand, result.status);
143
159
  return result.stdout;
144
160
  }
161
+ function git(cwd, args, input) {
162
+ return stdoutOf(runGit(cwd, args, MAX_BUFFER, input), args[0] ?? "");
163
+ }
145
164
  function lines(text) {
146
165
  return text.split("\n").filter((line) => line !== "");
147
166
  }
@@ -163,9 +182,18 @@ function parsePrePush(stdin) {
163
182
  throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);
164
183
  }
165
184
  }
166
- return { localSha: fields[1] ?? "", remoteSha: fields[3] ?? "" };
185
+ return { localRef: fields[0] ?? "", localSha: fields[1] ?? "", remoteRef: fields[2] ?? "", remoteSha: fields[3] ?? "" };
167
186
  });
168
187
  }
188
+ function refSource(pushLines) {
189
+ const idents = pushLines.flatMap(
190
+ (line, i) => isZeroSha(line.localSha) ? [] : [
191
+ { field: `push line ${i + 1} local ref`, text: line.localRef },
192
+ { field: `push line ${i + 1} remote ref`, text: line.remoteRef }
193
+ ]
194
+ );
195
+ return { files: [], binaryFiles: 0, idents };
196
+ }
169
197
  function hasCommit(cwd, sha) {
170
198
  return spawnSync("git", ["cat-file", "-e", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;
171
199
  }
@@ -183,13 +211,36 @@ function commitsForRange(cwd, base, head) {
183
211
  if (isZeroSha(base)) return lines(git(cwd, ["rev-list", "--no-walk", END_OF_OPTIONS, head]));
184
212
  return lines(git(cwd, ["rev-list", "--reverse", END_OF_OPTIONS, `${base}..${head}`]));
185
213
  }
186
- function readCommit(cwd, sha) {
214
+ function isOverBuffer(error) {
215
+ return error?.code === "ENOBUFS";
216
+ }
217
+ function requireAllText(source, what) {
218
+ if (source.binaryFiles > 0) throw new Error(`${what}: git printed a file as binary despite --text; refusing it`);
219
+ return source;
220
+ }
221
+ function showCommit(cwd, sha, flags, maxBytes) {
222
+ const result = runGit(cwd, ["show", ...flags, END_OF_OPTIONS, sha], maxBytes);
223
+ if (isOverBuffer(result.error)) throw new PatchTooLargeError(sha, maxBytes);
224
+ return stdoutOf(result, "show");
225
+ }
226
+ function splitHeader(text) {
227
+ const parts = text.split("\0");
228
+ const idents = IDENT_FIELDS.map((field, i) => ({ field, text: parts[i] ?? "" }));
229
+ const message = parts.slice(IDENT_FIELDS.length).join("\0").replace(/\n+$/, "").split("\n");
230
+ return { idents, message };
231
+ }
232
+ function readCommit(cwd, sha, maxPatchBytes = MAX_PATCH_BYTES) {
187
233
  requireRevision(sha, "commit");
188
- return parseCommit(sha, git(cwd, ["show", "-c", ...PATCH_FLAGS, "--format=%B%x00", END_OF_OPTIONS, sha]));
234
+ const { idents, message } = splitHeader(showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes));
235
+ const patch = parseDiff(showCommit(cwd, sha, COMMIT_PATCH_FLAGS, maxPatchBytes));
236
+ return requireAllText({ ...patch, sha, message, idents }, `commit ${sha.slice(0, 7)}`);
189
237
  }
190
- function readTree(cwd) {
238
+ function readTree(cwd, maxPatchBytes = MAX_PATCH_BYTES) {
191
239
  const emptyTree = git(cwd, ["hash-object", "-t", "tree", "--stdin"], "").trim();
192
- return parseDiff(git(cwd, ["diff", ...PATCH_FLAGS, "--no-renames", END_OF_OPTIONS, emptyTree, "HEAD"]));
240
+ const args = ["diff", ...PATCH_FLAGS, "--no-renames", END_OF_OPTIONS, emptyTree, "HEAD"];
241
+ const result = runGit(cwd, args, maxPatchBytes);
242
+ if (isOverBuffer(result.error)) throw new PatchTooLargeError(void 0, maxPatchBytes);
243
+ return requireAllText(parseDiff(stdoutOf(result, "diff")), "tree");
193
244
  }
194
245
 
195
246
  // src/install.ts
@@ -222,18 +273,17 @@ var USAGE = [
222
273
  " range <base> <head> scan every commit in base..head (CI)",
223
274
  " tree scan every tracked file at HEAD",
224
275
  " install-hook install the pre-push hook into git's hooks directory",
276
+ "files git calls binary are scanned as text; a commit or tree over 128 MiB of patch text exits 2",
225
277
  "exit: 0 clean, 1 findings, 2 usage or configuration error"
226
278
  ];
227
279
  var PREFIX = "titan-egress-scan: ";
228
- function readCommits(root, shas) {
229
- return [...new Set(shas)].map((sha) => readCommit(root, sha));
230
- }
231
- function prePushSources(root, remote, stdin) {
232
- const updates = parsePrePush(stdin);
233
- return readCommits(
234
- root,
235
- updates.flatMap((update) => commitsForUpdate(root, remote, update))
236
- );
280
+ function readCommits(root, shas, maxPatchBytes) {
281
+ return [...new Set(shas)].map((sha) => readCommit(root, sha, maxPatchBytes));
282
+ }
283
+ function prePushSources(root, remote, io) {
284
+ const pushLines = parsePrePush(io.readStdin());
285
+ const shas = pushLines.flatMap((line) => commitsForUpdate(root, remote, line));
286
+ return [refSource(pushLines), ...readCommits(root, shas, io.maxPatchBytes)];
237
287
  }
238
288
  function runScan(io, collect) {
239
289
  const root = repoRoot(io.cwd);
@@ -275,14 +325,17 @@ function dispatch(command, args, io) {
275
325
  case "pre-push":
276
326
  expectArgs(command, args, 1, 2);
277
327
  expectValid(command, args.slice(0, 1), isRemoteName, "a remote name");
278
- return runScan(io, (root) => prePushSources(root, args[0] ?? "", io.readStdin()));
328
+ return runScan(io, (root) => prePushSources(root, args[0] ?? "", io));
279
329
  case "range":
280
330
  expectArgs(command, args, 2, 2);
281
331
  expectValid(command, args, isRevision, "a sha or ref name");
282
- return runScan(io, (root) => readCommits(root, commitsForRange(root, args[0] ?? "", args[1] ?? "")));
332
+ return runScan(
333
+ io,
334
+ (root) => readCommits(root, commitsForRange(root, args[0] ?? "", args[1] ?? ""), io.maxPatchBytes)
335
+ );
283
336
  case "tree":
284
337
  expectArgs(command, args, 0, 0);
285
- return runScan(io, (root) => [readTree(root)]);
338
+ return runScan(io, (root) => [readTree(root, io.maxPatchBytes)]);
286
339
  case "install-hook":
287
340
  expectArgs(command, args, 0, 0);
288
341
  return runInstall(io);
package/dist/bin.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/bin.ts","../src/cli.ts","../src/config.ts","../src/git.ts","../src/install.ts"],"sourcesContent":["#!/usr/bin/env node\nimport * as fs from \"node:fs\";\nimport { runCli } from \"./cli.js\";\n\nprocess.exitCode = runCli(process.argv.slice(2), {\n cwd: process.cwd(),\n env: process.env,\n readStdin: () => fs.readFileSync(0, \"utf-8\"),\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n});\n","import { parseArgs } from \"node:util\";\nimport { ConfigError, loadAllow, loadTerms } from \"./config.js\";\nimport type { ScanSource } from \"./diff.js\";\nimport {\n commitsForRange,\n commitsForUpdate,\n isRemoteName,\n isRevision,\n parsePrePush,\n readCommit,\n readTree,\n repoRoot,\n} from \"./git.js\";\nimport { installHook, type InstallResult } from \"./install.js\";\nimport { formatReport } from \"./report.js\";\nimport { scan } from \"./scan.js\";\n\nexport interface CliIo {\n readonly cwd: string;\n readonly env: Readonly<Record<string, string | undefined>>;\n readStdin(): string;\n out(line: string): void;\n err(line: string): void;\n}\n\nconst USAGE = [\n \"usage: titan-egress-scan <command>\",\n \" pre-push <remote> scan the commits a push sends (reads git's pre-push stdin)\",\n \" range <base> <head> scan every commit in base..head (CI)\",\n \" tree scan every tracked file at HEAD\",\n \" install-hook install the pre-push hook into git's hooks directory\",\n \"exit: 0 clean, 1 findings, 2 usage or configuration error\",\n];\n\nconst PREFIX = \"titan-egress-scan: \";\n\nfunction readCommits(root: string, shas: readonly string[]): ScanSource[] {\n return [...new Set(shas)].map((sha) => readCommit(root, sha));\n}\n\nfunction prePushSources(root: string, remote: string, stdin: string): ScanSource[] {\n const updates = parsePrePush(stdin);\n return readCommits(\n root,\n updates.flatMap((update) => commitsForUpdate(root, remote, update)),\n );\n}\n\nfunction runScan(io: CliIo, collect: (root: string) => ScanSource[]): number {\n const root = repoRoot(io.cwd);\n const allow = loadAllow(root);\n const { terms, loaded, notices } = loadTerms(io.env);\n for (const notice of notices) io.err(PREFIX + notice);\n const result = scan(collect(root), { terms, allow });\n for (const line of formatReport(result.findings, { ...result, termsLoaded: loaded })) io.out(line);\n return result.findings.length > 0 ? 1 : 0;\n}\n\nconst INSTALL_MESSAGES: Record<InstallResult[\"outcome\"], string> = {\n installed: \"pre-push hook installed\",\n updated: \"pre-push hook updated\",\n unchanged: \"pre-push hook already installed\",\n \"skipped-in-ci\": \"CI detected; hook not installed\",\n \"foreign-hook\": \"an existing pre-push hook was not written by this tool; left it in place.\",\n};\n\nfunction runInstall(io: CliIo): number {\n const result = installHook(io.cwd, io.env);\n const where = result.hookPath === undefined ? \"\" : ` (${result.hookPath})`;\n if (result.outcome !== \"foreign-hook\") {\n io.out(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n return 0;\n }\n io.err(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n io.err(PREFIX + 'chain it: call `titan-egress-scan pre-push \"$1\"` from that hook with git\\'s stdin passed through');\n return 2;\n}\n\nfunction expectArgs(command: string, args: readonly string[], min: number, max: number): void {\n if (args.length < min || args.length > max) throw new ConfigError(`${command}: wrong number of arguments`);\n}\n\n/** Rejects a value by its position only, so the message never repeats what was passed. */\nfunction expectValid(command: string, args: readonly string[], valid: (value: string) => boolean, what: string): void {\n args.forEach((value, i) => {\n if (!valid(value)) throw new ConfigError(`${command}: argument ${i + 1} is not ${what}`);\n });\n}\n\nfunction dispatch(command: string | undefined, args: readonly string[], io: CliIo): number {\n switch (command) {\n case \"pre-push\":\n // git passes the remote name and its URL; only the name is used.\n expectArgs(command, args, 1, 2);\n expectValid(command, args.slice(0, 1), isRemoteName, \"a remote name\");\n return runScan(io, (root) => prePushSources(root, args[0] ?? \"\", io.readStdin()));\n case \"range\":\n expectArgs(command, args, 2, 2);\n expectValid(command, args, isRevision, \"a sha or ref name\");\n return runScan(io, (root) => readCommits(root, commitsForRange(root, args[0] ?? \"\", args[1] ?? \"\")));\n case \"tree\":\n expectArgs(command, args, 0, 0);\n return runScan(io, (root) => [readTree(root)]);\n case \"install-hook\":\n expectArgs(command, args, 0, 0);\n return runInstall(io);\n default:\n throw new ConfigError(command === undefined ? \"missing command\" : \"unknown command\");\n }\n}\n\n/** Help counts only before the command; after it, a help flag is a usage error, never a skipped scan. */\nfunction parseCommandLine(argv: readonly string[]): { positionals: string[]; help: boolean } {\n const { positionals, tokens } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n tokens: true,\n options: { help: { type: \"boolean\", short: \"h\" } },\n });\n const command = tokens.find((token) => token.kind === \"positional\");\n const helps = tokens.filter((token) => token.kind === \"option\" && token.name === \"help\");\n if (helps.some((token) => command !== undefined && token.index > command.index)) {\n throw new ConfigError(\"a help flag after the command is not allowed\");\n }\n return { positionals, help: helps.length > 0 };\n}\n\n/** Runs one command. Errors print as one line to stderr and exit 2; nothing echoes scanned text. */\nexport function runCli(argv: readonly string[], io: CliIo): number {\n try {\n const { positionals, help } = parseCommandLine(argv);\n if (help) {\n for (const line of USAGE) io.out(line);\n return 0;\n }\n return dispatch(positionals[0], positionals.slice(1), io);\n } catch (error) {\n io.err(PREFIX + (error instanceof Error ? error.message : \"unexpected error\"));\n if (error instanceof ConfigError) io.err(USAGE[0] ?? \"\");\n return 2;\n }\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { AllowFileError, EMPTY_ALLOW, parseAllow, type AllowList } from \"./allow.js\";\nimport type { TermRule } from \"./rules.js\";\nimport { parseTerms, TermFileError } from \"./terms.js\";\n\n/** A usage or configuration problem: exit 2. Messages never carry a term or scanned text. */\nexport class ConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ConfigError\";\n }\n}\n\nexport interface LoadedTerms {\n readonly terms: readonly TermRule[];\n readonly loaded: boolean;\n readonly notices: readonly string[];\n}\n\ntype Env = Readonly<Record<string, string | undefined>>;\n\nconst NOT_LOADED: LoadedTerms = { terms: [], loaded: false, notices: [] };\n\nexport function isCi(env: Env): boolean {\n const value = env.CI?.toLowerCase();\n return value !== undefined && value !== \"\" && value !== \"false\" && value !== \"0\";\n}\n\nexport function termFilePath(env: Env): string | undefined {\n if (env.TITAN_EGRESS_TERMS) return env.TITAN_EGRESS_TERMS;\n const configHome = env.XDG_CONFIG_HOME || (env.HOME && path.join(env.HOME, \".config\"));\n return configHome ? path.join(configHome, \"titan-egress\", \"private-terms\") : undefined;\n}\n\nfunction readIfPresent(file: string, what: string): string | undefined {\n try {\n return fs.readFileSync(file, \"utf-8\");\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n throw new ConfigError(`${what} could not be read`);\n }\n}\n\nconst ON_VALUES = new Set([\"1\", \"true\", \"yes\", \"on\"]);\nconst OFF_VALUES = new Set([\"\", \"0\", \"false\", \"no\", \"off\"]);\n\n/** An unrecognised value fails the scan: a typo must never read as off. The value is not echoed. */\nfunction requiresTerms(env: Env): boolean {\n const value = (env.TITAN_EGRESS_REQUIRE_TERMS ?? \"\").trim().toLowerCase();\n if (ON_VALUES.has(value)) return true;\n if (OFF_VALUES.has(value)) return false;\n throw new ConfigError(\"TITAN_EGRESS_REQUIRE_TERMS is not one of 1, true, yes, on, 0, false, no, off\");\n}\n\nfunction readTermFile(file: string, required: boolean): string | undefined {\n try {\n return readIfPresent(file, \"private term list\");\n } catch (error) {\n if (required && error instanceof ConfigError) {\n throw new ConfigError(\"private term list could not be read and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n throw error;\n }\n}\n\nfunction missingTerms(required: boolean): LoadedTerms {\n if (required) {\n throw new ConfigError(\"private term list not found and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { ...NOT_LOADED, notices: [\"private term list not found; generic rules only\"] };\n}\n\nfunction compileTerms(text: string): readonly TermRule[] {\n try {\n return parseTerms(text);\n } catch (error) {\n if (error instanceof TermFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n\n/**\n * Loads the private term list. In CI it is never looked up and an absent file is a notice, unless\n * TITAN_EGRESS_REQUIRE_TERMS=1, which always loads and fails on a missing, unreadable or empty list.\n */\nexport function loadTerms(env: Env): LoadedTerms {\n const required = requiresTerms(env);\n if (isCi(env) && !required) return NOT_LOADED;\n const file = termFilePath(env);\n const text = file === undefined ? undefined : readTermFile(file, required);\n if (file === undefined || text === undefined) return missingTerms(required);\n const openToOthers = process.platform !== \"win32\" && (fs.statSync(file).mode & 0o077) !== 0;\n const notices = openToOthers ? [\"private term list is readable by other users; chmod 600 it\"] : [];\n const terms = compileTerms(text);\n if (terms.length === 0 && required) {\n throw new ConfigError(\"private term list holds no terms and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { terms, loaded: true, notices };\n}\n\n/** Loads `.egress-allow` from the repo root. A malformed file fails the scan, never reads as empty. */\nexport function loadAllow(root: string): AllowList {\n const text = readIfPresent(path.join(root, \".egress-allow\"), \".egress-allow\");\n if (text === undefined) return EMPTY_ALLOW;\n try {\n return parseAllow(text);\n } catch (error) {\n if (error instanceof AllowFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n","import { spawnSync } from \"node:child_process\";\nimport * as path from \"node:path\";\nimport { ConfigError } from \"./config.js\";\nimport { parseCommit, parseDiff, type ScanSource } from \"./diff.js\";\n\n/** A git command that failed. The message names the subcommand, never git's output. */\nexport class GitError extends Error {\n constructor(subcommand: string, status: number | null) {\n super(`git ${subcommand} failed (exit ${status ?? \"signal\"}); run it by hand to see git's error`);\n this.name = \"GitError\";\n }\n}\n\nexport interface PushUpdate {\n readonly localSha: string;\n readonly remoteSha: string;\n}\n\n// Pinned so user config (prefixes, textconv, relative, external diff) cannot reshape the patch text.\nconst PATCH_FLAGS = [\n \"-U0\",\n \"--no-color\",\n \"--no-ext-diff\",\n \"--no-textconv\",\n \"--no-relative\",\n \"--src-prefix=a/\",\n \"--dst-prefix=b/\",\n];\nconst MAX_BUFFER = 1024 * 1024 * 1024;\n// Every revision argument follows this, so a value that starts with a dash cannot become an option.\nconst END_OF_OPTIONS = \"--end-of-options\";\n\nconst FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;\nconst REVISION = /^(?:[0-9a-f]{7,64}|[^-\\s]\\S*)$/;\nconst REMOTE_NAME = /^[^-\\s]\\S*$/;\n\nexport function isFullSha(value: string): boolean {\n return FULL_SHA.test(value);\n}\n\n/** A hex sha of 7 to 64 characters, or a ref name that cannot be read as an option. */\nexport function isRevision(value: string): boolean {\n return REVISION.test(value);\n}\n\nexport function isRemoteName(value: string): boolean {\n return REMOTE_NAME.test(value);\n}\n\nfunction requireRevision(value: string, what: string): void {\n if (!isRevision(value)) throw new ConfigError(`${what} is not a sha or ref name`);\n}\n\nexport function git(cwd: string, args: readonly string[], input?: string): string {\n const result = spawnSync(\"git\", args, { cwd, input, encoding: \"utf-8\", maxBuffer: MAX_BUFFER });\n if (result.error) throw result.error;\n if (result.status !== 0) throw new GitError(args[0] ?? \"\", result.status);\n return result.stdout;\n}\n\nfunction lines(text: string): string[] {\n return text.split(\"\\n\").filter((line) => line !== \"\");\n}\n\nexport function isZeroSha(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\nexport function repoRoot(cwd: string): string {\n return git(cwd, [\"rev-parse\", \"--show-toplevel\"]).trim();\n}\n\n/** Resolves the hooks directory as git does: `core.hooksPath` if set, else the common git dir's hooks. */\nexport function hooksDir(cwd: string): string {\n return path.resolve(cwd, git(cwd, [\"rev-parse\", \"--git-path\", \"hooks\"]).trim());\n}\n\n/** Parses git's pre-push stdin: `<local-ref> <local-sha> <remote-ref> <remote-sha>` per line. */\nexport function parsePrePush(stdin: string): PushUpdate[] {\n return lines(stdin).map((line, i) => {\n const fields = line.trim().split(/\\s+/);\n if (fields.length !== 4) throw new ConfigError(`pre-push stdin line ${i + 1}: expected four fields`);\n for (const field of [1, 3]) {\n if (!isFullSha(fields[field] ?? \"\")) {\n throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);\n }\n }\n return { localSha: fields[1] ?? \"\", remoteSha: fields[3] ?? \"\" };\n });\n}\n\nfunction hasCommit(cwd: string, sha: string): boolean {\n return spawnSync(\"git\", [\"cat-file\", \"-e\", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;\n}\n\n/** The commits one pushed ref update sends that the remote lacks, oldest first. */\nexport function commitsForUpdate(cwd: string, remote: string, update: PushUpdate): string[] {\n if (isZeroSha(update.localSha)) return [];\n if (!isFullSha(update.localSha) || !isFullSha(update.remoteSha)) throw new ConfigError(\"push update is not a full sha\");\n if (!isRemoteName(remote)) throw new ConfigError(\"remote is not a remote name\");\n const known = !isZeroSha(update.remoteSha) && hasCommit(cwd, update.remoteSha);\n // The second `--not` flips back, so the local sha after it counts as included.\n const range = known\n ? [END_OF_OPTIONS, `${update.remoteSha}..${update.localSha}`]\n : [\"--not\", `--remotes=${remote}`, \"--not\", END_OF_OPTIONS, update.localSha];\n return lines(git(cwd, [\"rev-list\", \"--reverse\", ...range]));\n}\n\n/** The commits in `base..head`, oldest first; an all-zero base means `head` alone. */\nexport function commitsForRange(cwd: string, base: string, head: string): string[] {\n requireRevision(base, \"range base\");\n requireRevision(head, \"range head\");\n if (isZeroSha(base)) return lines(git(cwd, [\"rev-list\", \"--no-walk\", END_OF_OPTIONS, head]));\n return lines(git(cwd, [\"rev-list\", \"--reverse\", END_OF_OPTIONS, `${base}..${head}`]));\n}\n\n/** One commit's message and patch; `-c` makes a merge show the lines it brings in from any parent. */\nexport function readCommit(cwd: string, sha: string): ScanSource {\n requireRevision(sha, \"commit\");\n return parseCommit(sha, git(cwd, [\"show\", \"-c\", ...PATCH_FLAGS, \"--format=%B%x00\", END_OF_OPTIONS, sha]));\n}\n\n/** Every tracked file at HEAD, as one diff from the empty tree. */\nexport function readTree(cwd: string): ScanSource {\n const emptyTree = git(cwd, [\"hash-object\", \"-t\", \"tree\", \"--stdin\"], \"\").trim();\n return parseDiff(git(cwd, [\"diff\", ...PATCH_FLAGS, \"--no-renames\", END_OF_OPTIONS, emptyTree, \"HEAD\"]));\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { isCi } from \"./config.js\";\nimport { hooksDir } from \"./git.js\";\n\nexport const HOOK_MARKER = \"# titan-egress-scan managed pre-push hook\";\n\nexport type InstallOutcome = \"installed\" | \"updated\" | \"unchanged\" | \"skipped-in-ci\" | \"foreign-hook\";\n\nexport interface InstallResult {\n readonly outcome: InstallOutcome;\n readonly hookPath?: string;\n}\n\n/** The shipped hook body, one directory above both `src/` and `dist/`. */\nexport function hookBody(): string {\n return fs.readFileSync(new URL(\"../hooks/pre-push\", import.meta.url), \"utf-8\");\n}\n\nfunction existing(hookPath: string): string | undefined {\n return fs.existsSync(hookPath) ? fs.readFileSync(hookPath, \"utf-8\") : undefined;\n}\n\n/**\n * Writes the pre-push hook into git's hooks directory, which honours `core.hooksPath` and is\n * shared by every linked worktree. It never sets `core.hooksPath` and never replaces a hook it\n * did not write.\n */\nexport function installHook(cwd: string, env: Readonly<Record<string, string | undefined>>): InstallResult {\n if (isCi(env)) return { outcome: \"skipped-in-ci\" };\n const hookPath = path.join(hooksDir(cwd), \"pre-push\");\n const body = hookBody();\n const current = existing(hookPath);\n if (current !== undefined && !current.includes(HOOK_MARKER)) return { outcome: \"foreign-hook\", hookPath };\n if (current === body) return { outcome: \"unchanged\", hookPath };\n fs.mkdirSync(path.dirname(hookPath), { recursive: true });\n fs.writeFileSync(hookPath, body, { mode: 0o755 });\n fs.chmodSync(hookPath, 0o755);\n return { outcome: current === undefined ? \"installed\" : \"updated\", hookPath };\n}\n"],"mappings":"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","names":["fs","path","fs","path"]}
1
+ {"version":3,"sources":["../src/bin.ts","../src/cli.ts","../src/config.ts","../src/git.ts","../src/install.ts"],"sourcesContent":["#!/usr/bin/env node\nimport * as fs from \"node:fs\";\nimport { runCli } from \"./cli.js\";\n\nprocess.exitCode = runCli(process.argv.slice(2), {\n cwd: process.cwd(),\n env: process.env,\n readStdin: () => fs.readFileSync(0, \"utf-8\"),\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n});\n","import { parseArgs } from \"node:util\";\nimport { ConfigError, loadAllow, loadTerms } from \"./config.js\";\nimport type { ScanSource } from \"./diff.js\";\nimport {\n commitsForRange,\n commitsForUpdate,\n isRemoteName,\n isRevision,\n parsePrePush,\n readCommit,\n readTree,\n refSource,\n repoRoot,\n} from \"./git.js\";\nimport { installHook, type InstallResult } from \"./install.js\";\nimport { formatReport } from \"./report.js\";\nimport { scan } from \"./scan.js\";\n\nexport interface CliIo {\n readonly cwd: string;\n readonly env: Readonly<Record<string, string | undefined>>;\n readStdin(): string;\n out(line: string): void;\n err(line: string): void;\n /** Overrides `MAX_PATCH_BYTES` for tests; the bin never sets it. */\n readonly maxPatchBytes?: number;\n}\n\nconst USAGE = [\n \"usage: titan-egress-scan <command>\",\n \" pre-push <remote> scan the commits a push sends (reads git's pre-push stdin)\",\n \" range <base> <head> scan every commit in base..head (CI)\",\n \" tree scan every tracked file at HEAD\",\n \" install-hook install the pre-push hook into git's hooks directory\",\n \"files git calls binary are scanned as text; a commit or tree over 128 MiB of patch text exits 2\",\n \"exit: 0 clean, 1 findings, 2 usage or configuration error\",\n];\n\nconst PREFIX = \"titan-egress-scan: \";\n\nfunction readCommits(root: string, shas: readonly string[], maxPatchBytes?: number): ScanSource[] {\n return [...new Set(shas)].map((sha) => readCommit(root, sha, maxPatchBytes));\n}\n\nfunction prePushSources(root: string, remote: string, io: CliIo): ScanSource[] {\n const pushLines = parsePrePush(io.readStdin());\n const shas = pushLines.flatMap((line) => commitsForUpdate(root, remote, line));\n return [refSource(pushLines), ...readCommits(root, shas, io.maxPatchBytes)];\n}\n\nfunction runScan(io: CliIo, collect: (root: string) => ScanSource[]): number {\n const root = repoRoot(io.cwd);\n const allow = loadAllow(root);\n const { terms, loaded, notices } = loadTerms(io.env);\n for (const notice of notices) io.err(PREFIX + notice);\n const result = scan(collect(root), { terms, allow });\n for (const line of formatReport(result.findings, { ...result, termsLoaded: loaded })) io.out(line);\n return result.findings.length > 0 ? 1 : 0;\n}\n\nconst INSTALL_MESSAGES: Record<InstallResult[\"outcome\"], string> = {\n installed: \"pre-push hook installed\",\n updated: \"pre-push hook updated\",\n unchanged: \"pre-push hook already installed\",\n \"skipped-in-ci\": \"CI detected; hook not installed\",\n \"foreign-hook\": \"an existing pre-push hook was not written by this tool; left it in place.\",\n};\n\nfunction runInstall(io: CliIo): number {\n const result = installHook(io.cwd, io.env);\n const where = result.hookPath === undefined ? \"\" : ` (${result.hookPath})`;\n if (result.outcome !== \"foreign-hook\") {\n io.out(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n return 0;\n }\n io.err(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n io.err(PREFIX + 'chain it: call `titan-egress-scan pre-push \"$1\"` from that hook with git\\'s stdin passed through');\n return 2;\n}\n\nfunction expectArgs(command: string, args: readonly string[], min: number, max: number): void {\n if (args.length < min || args.length > max) throw new ConfigError(`${command}: wrong number of arguments`);\n}\n\n/** Rejects a value by its position only, so the message never repeats what was passed. */\nfunction expectValid(command: string, args: readonly string[], valid: (value: string) => boolean, what: string): void {\n args.forEach((value, i) => {\n if (!valid(value)) throw new ConfigError(`${command}: argument ${i + 1} is not ${what}`);\n });\n}\n\nfunction dispatch(command: string | undefined, args: readonly string[], io: CliIo): number {\n switch (command) {\n case \"pre-push\":\n // git passes the remote name and its URL; only the name is used.\n expectArgs(command, args, 1, 2);\n expectValid(command, args.slice(0, 1), isRemoteName, \"a remote name\");\n return runScan(io, (root) => prePushSources(root, args[0] ?? \"\", io));\n case \"range\":\n expectArgs(command, args, 2, 2);\n expectValid(command, args, isRevision, \"a sha or ref name\");\n return runScan(io, (root) =>\n readCommits(root, commitsForRange(root, args[0] ?? \"\", args[1] ?? \"\"), io.maxPatchBytes),\n );\n case \"tree\":\n expectArgs(command, args, 0, 0);\n return runScan(io, (root) => [readTree(root, io.maxPatchBytes)]);\n case \"install-hook\":\n expectArgs(command, args, 0, 0);\n return runInstall(io);\n default:\n throw new ConfigError(command === undefined ? \"missing command\" : \"unknown command\");\n }\n}\n\n/** Help counts only before the command; after it, a help flag is a usage error, never a skipped scan. */\nfunction parseCommandLine(argv: readonly string[]): { positionals: string[]; help: boolean } {\n const { positionals, tokens } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n tokens: true,\n options: { help: { type: \"boolean\", short: \"h\" } },\n });\n const command = tokens.find((token) => token.kind === \"positional\");\n const helps = tokens.filter((token) => token.kind === \"option\" && token.name === \"help\");\n if (helps.some((token) => command !== undefined && token.index > command.index)) {\n throw new ConfigError(\"a help flag after the command is not allowed\");\n }\n return { positionals, help: helps.length > 0 };\n}\n\n/** Runs one command. Errors print as one line to stderr and exit 2; nothing echoes scanned text. */\nexport function runCli(argv: readonly string[], io: CliIo): number {\n try {\n const { positionals, help } = parseCommandLine(argv);\n if (help) {\n for (const line of USAGE) io.out(line);\n return 0;\n }\n return dispatch(positionals[0], positionals.slice(1), io);\n } catch (error) {\n io.err(PREFIX + (error instanceof Error ? error.message : \"unexpected error\"));\n if (error instanceof ConfigError) io.err(USAGE[0] ?? \"\");\n return 2;\n }\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { AllowFileError, EMPTY_ALLOW, parseAllow, type AllowList } from \"./allow.js\";\nimport type { TermRule } from \"./rules.js\";\nimport { parseTerms, TermFileError } from \"./terms.js\";\n\n/** A usage or configuration problem: exit 2. Messages never carry a term or scanned text. */\nexport class ConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ConfigError\";\n }\n}\n\nexport interface LoadedTerms {\n readonly terms: readonly TermRule[];\n readonly loaded: boolean;\n readonly notices: readonly string[];\n}\n\ntype Env = Readonly<Record<string, string | undefined>>;\n\nconst NOT_LOADED: LoadedTerms = { terms: [], loaded: false, notices: [] };\n\nexport function isCi(env: Env): boolean {\n const value = env.CI?.toLowerCase();\n return value !== undefined && value !== \"\" && value !== \"false\" && value !== \"0\";\n}\n\nexport function termFilePath(env: Env): string | undefined {\n if (env.TITAN_EGRESS_TERMS) return env.TITAN_EGRESS_TERMS;\n const configHome = env.XDG_CONFIG_HOME || (env.HOME && path.join(env.HOME, \".config\"));\n return configHome ? path.join(configHome, \"titan-egress\", \"private-terms\") : undefined;\n}\n\nfunction readIfPresent(file: string, what: string): string | undefined {\n try {\n return fs.readFileSync(file, \"utf-8\");\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n throw new ConfigError(`${what} could not be read`);\n }\n}\n\nconst ON_VALUES = new Set([\"1\", \"true\", \"yes\", \"on\"]);\nconst OFF_VALUES = new Set([\"\", \"0\", \"false\", \"no\", \"off\"]);\n\n/** An unrecognised value fails the scan: a typo must never read as off. The value is not echoed. */\nfunction requiresTerms(env: Env): boolean {\n const value = (env.TITAN_EGRESS_REQUIRE_TERMS ?? \"\").trim().toLowerCase();\n if (ON_VALUES.has(value)) return true;\n if (OFF_VALUES.has(value)) return false;\n throw new ConfigError(\"TITAN_EGRESS_REQUIRE_TERMS is not one of 1, true, yes, on, 0, false, no, off\");\n}\n\nfunction readTermFile(file: string, required: boolean): string | undefined {\n try {\n return readIfPresent(file, \"private term list\");\n } catch (error) {\n if (required && error instanceof ConfigError) {\n throw new ConfigError(\"private term list could not be read and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n throw error;\n }\n}\n\nfunction missingTerms(required: boolean): LoadedTerms {\n if (required) {\n throw new ConfigError(\"private term list not found and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { ...NOT_LOADED, notices: [\"private term list not found; generic rules only\"] };\n}\n\nfunction compileTerms(text: string): readonly TermRule[] {\n try {\n return parseTerms(text);\n } catch (error) {\n if (error instanceof TermFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n\n/**\n * Loads the private term list. In CI it is never looked up and an absent file is a notice, unless\n * TITAN_EGRESS_REQUIRE_TERMS=1, which always loads and fails on a missing, unreadable or empty list.\n */\nexport function loadTerms(env: Env): LoadedTerms {\n const required = requiresTerms(env);\n if (isCi(env) && !required) return NOT_LOADED;\n const file = termFilePath(env);\n const text = file === undefined ? undefined : readTermFile(file, required);\n if (file === undefined || text === undefined) return missingTerms(required);\n const openToOthers = process.platform !== \"win32\" && (fs.statSync(file).mode & 0o077) !== 0;\n const notices = openToOthers ? [\"private term list is readable by other users; chmod 600 it\"] : [];\n const terms = compileTerms(text);\n if (terms.length === 0 && required) {\n throw new ConfigError(\"private term list holds no terms and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { terms, loaded: true, notices };\n}\n\n/** Loads `.egress-allow` from the repo root. A malformed file fails the scan, never reads as empty. */\nexport function loadAllow(root: string): AllowList {\n const text = readIfPresent(path.join(root, \".egress-allow\"), \".egress-allow\");\n if (text === undefined) return EMPTY_ALLOW;\n try {\n return parseAllow(text);\n } catch (error) {\n if (error instanceof AllowFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n","import { spawnSync, type SpawnSyncReturns } from \"node:child_process\";\nimport * as path from \"node:path\";\nimport { ConfigError } from \"./config.js\";\nimport { parseDiff, type IdentField, type ScanSource } from \"./diff.js\";\n\n/** A git command that failed. The message names the subcommand, never git's output. */\nexport class GitError extends Error {\n constructor(subcommand: string, status: number | null) {\n super(`git ${subcommand} failed (exit ${status ?? \"signal\"}); run it by hand to see git's error`);\n this.name = \"GitError\";\n }\n}\n\nexport interface PushUpdate {\n readonly localSha: string;\n readonly remoteSha: string;\n}\n\n/** One pre-push stdin line: the shas plus the local and remote ref names, which leave the machine too. */\nexport interface PushLine extends PushUpdate {\n readonly localRef: string;\n readonly remoteRef: string;\n}\n\n/** A commit or tree whose patch text passes this many bytes is refused: over it, V8 cannot hold the text as one string. */\nexport const MAX_PATCH_BYTES = 128 * 1024 * 1024;\n\n/** A commit or tree too large to scan. The message names the source and the limit, never the content. */\nexport class PatchTooLargeError extends Error {\n constructor(sha: string | undefined, limit: number) {\n super(`${sha === undefined ? \"tree\" : `commit ${sha.slice(0, 7)}`}: patch text is over the scan limit of ${formatBytes(limit)}; refusing it`);\n this.name = \"PatchTooLargeError\";\n }\n}\n\nfunction formatBytes(bytes: number): string {\n const mib = 1024 * 1024;\n return bytes % mib === 0 ? `${bytes / mib} MiB` : `${bytes} bytes`;\n}\n\n// Pinned so user config (prefixes, textconv, relative, external diff) cannot reshape the patch text.\n// `--text` diffs a file git calls binary, so one NUL byte cannot hide the lines around it.\nconst PATCH_FLAGS = [\n \"--text\",\n \"-U0\",\n \"--no-color\",\n \"--no-ext-diff\",\n \"--no-textconv\",\n \"--no-relative\",\n \"--src-prefix=a/\",\n \"--dst-prefix=b/\",\n];\n// A message re-encoded by `i18n.logOutputEncoding` (UTF-16, say) would slip past every rule.\n// The raw `%an` forms, not the mailmapped `%aN`, since the raw ident is what the push sends.\nconst MESSAGE_FLAGS = [\"--no-patch\", \"--encoding=UTF-8\", \"--format=%an%x00%ae%x00%cn%x00%ce%x00%B\"];\nconst IDENT_FIELDS = [\"author.name\", \"author.email\", \"committer.name\", \"committer.email\"];\n// A combined diff ignores `--text`, so a merge is diffed against each parent in turn instead.\nconst COMMIT_PATCH_FLAGS = [\"--diff-merges=separate\", \"--format=\", ...PATCH_FLAGS];\nconst MAX_BUFFER = 1024 * 1024 * 1024;\n// Every revision argument follows this, so a value that starts with a dash cannot become an option.\nconst END_OF_OPTIONS = \"--end-of-options\";\n\nconst FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;\nconst REVISION = /^(?:[0-9a-f]{7,64}|[^-\\s]\\S*)$/;\nconst REMOTE_NAME = /^[^-\\s]\\S*$/;\n\nexport function isFullSha(value: string): boolean {\n return FULL_SHA.test(value);\n}\n\n/** A hex sha of 7 to 64 characters, or a ref name that cannot be read as an option. */\nexport function isRevision(value: string): boolean {\n return REVISION.test(value);\n}\n\nexport function isRemoteName(value: string): boolean {\n return REMOTE_NAME.test(value);\n}\n\nfunction requireRevision(value: string, what: string): void {\n if (!isRevision(value)) throw new ConfigError(`${what} is not a sha or ref name`);\n}\n\nfunction runGit(cwd: string, args: readonly string[], maxBuffer: number, input?: string): SpawnSyncReturns<string> {\n return spawnSync(\"git\", args, { cwd, input, encoding: \"utf-8\", maxBuffer });\n}\n\nfunction stdoutOf(result: SpawnSyncReturns<string>, subcommand: string): string {\n if (result.error) throw result.error;\n if (result.status !== 0) throw new GitError(subcommand, result.status);\n return result.stdout;\n}\n\nexport function git(cwd: string, args: readonly string[], input?: string): string {\n return stdoutOf(runGit(cwd, args, MAX_BUFFER, input), args[0] ?? \"\");\n}\n\nfunction lines(text: string): string[] {\n return text.split(\"\\n\").filter((line) => line !== \"\");\n}\n\nexport function isZeroSha(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\nexport function repoRoot(cwd: string): string {\n return git(cwd, [\"rev-parse\", \"--show-toplevel\"]).trim();\n}\n\n/** Resolves the hooks directory as git does: `core.hooksPath` if set, else the common git dir's hooks. */\nexport function hooksDir(cwd: string): string {\n return path.resolve(cwd, git(cwd, [\"rev-parse\", \"--git-path\", \"hooks\"]).trim());\n}\n\n/** Parses git's pre-push stdin: `<local-ref> <local-sha> <remote-ref> <remote-sha>` per line. */\nexport function parsePrePush(stdin: string): PushLine[] {\n return lines(stdin).map((line, i) => {\n const fields = line.trim().split(/\\s+/);\n if (fields.length !== 4) throw new ConfigError(`pre-push stdin line ${i + 1}: expected four fields`);\n for (const field of [1, 3]) {\n if (!isFullSha(fields[field] ?? \"\")) {\n throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);\n }\n }\n return { localRef: fields[0] ?? \"\", localSha: fields[1] ?? \"\", remoteRef: fields[2] ?? \"\", remoteSha: fields[3] ?? \"\" };\n });\n}\n\n/** The ref names a push sends, as idents named `push line <n> local ref` and `remote ref`; deletions are skipped. */\nexport function refSource(pushLines: readonly PushLine[]): ScanSource {\n const idents = pushLines.flatMap((line, i): IdentField[] =>\n isZeroSha(line.localSha)\n ? []\n : [\n { field: `push line ${i + 1} local ref`, text: line.localRef },\n { field: `push line ${i + 1} remote ref`, text: line.remoteRef },\n ],\n );\n return { files: [], binaryFiles: 0, idents };\n}\n\nfunction hasCommit(cwd: string, sha: string): boolean {\n return spawnSync(\"git\", [\"cat-file\", \"-e\", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;\n}\n\n/** The commits one pushed ref update sends that the remote lacks, oldest first. */\nexport function commitsForUpdate(cwd: string, remote: string, update: PushUpdate): string[] {\n if (isZeroSha(update.localSha)) return [];\n if (!isFullSha(update.localSha) || !isFullSha(update.remoteSha)) throw new ConfigError(\"push update is not a full sha\");\n if (!isRemoteName(remote)) throw new ConfigError(\"remote is not a remote name\");\n const known = !isZeroSha(update.remoteSha) && hasCommit(cwd, update.remoteSha);\n // The second `--not` flips back, so the local sha after it counts as included.\n const range = known\n ? [END_OF_OPTIONS, `${update.remoteSha}..${update.localSha}`]\n : [\"--not\", `--remotes=${remote}`, \"--not\", END_OF_OPTIONS, update.localSha];\n return lines(git(cwd, [\"rev-list\", \"--reverse\", ...range]));\n}\n\n/** The commits in `base..head`, oldest first; an all-zero base means `head` alone. */\nexport function commitsForRange(cwd: string, base: string, head: string): string[] {\n requireRevision(base, \"range base\");\n requireRevision(head, \"range head\");\n if (isZeroSha(base)) return lines(git(cwd, [\"rev-list\", \"--no-walk\", END_OF_OPTIONS, head]));\n return lines(git(cwd, [\"rev-list\", \"--reverse\", END_OF_OPTIONS, `${base}..${head}`]));\n}\n\nfunction isOverBuffer(error: Error | undefined): boolean {\n return (error as NodeJS.ErrnoException | undefined)?.code === \"ENOBUFS\";\n}\n\n/** Refuses a source in which git still skipped a file as binary, since its lines went unscanned. */\nfunction requireAllText(source: ScanSource, what: string): ScanSource {\n if (source.binaryFiles > 0) throw new Error(`${what}: git printed a file as binary despite --text; refusing it`);\n return source;\n}\n\nfunction showCommit(cwd: string, sha: string, flags: readonly string[], maxBytes: number): string {\n const result = runGit(cwd, [\"show\", ...flags, END_OF_OPTIONS, sha], maxBytes);\n if (isOverBuffer(result.error)) throw new PatchTooLargeError(sha, maxBytes);\n return stdoutOf(result, \"show\");\n}\n\nfunction splitHeader(text: string): { idents: IdentField[]; message: string[] } {\n const parts = text.split(\"\\0\");\n const idents = IDENT_FIELDS.map((field, i) => ({ field, text: parts[i] ?? \"\" }));\n // A NUL inside the message itself stays in the message rather than shifting the fields.\n const message = parts.slice(IDENT_FIELDS.length).join(\"\\0\").replace(/\\n+$/, \"\").split(\"\\n\");\n return { idents, message };\n}\n\n/**\n * One commit's idents, message and patch, read by separate calls so a merge's per-parent copies of\n * the message never land inside its patch. Throws `PatchTooLargeError` when either is over `maxPatchBytes`.\n */\nexport function readCommit(cwd: string, sha: string, maxPatchBytes = MAX_PATCH_BYTES): ScanSource {\n requireRevision(sha, \"commit\");\n const { idents, message } = splitHeader(showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes));\n const patch = parseDiff(showCommit(cwd, sha, COMMIT_PATCH_FLAGS, maxPatchBytes));\n return requireAllText({ ...patch, sha, message, idents }, `commit ${sha.slice(0, 7)}`);\n}\n\n/** Every tracked file at HEAD, as one diff from the empty tree. Throws `PatchTooLargeError` over `maxPatchBytes`. */\nexport function readTree(cwd: string, maxPatchBytes = MAX_PATCH_BYTES): ScanSource {\n const emptyTree = git(cwd, [\"hash-object\", \"-t\", \"tree\", \"--stdin\"], \"\").trim();\n const args = [\"diff\", ...PATCH_FLAGS, \"--no-renames\", END_OF_OPTIONS, emptyTree, \"HEAD\"];\n const result = runGit(cwd, args, maxPatchBytes);\n if (isOverBuffer(result.error)) throw new PatchTooLargeError(undefined, maxPatchBytes);\n return requireAllText(parseDiff(stdoutOf(result, \"diff\")), \"tree\");\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { isCi } from \"./config.js\";\nimport { hooksDir } from \"./git.js\";\n\nexport const HOOK_MARKER = \"# titan-egress-scan managed pre-push hook\";\n\nexport type InstallOutcome = \"installed\" | \"updated\" | \"unchanged\" | \"skipped-in-ci\" | \"foreign-hook\";\n\nexport interface InstallResult {\n readonly outcome: InstallOutcome;\n readonly hookPath?: string;\n}\n\n/** The shipped hook body, one directory above both `src/` and `dist/`. */\nexport function hookBody(): string {\n return fs.readFileSync(new URL(\"../hooks/pre-push\", import.meta.url), \"utf-8\");\n}\n\nfunction existing(hookPath: string): string | undefined {\n return fs.existsSync(hookPath) ? fs.readFileSync(hookPath, \"utf-8\") : undefined;\n}\n\n/**\n * Writes the pre-push hook into git's hooks directory, which honours `core.hooksPath` and is\n * shared by every linked worktree. It never sets `core.hooksPath` and never replaces a hook it\n * did not write.\n */\nexport function installHook(cwd: string, env: Readonly<Record<string, string | undefined>>): InstallResult {\n if (isCi(env)) return { outcome: \"skipped-in-ci\" };\n const hookPath = path.join(hooksDir(cwd), \"pre-push\");\n const body = hookBody();\n const current = existing(hookPath);\n if (current !== undefined && !current.includes(HOOK_MARKER)) return { outcome: \"foreign-hook\", hookPath };\n if (current === body) return { outcome: \"unchanged\", hookPath };\n fs.mkdirSync(path.dirname(hookPath), { recursive: true });\n fs.writeFileSync(hookPath, body, { mode: 0o755 });\n fs.chmodSync(hookPath, 0o755);\n return { outcome: current === undefined ? \"installed\" : \"updated\", hookPath };\n}\n"],"mappings":"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","names":["fs","path","fs","path"]}
@@ -171,6 +171,23 @@ function applyHeaderLine(line, file) {
171
171
  else if (line.startsWith("@@")) return parseHunkHeader(line);
172
172
  return void 0;
173
173
  }
174
+ function mergeInto(target, file) {
175
+ target.pathAdded ||= file.pathAdded;
176
+ target.binary ||= file.binary;
177
+ const seen = new Set(target.lines.map((l) => `${l.line}
178
+ ${l.text}`));
179
+ target.lines.push(...file.lines.filter((l) => !seen.has(`${l.line}
180
+ ${l.text}`)));
181
+ }
182
+ function mergeSamePaths(files) {
183
+ const byPath = /* @__PURE__ */ new Map();
184
+ for (const file of files) {
185
+ const target = byPath.get(file.path);
186
+ if (target) mergeInto(target, file);
187
+ else byPath.set(file.path, { ...file, ordinal: byPath.size + 1, lines: [...file.lines] });
188
+ }
189
+ return [...byPath.values()];
190
+ }
174
191
  function parseDiff(text) {
175
192
  const files = [];
176
193
  let hunk;
@@ -186,7 +203,8 @@ function parseDiff(text) {
186
203
  files.push({ path: headerPath(line), ordinal: files.length + 1, pathAdded: false, binary: false, lines: [] });
187
204
  } else if (file) hunk = applyHeaderLine(line, file);
188
205
  }
189
- return { files, binaryFiles: files.filter((f) => f.binary).length };
206
+ const merged = mergeSamePaths(files);
207
+ return { files: merged, binaryFiles: merged.filter((f) => f.binary).length };
190
208
  }
191
209
  function parseCommit(sha, text) {
192
210
  const cut = text.indexOf("\0");
@@ -267,6 +285,7 @@ function scanFile(ctx, file, prefix) {
267
285
  function scanSource(ctx, source) {
268
286
  const prefix = source.sha === void 0 ? "" : `commit ${source.sha.slice(0, 7)} `;
269
287
  source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));
288
+ source.idents?.forEach(({ field, text }) => record(ctx, `${prefix}${field}`, matchRules(text, ctx.terms)));
270
289
  for (const file of source.files) scanFile(ctx, file, prefix);
271
290
  }
272
291
  function scan(sources, options = {}) {
@@ -319,4 +338,4 @@ export {
319
338
  scan,
320
339
  formatReport
321
340
  };
322
- //# sourceMappingURL=chunk-FZDZ52KS.js.map
341
+ //# sourceMappingURL=chunk-YQBLQ32X.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/allow.ts","../src/terms.ts","../src/diff.ts","../src/rules.ts","../src/scan.ts","../src/report.ts"],"sourcesContent":["import type { RuleId } from \"./rules.js\";\n\nexport type AllowableRule = Exclude<RuleId, \"private-term\">;\n\nexport interface AllowEntry {\n readonly glob: string;\n readonly rule: AllowableRule;\n readonly line: number;\n readonly pattern: RegExp;\n}\n\nexport interface AllowList {\n readonly entries: readonly AllowEntry[];\n}\n\nexport const EMPTY_ALLOW: AllowList = { entries: [] };\n\n/** A malformed `.egress-allow`. Callers must fail the scan on it, never treat the file as empty. */\nexport class AllowFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`.egress-allow line ${line}: ${reason}`);\n this.name = \"AllowFileError\";\n }\n}\n\nconst ALLOWABLE: readonly string[] = [\"home-path\", \"aw-data-path\"];\nconst TASK_ID = /\\b[A-Z]+-\\d+\\b/;\n\n/** Anchored glob over repo-relative paths: `**` spans directories, `*` and `?` stay within one. */\nexport function globToRegExp(glob: string): RegExp {\n let source = \"\";\n for (let i = 0; i < glob.length; i++) {\n const char = glob[i] ?? \"\";\n if (glob.startsWith(\"**/\", i)) {\n source += \"(?:.*/)?\";\n i += 2;\n } else if (glob.startsWith(\"**\", i)) {\n source += \".*\";\n i += 1;\n } else if (char === \"*\") source += \"[^/]*\";\n else if (char === \"?\") source += \"[^/]\";\n else source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n return new RegExp(`^${source}$`);\n}\n\n// A glob made only of wildcard segments would allow a rule across the whole tree.\nfunction hasLiteralSegment(glob: string): boolean {\n return glob.split(\"/\").some((segment) => segment !== \"\" && !/[*?]/.test(segment));\n}\n\nfunction parseEntry(entry: string, line: number): AllowEntry {\n const fields = /^(\\S+)\\s+(\\S+)\\s+(.+)$/.exec(entry);\n if (!fields) throw new AllowFileError(line, \"expected <glob> <rule-id> <reason>\");\n const [, glob = \"\", rule = \"\", reason = \"\"] = fields;\n if (rule === \"private-term\") throw new AllowFileError(line, \"private-term is never allowable\");\n if (!ALLOWABLE.includes(rule)) throw new AllowFileError(line, \"unknown rule id\");\n if (!TASK_ID.test(reason)) throw new AllowFileError(line, \"reason must name a task id\");\n if (!hasLiteralSegment(glob)) throw new AllowFileError(line, \"glob must name at least one literal path segment\");\n return { glob, rule: rule as AllowableRule, line, pattern: globToRegExp(glob) };\n}\n\nexport function parseAllow(text: string): AllowList {\n const entries: AllowEntry[] = [];\n text.split(/\\r?\\n/).forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n entries.push(parseEntry(entry, i + 1));\n });\n return { entries };\n}\n\nexport function isAllowed(allow: AllowList, path: string, rule: RuleId): boolean {\n if (rule === \"private-term\") return false;\n return allow.entries.some((entry) => entry.rule === rule && entry.pattern.test(path));\n}\n","import type { TermRule } from \"./rules.js\";\n\n/** A term-file problem. The message names the line number only, never the term. */\nexport class TermFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`private term list line ${line}: ${reason}`);\n this.name = \"TermFileError\";\n }\n}\n\nconst WORD_CHAR = String.raw`[\\p{L}\\p{N}_]`;\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\/]/g, \"\\\\$&\");\n}\n\nfunction compileRegex(source: string, line: number): RegExp {\n if (source === \"\") throw new TermFileError(line, \"empty regular expression\");\n try {\n return new RegExp(source, \"iu\");\n } catch {\n // The engine's message quotes the pattern, so it is dropped rather than wrapped.\n throw new TermFileError(line, \"invalid regular expression\");\n }\n}\n\nfunction compileTerm(entry: string, line: number): RegExp {\n if (entry.startsWith(\"re:\")) return compileRegex(entry.slice(3), line);\n return new RegExp(`(?<!${WORD_CHAR})${escapeRegExp(entry)}(?!${WORD_CHAR})`, \"iu\");\n}\n\n/**\n * Parses a private term list: one term per line, `#` comments, blank lines ignored, matched\n * case-insensitively on word boundaries; a `re:` line is a regex source compiled with `iu`.\n */\nexport function parseTerms(text: string): TermRule[] {\n const rules: TermRule[] = [];\n text\n .replace(/^\\uFEFF/, \"\")\n .split(/\\r?\\n/)\n .forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n const pattern = compileTerm(entry, i + 1);\n if (pattern.test(\"\")) throw new TermFileError(i + 1, \"matches the empty string, so it would match every line\");\n rules.push({ index: i + 1, matches: (candidate) => pattern.test(candidate) });\n });\n return rules;\n}\n","export interface AddedLine {\n readonly line: number;\n readonly text: string;\n}\n\nexport interface DiffFile {\n readonly path: string;\n /** 1-based position in its diff, used in place of a path that itself trips a rule. */\n readonly ordinal: number;\n /** True for a new, renamed or copied file, whose path is itself new text. */\n readonly pathAdded: boolean;\n readonly binary: boolean;\n readonly lines: readonly AddedLine[];\n}\n\n/** A short named value scanned whole, such as a commit's author name or a pushed ref name. */\nexport interface IdentField {\n readonly field: string;\n readonly text: string;\n}\n\n/** One unit of scanned text: a diff, or a commit's message, idents and diff. */\nexport interface ScanSource {\n readonly sha?: string;\n readonly message?: readonly string[];\n readonly idents?: readonly IdentField[];\n readonly files: readonly DiffFile[];\n readonly binaryFiles: number;\n}\n\ninterface MutableFile {\n path: string;\n ordinal: number;\n pathAdded: boolean;\n binary: boolean;\n lines: AddedLine[];\n}\n\ninterface Hunk {\n columns: number;\n newLine: number;\n remainingNew: number;\n remainingOld: number[];\n}\n\nconst HUNK_HEADER = /^(@{2,}) ((?:-\\d+(?:,\\d+)? )+)\\+(\\d+)(?:,(\\d+))? \\1/;\nconst C_ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };\n\n/** Undoes git's C-style path quoting (`core.quotePath`), octal UTF-8 bytes included. */\nexport function unquotePath(raw: string): string {\n if (raw.length < 2 || !raw.startsWith('\"') || !raw.endsWith('\"')) return raw;\n const body = raw.slice(1, -1);\n const bytes: number[] = [];\n for (let i = 0; i < body.length; i++) {\n const char = body[i] ?? \"\";\n const next = body[i + 1] ?? \"\";\n if (char !== \"\\\\\") bytes.push(...new TextEncoder().encode(char));\n else if (/[0-7]/.test(next)) {\n bytes.push(parseInt(body.slice(i + 1, i + 4), 8));\n i += 3;\n } else {\n bytes.push(C_ESCAPES[next] ?? next.charCodeAt(0));\n i += 1;\n }\n }\n return new TextDecoder().decode(new Uint8Array(bytes));\n}\n\nfunction stripSide(path: string): string {\n return path.replace(/^[ab]\\//, \"\");\n}\n\nfunction headerPath(header: string): string {\n const combined = /^diff --(?:cc|combined) (.+)$/.exec(header);\n if (combined) return unquotePath(combined[1] ?? \"\");\n const rest = header.slice(\"diff --git \".length);\n const quoted = / (\"(?:[^\"\\\\]|\\\\.)*\")$/.exec(rest);\n if (quoted) return stripSide(unquotePath(quoted[1] ?? \"\"));\n const half = (rest.length - 1) / 2;\n if (Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3)) return rest.slice(2, half);\n return stripSide(rest.slice(rest.lastIndexOf(\" b/\") + 1));\n}\n\nfunction rangeCount(token: string): number {\n const count = token.split(\",\")[1];\n return count === undefined ? 1 : Number(count);\n}\n\nfunction parseHunkHeader(line: string): Hunk | undefined {\n const match = HUNK_HEADER.exec(line);\n if (!match) return undefined;\n const oldRanges = (match[2] ?? \"\").trim().split(\" \");\n return {\n columns: (match[1] ?? \"\").length - 1,\n newLine: Number(match[3]),\n remainingNew: match[4] === undefined ? 1 : Number(match[4]),\n remainingOld: oldRanges.map(rangeCount),\n };\n}\n\nfunction isHunkLine(hunk: Hunk, line: string): boolean {\n if (line.startsWith(\"\\\\\")) return true;\n return line.length >= hunk.columns && /^[ +-]*$/.test(line.slice(0, hunk.columns));\n}\n\n/** Applies one hunk body line; a `-` in any column means the line is not in the new file. */\nfunction consumeHunkLine(hunk: Hunk, line: string, file: MutableFile): void {\n if (line.startsWith(\"\\\\\")) return;\n const marks = [...line.slice(0, hunk.columns)];\n const removed = marks.includes(\"-\");\n const oldMark = removed ? \"-\" : \" \";\n hunk.remainingOld = hunk.remainingOld.map((n, i) => (marks[i] === oldMark ? n - 1 : n));\n if (removed) return;\n if (marks.includes(\"+\")) file.lines.push({ line: hunk.newLine, text: line.slice(hunk.columns) });\n hunk.newLine++;\n hunk.remainingNew--;\n}\n\nfunction hunkDone(hunk: Hunk): boolean {\n return hunk.remainingNew <= 0 && hunk.remainingOld.every((n) => n <= 0);\n}\n\nfunction applyHeaderLine(line: string, file: MutableFile): Hunk | undefined {\n if (file.binary) return undefined;\n const renamed = /^(?:rename|copy) to (.+)$/.exec(line);\n if (line.startsWith(\"new file mode \")) file.pathAdded = true;\n else if (renamed) {\n file.path = unquotePath(renamed[1] ?? \"\");\n file.pathAdded = true;\n } else if (line.startsWith(\"+++ \")) {\n const target = line.slice(4).replace(/\\t$/, \"\");\n if (target !== \"/dev/null\") file.path = stripSide(unquotePath(target));\n } else if (line.startsWith(\"Binary files \") || line === \"GIT binary patch\") file.binary = true;\n else if (line.startsWith(\"@@\")) return parseHunkHeader(line);\n return undefined;\n}\n\nfunction mergeInto(target: MutableFile, file: DiffFile): void {\n target.pathAdded ||= file.pathAdded;\n target.binary ||= file.binary;\n const seen = new Set(target.lines.map((l) => `${l.line}\\n${l.text}`));\n target.lines.push(...file.lines.filter((l) => !seen.has(`${l.line}\\n${l.text}`)));\n}\n\n/** Folds the per-parent diffs of one merge into one entry per path, so a line is reported once. */\nfunction mergeSamePaths(files: readonly DiffFile[]): DiffFile[] {\n const byPath = new Map<string, MutableFile>();\n for (const file of files) {\n const target = byPath.get(file.path);\n if (target) mergeInto(target, file);\n else byPath.set(file.path, { ...file, ordinal: byPath.size + 1, lines: [...file.lines] });\n }\n return [...byPath.values()];\n}\n\n/**\n * Parses `git diff -U0` or `git show -U0` patch text into the added lines of each file, with\n * new-file line numbers. Removed lines are dropped; binary files are kept for their path only.\n * A merge shown with `--diff-merges=separate` names a path once per parent; those fold into one.\n */\nexport function parseDiff(text: string): ScanSource {\n const files: MutableFile[] = [];\n let hunk: Hunk | undefined;\n for (const line of text.split(\"\\n\")) {\n const file = files.at(-1);\n if (hunk && file && isHunkLine(hunk, line)) {\n consumeHunkLine(hunk, line, file);\n if (hunkDone(hunk)) hunk = undefined;\n continue;\n }\n hunk = undefined;\n if (/^diff --(?:git|cc|combined) /.test(line)) {\n files.push({ path: headerPath(line), ordinal: files.length + 1, pathAdded: false, binary: false, lines: [] });\n } else if (file) hunk = applyHeaderLine(line, file);\n }\n const merged = mergeSamePaths(files);\n return { files: merged, binaryFiles: merged.filter((f) => f.binary).length };\n}\n\n/** Parses `git show -U0 --format=%B%x00 <sha>` output: the message, a NUL, then the patch. */\nexport function parseCommit(sha: string, text: string): ScanSource {\n const cut = text.indexOf(\"\\0\");\n if (cut < 0) throw new Error(`commit ${sha.slice(0, 7)}: no NUL after the message`);\n const message = text.slice(0, cut).replace(/\\n+$/, \"\").split(\"\\n\");\n return { ...parseDiff(text.slice(cut + 1)), sha, message };\n}\n","export type RuleId = \"home-path\" | \"aw-data-path\" | \"private-term\";\n\nexport const RULE_IDS: readonly RuleId[] = [\"home-path\", \"aw-data-path\", \"private-term\"];\n\n/** A compiled private term. It exposes its line number, never its text or pattern. */\nexport interface TermRule {\n readonly index: number;\n matches(text: string): boolean;\n}\n\nexport interface RuleHit {\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\n/** Home-directory segments that are placeholders, not people. Compared case-insensitively. */\nexport const PLACEHOLDER_SEGMENTS: readonly string[] = [\n \"Shared\",\n \"runner\",\n \"you\",\n \"your-name\",\n \"user\",\n \"username\",\n \"me\",\n \"example\",\n \"name\",\n \"x\",\n \"alice\",\n \"bob\",\n];\n\n// One path separator: slash, backslash, or the JSON-escaped form of either.\nconst SEP = String.raw`(?:\\\\\\\\|\\\\/|/|\\\\)`;\nconst SEGMENT = String.raw`([^/\\\\\\s\"'\\`),;\\]]+)`;\nconst USERS = \"[Uu][Ss][Ee][Rr][Ss]\";\n// A root must start a path; after `file://` any host may precede it.\nconst PATH_START = String.raw`(?:(?<![\\w.-])|(?<=file://[^/\\s\"'\\`]*))`;\nconst HOME_ROOT = String.raw`(?:${PATH_START}${SEP}(?:${USERS}|[Hh][Oo][Mm][Ee])|(?<!\\w)[A-Za-z]:${SEP}${USERS})`;\nconst HOME_PATH = new RegExp(`${HOME_ROOT}${SEP}${SEGMENT}`, \"g\");\n\nconst SPACE = String.raw`(?: |%20|\\\\ )`;\nconst AW_DATA_ROOTS = [`Library${SEP}Application${SPACE}Support`, String.raw`\\.local${SEP}share`, `AppData${SEP}Local`];\n// The single definition of the active-work data directory shapes; narrow it here and nowhere else.\nconst AW_DATA_PATH = new RegExp(`(?:${AW_DATA_ROOTS.join(\"|\")})${SEP}active-work(?!\\\\w)`, \"i\");\n\nconst PLACEHOLDER_SET = new Set(PLACEHOLDER_SEGMENTS.map((s) => s.toLowerCase()));\n\nfunction isPlaceholder(rawSegment: string): boolean {\n const segment = rawSegment.replace(/[.:]+$/, \"\");\n if (segment === \"\") return true;\n if (/^<.*>$/.test(segment) || /^\\{.*\\}$/.test(segment) || segment.startsWith(\"$\")) return true;\n return PLACEHOLDER_SET.has(segment.toLowerCase());\n}\n\nexport function matchesHomePath(text: string): boolean {\n for (const match of text.matchAll(HOME_PATH)) {\n if (!isPlaceholder(match[1] ?? \"\")) return true;\n }\n return false;\n}\n\nexport function matchesAwDataPath(text: string): boolean {\n return AW_DATA_PATH.test(text);\n}\n\nexport function matchRules(text: string, terms: readonly TermRule[] = []): RuleHit[] {\n const hits: RuleHit[] = [];\n if (matchesHomePath(text)) hits.push({ rule: \"home-path\" });\n if (matchesAwDataPath(text)) hits.push({ rule: \"aw-data-path\" });\n for (const term of terms) {\n if (term.matches(text)) hits.push({ rule: \"private-term\", termIndex: term.index });\n }\n return hits;\n}\n","import { EMPTY_ALLOW, isAllowed, type AllowList } from \"./allow.js\";\nimport type { DiffFile, ScanSource } from \"./diff.js\";\nimport { matchRules, type RuleHit, type RuleId, type TermRule } from \"./rules.js\";\n\n/** Where a rule fired. By construction no field carries the matched text, term or line. */\nexport interface Finding {\n readonly location: string;\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\nexport type RuleCounts = Record<RuleId, number>;\n\nexport interface ScanOptions {\n readonly terms?: readonly TermRule[];\n readonly allow?: AllowList;\n}\n\nexport interface ScanResult {\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n}\n\ninterface Context {\n readonly terms: readonly TermRule[];\n readonly allow: AllowList;\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n}\n\nexport function zeroCounts(): RuleCounts {\n return { \"home-path\": 0, \"aw-data-path\": 0, \"private-term\": 0 };\n}\n\nfunction record(ctx: Context, location: string, hits: readonly RuleHit[], path?: string): void {\n for (const { rule, termIndex } of hits) {\n if (path !== undefined && isAllowed(ctx.allow, path, rule)) ctx.allowed[rule]++;\n else ctx.findings.push(termIndex === undefined ? { location, rule } : { location, rule, termIndex });\n }\n}\n\nfunction scanFile(ctx: Context, file: DiffFile, prefix: string): void {\n const pathHits = matchRules(file.path, ctx.terms);\n // A path that trips a rule would leak through the location itself, so it is replaced by its ordinal.\n const name = pathHits.length > 0 ? `file #${file.ordinal}` : file.path;\n if (file.pathAdded) record(ctx, `${prefix}${name} path`, pathHits, file.path);\n for (const { line, text } of file.lines) {\n record(ctx, `${prefix}${name}:${line}`, matchRules(text, ctx.terms), file.path);\n }\n}\n\nfunction scanSource(ctx: Context, source: ScanSource): void {\n const prefix = source.sha === undefined ? \"\" : `commit ${source.sha.slice(0, 7)} `;\n source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));\n source.idents?.forEach(({ field, text }) => record(ctx, `${prefix}${field}`, matchRules(text, ctx.terms)));\n for (const file of source.files) scanFile(ctx, file, prefix);\n}\n\n/** Scans added lines, added file paths, commit messages and idents. Messages and idents are never allowable. */\nexport function scan(sources: readonly ScanSource[], options: ScanOptions = {}): ScanResult {\n const ctx: Context = {\n terms: options.terms ?? [],\n allow: options.allow ?? EMPTY_ALLOW,\n findings: [],\n allowed: zeroCounts(),\n };\n for (const source of sources) scanSource(ctx, source);\n const binaryFilesSkipped = sources.reduce((sum, source) => sum + source.binaryFiles, 0);\n return { findings: ctx.findings, allowed: ctx.allowed, binaryFilesSkipped };\n}\n","import { RULE_IDS } from \"./rules.js\";\nimport { zeroCounts, type Finding, type RuleCounts } from \"./scan.js\";\n\nexport interface ReportSummary {\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n readonly termsLoaded: boolean;\n}\n\nfunction formatFinding(finding: Finding): string {\n const term = finding.termIndex === undefined ? \"\" : ` #${finding.termIndex}`;\n return `${finding.location} ${finding.rule}${term}`;\n}\n\nfunction formatCounts(counts: RuleCounts, rules: readonly string[]): string {\n return rules.map((rule) => `${rule} ${counts[rule as keyof RuleCounts]}`).join(\", \");\n}\n\n/**\n * Formats findings as `<location> <rule>[ #<termIndex>]` lines plus a summary. It takes no\n * scanned text, so it cannot echo a matched line, path fragment or term.\n */\nexport function formatReport(findings: readonly Finding[], summary: ReportSummary): string[] {\n const found = zeroCounts();\n for (const finding of findings) found[finding.rule]++;\n const plural = findings.length === 1 ? \"finding\" : \"findings\";\n return [\n ...findings.map(formatFinding),\n `egress-scan: ${findings.length} ${plural} (${formatCounts(found, RULE_IDS)})`,\n `allowed: ${formatCounts(summary.allowed, [\"home-path\", \"aw-data-path\"])}`,\n `binary files skipped: ${summary.binaryFilesSkipped}`,\n `private term list: ${summary.termsLoaded ? \"loaded\" : \"not loaded, generic rules only\"}`,\n ];\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -28,16 +28,23 @@ interface DiffFile {
28
28
  readonly binary: boolean;
29
29
  readonly lines: readonly AddedLine[];
30
30
  }
31
- /** One unit of scanned text: a diff, or a commit's message plus its diff. */
31
+ /** A short named value scanned whole, such as a commit's author name or a pushed ref name. */
32
+ interface IdentField {
33
+ readonly field: string;
34
+ readonly text: string;
35
+ }
36
+ /** One unit of scanned text: a diff, or a commit's message, idents and diff. */
32
37
  interface ScanSource {
33
38
  readonly sha?: string;
34
39
  readonly message?: readonly string[];
40
+ readonly idents?: readonly IdentField[];
35
41
  readonly files: readonly DiffFile[];
36
42
  readonly binaryFiles: number;
37
43
  }
38
44
  /**
39
45
  * Parses `git diff -U0` or `git show -U0` patch text into the added lines of each file, with
40
46
  * new-file line numbers. Removed lines are dropped; binary files are kept for their path only.
47
+ * A merge shown with `--diff-merges=separate` names a path once per parent; those fold into one.
41
48
  */
42
49
  declare function parseDiff(text: string): ScanSource;
43
50
  /** Parses `git show -U0 --format=%B%x00 <sha>` output: the message, a NUL, then the patch. */
@@ -89,7 +96,7 @@ interface ScanResult {
89
96
  readonly allowed: RuleCounts;
90
97
  readonly binaryFilesSkipped: number;
91
98
  }
92
- /** Scans added lines, added file paths and commit messages. Messages are never allowable. */
99
+ /** Scans added lines, added file paths, commit messages and idents. Messages and idents are never allowable. */
93
100
  declare function scan(sources: readonly ScanSource[], options?: ScanOptions): ScanResult;
94
101
 
95
102
  interface ReportSummary {
@@ -103,4 +110,4 @@ interface ReportSummary {
103
110
  */
104
111
  declare function formatReport(findings: readonly Finding[], summary: ReportSummary): string[];
105
112
 
106
- export { type AddedLine, type AllowEntry, AllowFileError, type AllowList, type AllowableRule, type DiffFile, EMPTY_ALLOW, type Finding, PLACEHOLDER_SEGMENTS, RULE_IDS, type ReportSummary, type RuleCounts, type RuleHit, type RuleId, type ScanOptions, type ScanResult, type ScanSource, TermFileError, type TermRule, formatReport, isAllowed, matchRules, matchesAwDataPath, matchesHomePath, parseAllow, parseCommit, parseDiff, parseTerms, scan };
113
+ export { type AddedLine, type AllowEntry, AllowFileError, type AllowList, type AllowableRule, type DiffFile, EMPTY_ALLOW, type Finding, type IdentField, PLACEHOLDER_SEGMENTS, RULE_IDS, type ReportSummary, type RuleCounts, type RuleHit, type RuleId, type ScanOptions, type ScanResult, type ScanSource, TermFileError, type TermRule, formatReport, isAllowed, matchRules, matchesAwDataPath, matchesHomePath, parseAllow, parseCommit, parseDiff, parseTerms, scan };
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  parseDiff,
15
15
  parseTerms,
16
16
  scan
17
- } from "./chunk-FZDZ52KS.js";
17
+ } from "./chunk-YQBLQ32X.js";
18
18
  export {
19
19
  AllowFileError,
20
20
  EMPTY_ALLOW,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/egress-scan",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Scan git diff text for home paths, private-workspace paths and private terms, reporting location and rule id only",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/allow.ts","../src/terms.ts","../src/diff.ts","../src/rules.ts","../src/scan.ts","../src/report.ts"],"sourcesContent":["import type { RuleId } from \"./rules.js\";\n\nexport type AllowableRule = Exclude<RuleId, \"private-term\">;\n\nexport interface AllowEntry {\n readonly glob: string;\n readonly rule: AllowableRule;\n readonly line: number;\n readonly pattern: RegExp;\n}\n\nexport interface AllowList {\n readonly entries: readonly AllowEntry[];\n}\n\nexport const EMPTY_ALLOW: AllowList = { entries: [] };\n\n/** A malformed `.egress-allow`. Callers must fail the scan on it, never treat the file as empty. */\nexport class AllowFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`.egress-allow line ${line}: ${reason}`);\n this.name = \"AllowFileError\";\n }\n}\n\nconst ALLOWABLE: readonly string[] = [\"home-path\", \"aw-data-path\"];\nconst TASK_ID = /\\b[A-Z]+-\\d+\\b/;\n\n/** Anchored glob over repo-relative paths: `**` spans directories, `*` and `?` stay within one. */\nexport function globToRegExp(glob: string): RegExp {\n let source = \"\";\n for (let i = 0; i < glob.length; i++) {\n const char = glob[i] ?? \"\";\n if (glob.startsWith(\"**/\", i)) {\n source += \"(?:.*/)?\";\n i += 2;\n } else if (glob.startsWith(\"**\", i)) {\n source += \".*\";\n i += 1;\n } else if (char === \"*\") source += \"[^/]*\";\n else if (char === \"?\") source += \"[^/]\";\n else source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n return new RegExp(`^${source}$`);\n}\n\n// A glob made only of wildcard segments would allow a rule across the whole tree.\nfunction hasLiteralSegment(glob: string): boolean {\n return glob.split(\"/\").some((segment) => segment !== \"\" && !/[*?]/.test(segment));\n}\n\nfunction parseEntry(entry: string, line: number): AllowEntry {\n const fields = /^(\\S+)\\s+(\\S+)\\s+(.+)$/.exec(entry);\n if (!fields) throw new AllowFileError(line, \"expected <glob> <rule-id> <reason>\");\n const [, glob = \"\", rule = \"\", reason = \"\"] = fields;\n if (rule === \"private-term\") throw new AllowFileError(line, \"private-term is never allowable\");\n if (!ALLOWABLE.includes(rule)) throw new AllowFileError(line, \"unknown rule id\");\n if (!TASK_ID.test(reason)) throw new AllowFileError(line, \"reason must name a task id\");\n if (!hasLiteralSegment(glob)) throw new AllowFileError(line, \"glob must name at least one literal path segment\");\n return { glob, rule: rule as AllowableRule, line, pattern: globToRegExp(glob) };\n}\n\nexport function parseAllow(text: string): AllowList {\n const entries: AllowEntry[] = [];\n text.split(/\\r?\\n/).forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n entries.push(parseEntry(entry, i + 1));\n });\n return { entries };\n}\n\nexport function isAllowed(allow: AllowList, path: string, rule: RuleId): boolean {\n if (rule === \"private-term\") return false;\n return allow.entries.some((entry) => entry.rule === rule && entry.pattern.test(path));\n}\n","import type { TermRule } from \"./rules.js\";\n\n/** A term-file problem. The message names the line number only, never the term. */\nexport class TermFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`private term list line ${line}: ${reason}`);\n this.name = \"TermFileError\";\n }\n}\n\nconst WORD_CHAR = String.raw`[\\p{L}\\p{N}_]`;\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\/]/g, \"\\\\$&\");\n}\n\nfunction compileRegex(source: string, line: number): RegExp {\n if (source === \"\") throw new TermFileError(line, \"empty regular expression\");\n try {\n return new RegExp(source, \"iu\");\n } catch {\n // The engine's message quotes the pattern, so it is dropped rather than wrapped.\n throw new TermFileError(line, \"invalid regular expression\");\n }\n}\n\nfunction compileTerm(entry: string, line: number): RegExp {\n if (entry.startsWith(\"re:\")) return compileRegex(entry.slice(3), line);\n return new RegExp(`(?<!${WORD_CHAR})${escapeRegExp(entry)}(?!${WORD_CHAR})`, \"iu\");\n}\n\n/**\n * Parses a private term list: one term per line, `#` comments, blank lines ignored, matched\n * case-insensitively on word boundaries; a `re:` line is a regex source compiled with `iu`.\n */\nexport function parseTerms(text: string): TermRule[] {\n const rules: TermRule[] = [];\n text\n .replace(/^\\uFEFF/, \"\")\n .split(/\\r?\\n/)\n .forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n const pattern = compileTerm(entry, i + 1);\n if (pattern.test(\"\")) throw new TermFileError(i + 1, \"matches the empty string, so it would match every line\");\n rules.push({ index: i + 1, matches: (candidate) => pattern.test(candidate) });\n });\n return rules;\n}\n","export interface AddedLine {\n readonly line: number;\n readonly text: string;\n}\n\nexport interface DiffFile {\n readonly path: string;\n /** 1-based position in its diff, used in place of a path that itself trips a rule. */\n readonly ordinal: number;\n /** True for a new, renamed or copied file, whose path is itself new text. */\n readonly pathAdded: boolean;\n readonly binary: boolean;\n readonly lines: readonly AddedLine[];\n}\n\n/** One unit of scanned text: a diff, or a commit's message plus its diff. */\nexport interface ScanSource {\n readonly sha?: string;\n readonly message?: readonly string[];\n readonly files: readonly DiffFile[];\n readonly binaryFiles: number;\n}\n\ninterface MutableFile {\n path: string;\n ordinal: number;\n pathAdded: boolean;\n binary: boolean;\n lines: AddedLine[];\n}\n\ninterface Hunk {\n columns: number;\n newLine: number;\n remainingNew: number;\n remainingOld: number[];\n}\n\nconst HUNK_HEADER = /^(@{2,}) ((?:-\\d+(?:,\\d+)? )+)\\+(\\d+)(?:,(\\d+))? \\1/;\nconst C_ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };\n\n/** Undoes git's C-style path quoting (`core.quotePath`), octal UTF-8 bytes included. */\nexport function unquotePath(raw: string): string {\n if (raw.length < 2 || !raw.startsWith('\"') || !raw.endsWith('\"')) return raw;\n const body = raw.slice(1, -1);\n const bytes: number[] = [];\n for (let i = 0; i < body.length; i++) {\n const char = body[i] ?? \"\";\n const next = body[i + 1] ?? \"\";\n if (char !== \"\\\\\") bytes.push(...new TextEncoder().encode(char));\n else if (/[0-7]/.test(next)) {\n bytes.push(parseInt(body.slice(i + 1, i + 4), 8));\n i += 3;\n } else {\n bytes.push(C_ESCAPES[next] ?? next.charCodeAt(0));\n i += 1;\n }\n }\n return new TextDecoder().decode(new Uint8Array(bytes));\n}\n\nfunction stripSide(path: string): string {\n return path.replace(/^[ab]\\//, \"\");\n}\n\nfunction headerPath(header: string): string {\n const combined = /^diff --(?:cc|combined) (.+)$/.exec(header);\n if (combined) return unquotePath(combined[1] ?? \"\");\n const rest = header.slice(\"diff --git \".length);\n const quoted = / (\"(?:[^\"\\\\]|\\\\.)*\")$/.exec(rest);\n if (quoted) return stripSide(unquotePath(quoted[1] ?? \"\"));\n const half = (rest.length - 1) / 2;\n if (Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3)) return rest.slice(2, half);\n return stripSide(rest.slice(rest.lastIndexOf(\" b/\") + 1));\n}\n\nfunction rangeCount(token: string): number {\n const count = token.split(\",\")[1];\n return count === undefined ? 1 : Number(count);\n}\n\nfunction parseHunkHeader(line: string): Hunk | undefined {\n const match = HUNK_HEADER.exec(line);\n if (!match) return undefined;\n const oldRanges = (match[2] ?? \"\").trim().split(\" \");\n return {\n columns: (match[1] ?? \"\").length - 1,\n newLine: Number(match[3]),\n remainingNew: match[4] === undefined ? 1 : Number(match[4]),\n remainingOld: oldRanges.map(rangeCount),\n };\n}\n\nfunction isHunkLine(hunk: Hunk, line: string): boolean {\n if (line.startsWith(\"\\\\\")) return true;\n return line.length >= hunk.columns && /^[ +-]*$/.test(line.slice(0, hunk.columns));\n}\n\n/** Applies one hunk body line; a `-` in any column means the line is not in the new file. */\nfunction consumeHunkLine(hunk: Hunk, line: string, file: MutableFile): void {\n if (line.startsWith(\"\\\\\")) return;\n const marks = [...line.slice(0, hunk.columns)];\n const removed = marks.includes(\"-\");\n const oldMark = removed ? \"-\" : \" \";\n hunk.remainingOld = hunk.remainingOld.map((n, i) => (marks[i] === oldMark ? n - 1 : n));\n if (removed) return;\n if (marks.includes(\"+\")) file.lines.push({ line: hunk.newLine, text: line.slice(hunk.columns) });\n hunk.newLine++;\n hunk.remainingNew--;\n}\n\nfunction hunkDone(hunk: Hunk): boolean {\n return hunk.remainingNew <= 0 && hunk.remainingOld.every((n) => n <= 0);\n}\n\nfunction applyHeaderLine(line: string, file: MutableFile): Hunk | undefined {\n if (file.binary) return undefined;\n const renamed = /^(?:rename|copy) to (.+)$/.exec(line);\n if (line.startsWith(\"new file mode \")) file.pathAdded = true;\n else if (renamed) {\n file.path = unquotePath(renamed[1] ?? \"\");\n file.pathAdded = true;\n } else if (line.startsWith(\"+++ \")) {\n const target = line.slice(4).replace(/\\t$/, \"\");\n if (target !== \"/dev/null\") file.path = stripSide(unquotePath(target));\n } else if (line.startsWith(\"Binary files \") || line === \"GIT binary patch\") file.binary = true;\n else if (line.startsWith(\"@@\")) return parseHunkHeader(line);\n return undefined;\n}\n\n/**\n * Parses `git diff -U0` or `git show -U0` patch text into the added lines of each file, with\n * new-file line numbers. Removed lines are dropped; binary files are kept for their path only.\n */\nexport function parseDiff(text: string): ScanSource {\n const files: MutableFile[] = [];\n let hunk: Hunk | undefined;\n for (const line of text.split(\"\\n\")) {\n const file = files.at(-1);\n if (hunk && file && isHunkLine(hunk, line)) {\n consumeHunkLine(hunk, line, file);\n if (hunkDone(hunk)) hunk = undefined;\n continue;\n }\n hunk = undefined;\n if (/^diff --(?:git|cc|combined) /.test(line)) {\n files.push({ path: headerPath(line), ordinal: files.length + 1, pathAdded: false, binary: false, lines: [] });\n } else if (file) hunk = applyHeaderLine(line, file);\n }\n return { files, binaryFiles: files.filter((f) => f.binary).length };\n}\n\n/** Parses `git show -U0 --format=%B%x00 <sha>` output: the message, a NUL, then the patch. */\nexport function parseCommit(sha: string, text: string): ScanSource {\n const cut = text.indexOf(\"\\0\");\n if (cut < 0) throw new Error(`commit ${sha.slice(0, 7)}: no NUL after the message`);\n const message = text.slice(0, cut).replace(/\\n+$/, \"\").split(\"\\n\");\n return { ...parseDiff(text.slice(cut + 1)), sha, message };\n}\n","export type RuleId = \"home-path\" | \"aw-data-path\" | \"private-term\";\n\nexport const RULE_IDS: readonly RuleId[] = [\"home-path\", \"aw-data-path\", \"private-term\"];\n\n/** A compiled private term. It exposes its line number, never its text or pattern. */\nexport interface TermRule {\n readonly index: number;\n matches(text: string): boolean;\n}\n\nexport interface RuleHit {\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\n/** Home-directory segments that are placeholders, not people. Compared case-insensitively. */\nexport const PLACEHOLDER_SEGMENTS: readonly string[] = [\n \"Shared\",\n \"runner\",\n \"you\",\n \"your-name\",\n \"user\",\n \"username\",\n \"me\",\n \"example\",\n \"name\",\n \"x\",\n \"alice\",\n \"bob\",\n];\n\n// One path separator: slash, backslash, or the JSON-escaped form of either.\nconst SEP = String.raw`(?:\\\\\\\\|\\\\/|/|\\\\)`;\nconst SEGMENT = String.raw`([^/\\\\\\s\"'\\`),;\\]]+)`;\nconst USERS = \"[Uu][Ss][Ee][Rr][Ss]\";\n// A root must start a path; after `file://` any host may precede it.\nconst PATH_START = String.raw`(?:(?<![\\w.-])|(?<=file://[^/\\s\"'\\`]*))`;\nconst HOME_ROOT = String.raw`(?:${PATH_START}${SEP}(?:${USERS}|[Hh][Oo][Mm][Ee])|(?<!\\w)[A-Za-z]:${SEP}${USERS})`;\nconst HOME_PATH = new RegExp(`${HOME_ROOT}${SEP}${SEGMENT}`, \"g\");\n\nconst SPACE = String.raw`(?: |%20|\\\\ )`;\nconst AW_DATA_ROOTS = [`Library${SEP}Application${SPACE}Support`, String.raw`\\.local${SEP}share`, `AppData${SEP}Local`];\n// The single definition of the active-work data directory shapes; narrow it here and nowhere else.\nconst AW_DATA_PATH = new RegExp(`(?:${AW_DATA_ROOTS.join(\"|\")})${SEP}active-work(?!\\\\w)`, \"i\");\n\nconst PLACEHOLDER_SET = new Set(PLACEHOLDER_SEGMENTS.map((s) => s.toLowerCase()));\n\nfunction isPlaceholder(rawSegment: string): boolean {\n const segment = rawSegment.replace(/[.:]+$/, \"\");\n if (segment === \"\") return true;\n if (/^<.*>$/.test(segment) || /^\\{.*\\}$/.test(segment) || segment.startsWith(\"$\")) return true;\n return PLACEHOLDER_SET.has(segment.toLowerCase());\n}\n\nexport function matchesHomePath(text: string): boolean {\n for (const match of text.matchAll(HOME_PATH)) {\n if (!isPlaceholder(match[1] ?? \"\")) return true;\n }\n return false;\n}\n\nexport function matchesAwDataPath(text: string): boolean {\n return AW_DATA_PATH.test(text);\n}\n\nexport function matchRules(text: string, terms: readonly TermRule[] = []): RuleHit[] {\n const hits: RuleHit[] = [];\n if (matchesHomePath(text)) hits.push({ rule: \"home-path\" });\n if (matchesAwDataPath(text)) hits.push({ rule: \"aw-data-path\" });\n for (const term of terms) {\n if (term.matches(text)) hits.push({ rule: \"private-term\", termIndex: term.index });\n }\n return hits;\n}\n","import { EMPTY_ALLOW, isAllowed, type AllowList } from \"./allow.js\";\nimport type { DiffFile, ScanSource } from \"./diff.js\";\nimport { matchRules, type RuleHit, type RuleId, type TermRule } from \"./rules.js\";\n\n/** Where a rule fired. By construction no field carries the matched text, term or line. */\nexport interface Finding {\n readonly location: string;\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\nexport type RuleCounts = Record<RuleId, number>;\n\nexport interface ScanOptions {\n readonly terms?: readonly TermRule[];\n readonly allow?: AllowList;\n}\n\nexport interface ScanResult {\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n}\n\ninterface Context {\n readonly terms: readonly TermRule[];\n readonly allow: AllowList;\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n}\n\nexport function zeroCounts(): RuleCounts {\n return { \"home-path\": 0, \"aw-data-path\": 0, \"private-term\": 0 };\n}\n\nfunction record(ctx: Context, location: string, hits: readonly RuleHit[], path?: string): void {\n for (const { rule, termIndex } of hits) {\n if (path !== undefined && isAllowed(ctx.allow, path, rule)) ctx.allowed[rule]++;\n else ctx.findings.push(termIndex === undefined ? { location, rule } : { location, rule, termIndex });\n }\n}\n\nfunction scanFile(ctx: Context, file: DiffFile, prefix: string): void {\n const pathHits = matchRules(file.path, ctx.terms);\n // A path that trips a rule would leak through the location itself, so it is replaced by its ordinal.\n const name = pathHits.length > 0 ? `file #${file.ordinal}` : file.path;\n if (file.pathAdded) record(ctx, `${prefix}${name} path`, pathHits, file.path);\n for (const { line, text } of file.lines) {\n record(ctx, `${prefix}${name}:${line}`, matchRules(text, ctx.terms), file.path);\n }\n}\n\nfunction scanSource(ctx: Context, source: ScanSource): void {\n const prefix = source.sha === undefined ? \"\" : `commit ${source.sha.slice(0, 7)} `;\n source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));\n for (const file of source.files) scanFile(ctx, file, prefix);\n}\n\n/** Scans added lines, added file paths and commit messages. Messages are never allowable. */\nexport function scan(sources: readonly ScanSource[], options: ScanOptions = {}): ScanResult {\n const ctx: Context = {\n terms: options.terms ?? [],\n allow: options.allow ?? EMPTY_ALLOW,\n findings: [],\n allowed: zeroCounts(),\n };\n for (const source of sources) scanSource(ctx, source);\n const binaryFilesSkipped = sources.reduce((sum, source) => sum + source.binaryFiles, 0);\n return { findings: ctx.findings, allowed: ctx.allowed, binaryFilesSkipped };\n}\n","import { RULE_IDS } from \"./rules.js\";\nimport { zeroCounts, type Finding, type RuleCounts } from \"./scan.js\";\n\nexport interface ReportSummary {\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n readonly termsLoaded: boolean;\n}\n\nfunction formatFinding(finding: Finding): string {\n const term = finding.termIndex === undefined ? \"\" : ` #${finding.termIndex}`;\n return `${finding.location} ${finding.rule}${term}`;\n}\n\nfunction formatCounts(counts: RuleCounts, rules: readonly string[]): string {\n return rules.map((rule) => `${rule} ${counts[rule as keyof RuleCounts]}`).join(\", \");\n}\n\n/**\n * Formats findings as `<location> <rule>[ #<termIndex>]` lines plus a summary. It takes no\n * scanned text, so it cannot echo a matched line, path fragment or term.\n */\nexport function formatReport(findings: readonly Finding[], summary: ReportSummary): string[] {\n const found = zeroCounts();\n for (const finding of findings) found[finding.rule]++;\n const plural = findings.length === 1 ? \"finding\" : \"findings\";\n return [\n ...findings.map(formatFinding),\n `egress-scan: ${findings.length} ${plural} (${formatCounts(found, RULE_IDS)})`,\n `allowed: ${formatCounts(summary.allowed, [\"home-path\", \"aw-data-path\"])}`,\n `binary files skipped: ${summary.binaryFilesSkipped}`,\n `private term list: ${summary.termsLoaded ? \"loaded\" : \"not loaded, generic rules only\"}`,\n ];\n}\n"],"mappings":"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","names":[]}