mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 17:04:27 +01:00
fix: adversarial review fixes (creation proof, PATH reads, bound loopback endpoints, unix sockets, env and runtime binding, future traces)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
cfb799b9d6
commit
6b4e6c5f05
7 files changed
+118
-42
No files matched your search
+5
-5
@@ -10,11 +10,11 @@ GST-167 is the plan to skip a free test on a pull request only when main already
|
|||||||
- `key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>...` prints each file's key at `<ref>`, or why it has none.
|
- `key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>...` prints each file's key at `<ref>`, or why it has none.
|
||||||
- `explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>` prints every input of one file's key and why it is or isn't eligible.
|
- `explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>` prints every input of one file's key and why it is or isn't eligible.
|
||||||
|
|
||||||
With `--base`, the CLI loads `lib/test-impact/key.ts` from that ref and runs it over the tree, so a branch can't change the rules that decide what it skips. Without `--base`, it uses this checkout's code and says so. A base ref with no key code makes every file ineligible.
|
With `--base`, the CLI loads `lib/test-impact/key.ts` from that ref and runs it over the tree, so a branch can't change the rules that decide what it skips. The CLI that does the loading comes from the checkout, so CI wiring (PR 3 and 4) has to run the base ref's copy of `scripts/test-impact.ts`, never the branch's. Without `--base`, it uses this checkout's code and says so. A base ref with no key code makes every file ineligible.
|
||||||
|
|
||||||
## What the trace records
|
## What the trace records
|
||||||
|
|
||||||
The recorder runs `strace -f -y -qq -v` with `openat, open, execve, execveat, getdents64, readlink, readlinkat, stat, lstat, newfstatat, statx, access, faccessat, faccessat2, chdir, fchdir, clone, clone3, fork, vfork, connect, bind, sendto, getsockname` (strace 6.8 on x86_64 Linux accepts the whole set) and gives the run a private TMPDIR. `lib/test-impact/trace.ts` keeps every repo path the test or any child process opened, ran, checked or probed. A failed open counts, because a file that shows up later changes what the test sees. For each repo directory it listed, the trace keeps the sorted names. Paths outside the repo, in /proc and /dev, under node_modules and in the private TMPDIR are dropped.
|
The recorder runs `strace -f -y -qq -v` with `openat, open, execve, execveat, getdents64, readlink, readlinkat, stat, lstat, newfstatat, statx, access, faccessat, faccessat2, chdir, fchdir, clone, clone3, fork, vfork, connect, bind, sendto, sendmsg, sendmmsg, getsockname` (strace 6.8 on x86_64 Linux accepts the whole set) and gives the run a private TMPDIR. `lib/test-impact/trace.ts` keeps every repo path the test or any child process opened, ran, checked or probed. A failed open counts, because a file that shows up later changes what the test sees. For each repo directory it listed, the trace keeps the sorted names. Paths outside the repo, in /proc and /dev, under node_modules and in the private TMPDIR are dropped.
|
||||||
|
|
||||||
## What the key covers
|
## What the key covers
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@ The recorder runs `strace -f -y -qq -v` with `openat, open, execve, execveat, ge
|
|||||||
A file is ineligible, so it always runs, when:
|
A file is ineligible, so it always runs, when:
|
||||||
|
|
||||||
- it is on the always-run list in the base branch's `.gstack/always-run.json` or the tree's (the union, so a branch can add entries but never drop main's);
|
- it is on the always-run list in the base branch's `.gstack/always-run.json` or the tree's (the union, so a branch can add entries but never drop main's);
|
||||||
- it has no trace, a trace older than 7 days, or a trace whose run failed;
|
- it has no trace, a trace older than 7 days or dated more than 5 minutes in the future, a trace whose run failed, or a trace made under other values of the allowlisted variables or on another runtime;
|
||||||
- its trace shows an inet connect or send to anything other than a loopback port the same run opened, any access under the real HOME other than a PATH tool lookup, shared /tmp state, or a read of the repository's own `.git`;
|
- its trace shows an inet connect or send to anything other than a loopback endpoint a socket in the same run bound, a unix socket outside the repo and the private TMPDIR (or an abstract one), any access under the real HOME other than a PATH tool lookup or the tool itself, a listing of HOME, shared /tmp state, or a read of the repository's own `.git`;
|
||||||
- its trace read a repo path the tree doesn't track, such as a build output under `dist/`. A probe of a missing path and a file the run created itself don't count;
|
- its trace read a repo path the tree doesn't track, such as a build output under `dist/`. A probe of a missing path and a file the run created or truncated itself don't count;
|
||||||
- its import-graph walk reaches the 12-hop cap. The walk fails closed: a deeper chain makes the test ineligible instead of cutting the closure short;
|
- its import-graph walk reaches the 12-hop cap. The walk fails closed: a deeper chain makes the test ineligible instead of cutting the closure short;
|
||||||
- the tree changes any key-code file against the base. Then every file runs.
|
- the tree changes any key-code file against the base. Then every file runs.
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -13,7 +13,8 @@
|
|||||||
* - tool: the bytes of each executable outside the repo the trace ran (git, bash, tar…), read on this machine;
|
* - tool: the bytes of each executable outside the repo the trace ran (git, bash, tar…), read on this machine;
|
||||||
* - runtime and env: the runtime manifest and ENV_ALLOWLIST values.
|
* - runtime and env: the runtime manifest and ENV_ALLOWLIST values.
|
||||||
* A test is ineligible (always executed) when it is always_run in the base or the tree list,
|
* A test is ineligible (always executed) when it is always_run in the base or the tree list,
|
||||||
* has no trace or one older than TRACE_MAX_AGE_MS, its traced run failed or touched the
|
* has no trace, one older than TRACE_MAX_AGE_MS or dated in the future, one made under other
|
||||||
|
* allowlisted env values or another runtime, its traced run failed or touched the
|
||||||
* network, the real HOME, shared /tmp or .git, it read a repo path the tree does not track (a build
|
* network, the real HOME, shared /tmp or .git, it read a repo path the tree does not track (a build
|
||||||
* output), or its graph walk hit the cap. A tree that
|
* output), or its graph walk hit the cap. A tree that
|
||||||
* changes any KEY_CODE_FILES file against the base makes every test ineligible.
|
* changes any KEY_CODE_FILES file against the base makes every test ineligible.
|
||||||
@@ -29,19 +30,21 @@ import { matchesAny } from '../ship-policy';
|
|||||||
import { PREGATE_CONFIG_PATH } from '../pregate/config';
|
import { PREGATE_CONFIG_PATH } from '../pregate/config';
|
||||||
import { buildGraph, type Graph } from '../pregate/import-graph';
|
import { buildGraph, type Graph } from '../pregate/import-graph';
|
||||||
import { ALWAYS_RUN_PATH, parseAlwaysRun } from '../pregate/lane-selection';
|
import { ALWAYS_RUN_PATH, parseAlwaysRun } from '../pregate/lane-selection';
|
||||||
import { readTrace } from './trace';
|
import { ENV_ALLOWLIST, envDigests, readTrace } from './trace';
|
||||||
|
|
||||||
|
export { ENV_ALLOWLIST };
|
||||||
|
|
||||||
export const MAX_HOPS = 12;
|
export const MAX_HOPS = 12;
|
||||||
export const TRACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
export const TRACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
/** How far in the future a trace's timestamp may sit (clock skew) before it counts as stale. */
|
||||||
|
export const TRACE_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||||
/** Tracer, key, CLI, the runner/planner and every repo module it imports, and the graph and always-run code the key reads. PR 4 adds its verifier here. */
|
/** Tracer, key, CLI, the runner/planner and every repo module it imports, and the graph and always-run code the key reads. PR 4 adds its verifier here. */
|
||||||
export const KEY_CODE_FILES = [
|
export const KEY_CODE_FILES = [
|
||||||
'lib/test-impact/trace.ts', 'lib/test-impact/key.ts', 'scripts/test-impact.ts',
|
'lib/test-impact/trace.ts', 'lib/test-impact/key.ts', 'scripts/test-impact.ts',
|
||||||
'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/free-ci-health.ts',
|
'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'scripts/lib/free-home-guard.ts', 'scripts/lib/free-ci-health.ts',
|
||||||
'scripts/lib/windows-curation.ts', 'test/helpers/paid-test-set.ts', 'test/helpers/touchfiles.ts', 'lib/state-root.ts',
|
'scripts/lib/windows-curation.ts', 'test/helpers/paid-test-set.ts', 'test/helpers/touchfiles.ts', 'test/helpers/test-selection.ts', 'lib/state-root.ts',
|
||||||
'lib/pregate/import-graph.ts', 'lib/pregate/lane-selection.ts',
|
'lib/pregate/import-graph.ts', 'lib/pregate/lane-selection.ts',
|
||||||
];
|
];
|
||||||
/** Environment variables whose values a free test may branch on; anything else a test reads is outside the key. */
|
|
||||||
export const ENV_ALLOWLIST = ['CI', 'GITHUB_ACTIONS', 'TZ', 'LANG', 'LC_ALL', 'NODE_ENV', 'EVALS', 'EVALS_TIER'];
|
|
||||||
const TSCONFIG = /(?:^|\/)tsconfig[^/]*\.json$/;
|
const TSCONFIG = /(?:^|\/)tsconfig[^/]*\.json$/;
|
||||||
|
|
||||||
export interface TreeEntry { mode: string; type: string; oid: string }
|
export interface TreeEntry { mode: string; type: string; oid: string }
|
||||||
@@ -181,13 +184,14 @@ export function computeKeys(opts: KeyOptions): KeyReport {
|
|||||||
const dir = materializeTree(repoRoot, tree);
|
const dir = materializeTree(repoRoot, tree);
|
||||||
try { graph = buildGraph(dir, { declarations: dependencies }); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
try { graph = buildGraph(dir, { declarations: dependencies }); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||||
}
|
}
|
||||||
const env = opts.env ?? process.env;
|
const envNow = envDigests(opts.env ?? process.env);
|
||||||
|
const runtime = opts.runtime ?? runtimeManifest();
|
||||||
const shared: KeyInput[] = [
|
const shared: KeyInput[] = [
|
||||||
...[...treeIdx.entries.keys()].filter(p => p === 'bun.lock' || TSCONFIG.test(p)).map(p => ({ kind: 'global' as const, path: p, digest: '' })),
|
...[...treeIdx.entries.keys()].filter(p => p === 'bun.lock' || TSCONFIG.test(p)).map(p => ({ kind: 'global' as const, path: p, digest: '' })),
|
||||||
{ kind: 'global', path: `${PREGATE_CONFIG_PATH}#dependencies`, digest: sha(JSON.stringify(dependencies)) },
|
{ kind: 'global', path: `${PREGATE_CONFIG_PATH}#dependencies`, digest: sha(JSON.stringify(dependencies)) },
|
||||||
...KEY_CODE_FILES.map(p => ({ kind: 'code' as const, path: p, digest: '' })),
|
...KEY_CODE_FILES.map(p => ({ kind: 'code' as const, path: p, digest: '' })),
|
||||||
...Object.entries(opts.runtime ?? runtimeManifest()).sort().map(([k, v]) => ({ kind: 'runtime' as const, path: k, digest: v })),
|
...Object.entries(runtime).sort().map(([k, v]) => ({ kind: 'runtime' as const, path: k, digest: v })),
|
||||||
...ENV_ALLOWLIST.map(k => ({ kind: 'env' as const, path: k, digest: env[k] === undefined ? 'unset' : sha(env[k]!) })),
|
...Object.entries(envNow).map(([k, digest]) => ({ kind: 'env' as const, path: k, digest })),
|
||||||
];
|
];
|
||||||
const pending = tests.map(test => {
|
const pending = tests.map(test => {
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
@@ -199,8 +203,12 @@ export function computeKeys(opts: KeyOptions): KeyReport {
|
|||||||
if (!trace) reasons.push('no trace');
|
if (!trace) reasons.push('no trace');
|
||||||
else {
|
else {
|
||||||
const age = now - Date.parse(trace.tracedAt);
|
const age = now - Date.parse(trace.tracedAt);
|
||||||
if (!(age <= TRACE_MAX_AGE_MS)) reasons.push(`stale trace (traced ${trace.tracedAt}, limit ${TRACE_MAX_AGE_MS / 86_400_000} days)`);
|
if (!(age >= -TRACE_CLOCK_SKEW_MS && age <= TRACE_MAX_AGE_MS)) reasons.push(`stale trace (traced ${trace.tracedAt}, limit ${TRACE_MAX_AGE_MS / 86_400_000} days)`);
|
||||||
if (trace.exitCode !== 0) reasons.push(`the traced run failed (exit ${trace.exitCode})`);
|
if (trace.exitCode !== 0) reasons.push(`the traced run failed (exit ${trace.exitCode})`);
|
||||||
|
const envDiff = ENV_ALLOWLIST.filter(k => trace.env?.[k] !== envNow[k]);
|
||||||
|
if (envDiff.length) reasons.push(`traced under other values of ${envDiff.join(', ')}`);
|
||||||
|
const runtimeDiff = Object.keys(runtime).filter(k => trace.runtime?.[k] !== runtime[k]);
|
||||||
|
if (runtimeDiff.length) reasons.push(`traced on another runtime (${runtimeDiff.map(k => `${k} ${trace.runtime?.[k]} vs ${runtime[k]}`).join(', ')})`);
|
||||||
reasons.push(...trace.ineligible);
|
reasons.push(...trace.ineligible);
|
||||||
const untracked = (trace.present ?? []).filter(p => p !== '.' && !treeIdx.entries.has(p));
|
const untracked = (trace.present ?? []).filter(p => p !== '.' && !treeIdx.entries.has(p));
|
||||||
if (untracked.length) reasons.push(`reads paths ${tree} does not track (${untracked.slice(0, 5).join(', ')}${untracked.length > 5 ? ` +${untracked.length - 5} more` : ''})`);
|
if (untracked.length) reasons.push(`reads paths ${tree} does not track (${untracked.slice(0, 5).join(', ')}${untracked.length > 5 ? ` +${untracked.length - 5} more` : ''})`);
|
||||||
|
|||||||
+38
-17
@@ -15,6 +15,7 @@
|
|||||||
* executables outside the repo as `tools`, so key.ts can refuse untracked inputs and hash tools.
|
* executables outside the repo as `tools`, so key.ts can refuse untracked inputs and hash tools.
|
||||||
* lib/test-impact/key.ts hashes the read set at a tree.
|
* lib/test-impact/key.ts hashes the read set at a tree.
|
||||||
*/
|
*/
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
@@ -22,9 +23,17 @@ export const TRACE_VERSION = 2;
|
|||||||
/** `-e trace=` set; strace 6.8 on x86_64 Linux accepts every name (stat and open are the legacy calls static binaries still make). */
|
/** `-e trace=` set; strace 6.8 on x86_64 Linux accepts every name (stat and open are the legacy calls static binaries still make). */
|
||||||
export const TRACE_SYSCALLS = [
|
export const TRACE_SYSCALLS = [
|
||||||
'openat', 'open', 'execve', 'execveat', 'getdents64', 'readlink', 'readlinkat', 'stat', 'lstat', 'newfstatat', 'statx',
|
'openat', 'open', 'execve', 'execveat', 'getdents64', 'readlink', 'readlinkat', 'stat', 'lstat', 'newfstatat', 'statx',
|
||||||
'access', 'faccessat', 'faccessat2', 'chdir', 'fchdir', 'clone', 'clone3', 'fork', 'vfork', 'connect', 'bind', 'sendto', 'getsockname',
|
'access', 'faccessat', 'faccessat2', 'chdir', 'fchdir', 'clone', 'clone3', 'fork', 'vfork', 'connect', 'bind', 'sendto', 'sendmsg', 'sendmmsg', 'getsockname',
|
||||||
] as const;
|
] as const;
|
||||||
/** strace flags: follow children, decode fd paths (gives every *at call its directory and every pid its cwd), full dirents, long strings. */
|
/** strace flags: follow children, decode fd paths (gives every *at call its directory and every pid its cwd), full dirents, long strings. */
|
||||||
|
/** Environment variables whose values a free test may branch on; a trace records them and a key refuses a trace made under other values. */
|
||||||
|
export const ENV_ALLOWLIST = ['CI', 'GITHUB_ACTIONS', 'TZ', 'LANG', 'LC_ALL', 'NODE_ENV', 'EVALS', 'EVALS_TIER'];
|
||||||
|
|
||||||
|
/** Each ENV_ALLOWLIST variable as `unset` or a sha256 of its value. */
|
||||||
|
export function envDigests(env: Record<string, string | undefined>): Record<string, string> {
|
||||||
|
return Object.fromEntries(ENV_ALLOWLIST.map(k => [k, env[k] === undefined ? 'unset' : createHash('sha256').update(env[k]!).digest('hex')]));
|
||||||
|
}
|
||||||
|
|
||||||
export const STRACE_FLAGS = ['-f', '-y', '-qq', '-v', '-s', '65535', '-e', `trace=${TRACE_SYSCALLS.join(',')}`];
|
export const STRACE_FLAGS = ['-f', '-y', '-qq', '-v', '-s', '65535', '-e', `trace=${TRACE_SYSCALLS.join(',')}`];
|
||||||
|
|
||||||
export interface TraceContext {
|
export interface TraceContext {
|
||||||
@@ -63,6 +72,8 @@ export interface TraceRecord extends ReadSet {
|
|||||||
commit: string | null;
|
commit: string | null;
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
runtime: Record<string, string>;
|
runtime: Record<string, string>;
|
||||||
|
/** envDigests of the traced run. */
|
||||||
|
env: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Call { pid: number; name: string; args: string; ret: string; retPath: string | null }
|
interface Call { pid: number; name: string; args: string; ret: string; retPath: string | null }
|
||||||
@@ -109,14 +120,16 @@ function dirfdAndPath(args: string): { dirfd: string | null; file: string | null
|
|||||||
const firstString = (args: string): string | null => { const s = /^"((?:[^"\\]|\\.)*)"/.exec(args); return s ? unescapeStrace(s[1]!) : null; };
|
const firstString = (args: string): string | null => { const s = /^"((?:[^"\\]|\\.)*)"/.exec(args); return s ? unescapeStrace(s[1]!) : null; };
|
||||||
const within = (p: string, root: string) => p === root || p.startsWith(root === '/' ? '/' : `${root}/`);
|
const within = (p: string, root: string) => p === root || p.startsWith(root === '/' ? '/' : `${root}/`);
|
||||||
|
|
||||||
interface Peer { family: 'inet' | 'unix'; host: string; port: number; path?: string }
|
interface Peer { family: 'inet' | 'unix'; host: string; port: number; path?: string; abstract?: boolean }
|
||||||
function sockaddr(args: string): Peer | null {
|
function sockaddr(args: string): Peer | null {
|
||||||
if (/sa_family=AF_UNIX/.test(args)) { const p = /sun_path=@?"((?:[^"\\]|\\.)*)"/.exec(args); return p ? { family: 'unix', host: '', port: 0, path: unescapeStrace(p[1]!) } : null; }
|
if (/sa_family=AF_UNIX/.test(args)) { const p = /sun_path=(@?)"((?:[^"\\]|\\.)*)"/.exec(args); return p ? { family: 'unix', host: '', port: 0, path: unescapeStrace(p[2]!), abstract: p[1] === '@' } : null; }
|
||||||
if (!/sa_family=AF_INET6?\b/.test(args)) return null;
|
if (!/sa_family=AF_INET6?\b/.test(args)) return null;
|
||||||
const port = /sin6?_port=htons\((\d+)\)/.exec(args);
|
const port = /sin6?_port=htons\((\d+)\)/.exec(args);
|
||||||
const host = /inet_addr\("([^"]+)"\)/.exec(args) ?? /inet_pton\(AF_INET6?,\s*"([^"]+)"/.exec(args);
|
const host = /inet_addr\("([^"]+)"\)/.exec(args) ?? /inet_pton\(AF_INET6?,\s*"([^"]+)"/.exec(args);
|
||||||
return { family: 'inet', host: host?.[1] ?? '?', port: port ? Number(port[1]) : 0 };
|
return { family: 'inet', host: host?.[1] ?? '?', port: port ? Number(port[1]) : 0 };
|
||||||
}
|
}
|
||||||
|
/** An open that creates the file or discards its old bytes (O_CREAT with O_EXCL or O_TRUNC); plain O_CREAT may open an existing file as is. */
|
||||||
|
const replacesFile = (args: string) => /O_CREAT/.test(args) && /O_EXCL|O_TRUNC/.test(args);
|
||||||
const loopback = (host: string) => /^127\./.test(host) || host === '::1' || host === '::ffff:127.0.0.1';
|
const loopback = (host: string) => /^127\./.test(host) || host === '::1' || host === '::ffff:127.0.0.1';
|
||||||
|
|
||||||
/** A per-test read set from one strace log. */
|
/** A per-test read set from one strace log. */
|
||||||
@@ -126,37 +139,42 @@ export function parseStrace(text: string, ctx: TraceContext): ReadSet {
|
|||||||
const tmp = path.posix.normalize(ctx.tmpDir);
|
const tmp = path.posix.normalize(ctx.tmpDir);
|
||||||
const shared = (ctx.sharedTmp ?? ['/tmp', '/var/tmp']).map(p => path.posix.normalize(p));
|
const shared = (ctx.sharedTmp ?? ['/tmp', '/var/tmp']).map(p => path.posix.normalize(p));
|
||||||
const pathDirs = new Set((ctx.pathDirs ?? []).filter(Boolean).map(p => path.posix.normalize(p)));
|
const pathDirs = new Set((ctx.pathDirs ?? []).filter(Boolean).map(p => path.posix.normalize(p)));
|
||||||
const ancestors = new Set<string>([home]);
|
const ancestors = new Set<string>();
|
||||||
for (const p of [repo, tmp, home]) for (let d = path.posix.dirname(p); ; d = path.posix.dirname(d)) { ancestors.add(d); if (d === '/') break; }
|
for (const p of [repo, tmp, home]) for (let d = path.posix.dirname(p); ; d = path.posix.dirname(d)) { ancestors.add(d); if (d === '/') break; }
|
||||||
const cwd = new Map<number, string>();
|
const cwd = new Map<number, string>();
|
||||||
const paths = new Set<string>();
|
const paths = new Set<string>();
|
||||||
const reached = new Set<string>();
|
const firstReach = new Map<string, 'created' | 'reached'>();
|
||||||
const created = new Set<string>();
|
|
||||||
const dirs = new Map<string, Set<string>>();
|
const dirs = new Map<string, Set<string>>();
|
||||||
const reasons = new Set<string>();
|
const reasons = new Set<string>();
|
||||||
const tools = new Set<string>();
|
const tools = new Set<string>();
|
||||||
const calls = straceCalls(text);
|
const calls = straceCalls(text);
|
||||||
const ownPorts = new Set<number>();
|
const bound = new Set<string>();
|
||||||
|
const ownEndpoints = new Set<string>();
|
||||||
for (const c of calls) {
|
for (const c of calls) {
|
||||||
if ((c.name !== 'bind' && c.name !== 'getsockname') || c.ret !== '0') continue;
|
if ((c.name !== 'bind' && c.name !== 'getsockname') || c.ret !== '0') continue;
|
||||||
|
const inode = /^-?\d+<socket:\[(\d+)\]>/.exec(c.args)?.[1] ?? '';
|
||||||
|
if (c.name === 'bind') bound.add(`${c.pid}:${inode}`);
|
||||||
|
else if (!bound.has(`${c.pid}:${inode}`)) continue;
|
||||||
const a = sockaddr(c.args);
|
const a = sockaddr(c.args);
|
||||||
if (a?.family === 'inet' && a.port && loopback(a.host)) ownPorts.add(a.port);
|
if (a?.family === 'inet' && a.port) ownEndpoints.add(`${a.host}:${a.port}`);
|
||||||
}
|
}
|
||||||
|
const ownLoopback = (p: Peer) => loopback(p.host) && [p.host, '0.0.0.0', '::'].some(h => ownEndpoints.has(`${h}:${p.port}`));
|
||||||
const cwdOf = (pid: number) => cwd.get(pid) ?? ctx.startCwd ?? repo;
|
const cwdOf = (pid: number) => cwd.get(pid) ?? ctx.startCwd ?? repo;
|
||||||
const absolute = (pid: number, p: string, dir: string | null) => path.posix.normalize(path.posix.isAbsolute(p) ? p : path.posix.join(dir ?? cwdOf(pid), p));
|
const absolute = (pid: number, p: string, dir: string | null) => path.posix.normalize(path.posix.isAbsolute(p) ? p : path.posix.join(dir ?? cwdOf(pid), p));
|
||||||
const record = (raw: string, kind: 'access' | 'exec' | 'content', ok = false, creates = false) => {
|
const record = (raw: string, kind: 'access' | 'exec' | 'content', ok = false, creates = false) => {
|
||||||
const abs = raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
const abs = raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
||||||
if (within(abs, '/proc') || within(abs, '/dev') || within(abs, tmp) || ancestors.has(abs)) return;
|
if (within(abs, '/proc') || within(abs, '/dev') || within(abs, tmp) || ancestors.has(abs) || (abs === home && kind === 'access')) return;
|
||||||
if (within(abs, repo)) {
|
if (within(abs, repo)) {
|
||||||
const rel = abs === repo ? '.' : abs.slice(repo.length + 1);
|
const rel = abs === repo ? '.' : abs.slice(repo.length + 1);
|
||||||
if (/(?:^|\/)node_modules(?:\/|$)/.test(rel)) return;
|
if (/(?:^|\/)node_modules(?:\/|$)/.test(rel)) return;
|
||||||
if (rel === '.git' && kind === 'access') return;
|
if (rel === '.git' && kind === 'access') return;
|
||||||
if (rel === '.git' || rel.startsWith('.git/')) { reasons.add(`reads the repository's own git state (${rel})`); return; }
|
if (rel === '.git' || rel.startsWith('.git/')) { reasons.add(`reads the repository's own git state (${rel})`); return; }
|
||||||
paths.add(rel);
|
paths.add(rel);
|
||||||
if (ok) (creates ? created : reached).add(rel);
|
if (ok && !firstReach.has(rel)) firstReach.set(rel, creates ? 'created' : 'reached');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathDirs.has(path.posix.dirname(abs))) { if (kind === 'exec') tools.add(abs); return; }
|
if (tools.has(abs) && kind === 'content') return;
|
||||||
|
if (pathDirs.has(path.posix.dirname(abs)) && kind !== 'content') { if (kind === 'exec') tools.add(abs); return; }
|
||||||
if (within(abs, home)) { reasons.add(`reads the real HOME (${abs})`); return; }
|
if (within(abs, home)) { reasons.add(`reads the real HOME (${abs})`); return; }
|
||||||
const sharedRoot = shared.find(s => within(abs, s));
|
const sharedRoot = shared.find(s => within(abs, s));
|
||||||
if (sharedRoot && abs !== sharedRoot) { reasons.add(`reads shared temp state (${abs})`); return; }
|
if (sharedRoot && abs !== sharedRoot) { reasons.add(`reads shared temp state (${abs})`); return; }
|
||||||
@@ -191,23 +209,26 @@ export function parseStrace(text: string, ctx: TraceContext): ReadSet {
|
|||||||
dirs.set(rel, names);
|
dirs.set(rel, names);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'connect': case 'sendto': {
|
case 'connect': case 'sendto': case 'sendmsg': case 'sendmmsg': {
|
||||||
const peer = sockaddr(c.args);
|
const peer = sockaddr(c.args);
|
||||||
if (peer?.family === 'unix' && peer.path) record(absolute(c.pid, peer.path, null), 'access');
|
if (peer?.family === 'inet' && !ownLoopback(peer)) reasons.add(`network: ${c.name} ${peer.host}:${peer.port}`);
|
||||||
if (peer?.family === 'inet' && !(loopback(peer.host) && ownPorts.has(peer.port))) reasons.add(`network: ${c.name} ${peer.host}:${peer.port}`);
|
if (peer?.family !== 'unix' || !peer.path || !ok) break;
|
||||||
|
const abs = peer.abstract ? null : absolute(c.pid, peer.path, null);
|
||||||
|
if (abs && (within(abs, repo) || within(abs, tmp))) record(abs, 'access', true);
|
||||||
|
else reasons.add(`external socket: ${c.name} ${peer.abstract ? '@' : ''}${peer.path}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'bind': case 'getsockname': break;
|
case 'bind': case 'getsockname': break;
|
||||||
case 'execve': case 'open': case 'stat': case 'lstat': case 'access': case 'readlink': {
|
case 'execve': case 'open': case 'stat': case 'lstat': case 'access': case 'readlink': {
|
||||||
const p = firstString(c.args);
|
const p = firstString(c.args);
|
||||||
if (p === null) break;
|
if (p === null) break;
|
||||||
record(absolute(c.pid, p, null), c.name === 'execve' ? (ok ? 'exec' : 'access') : c.name === 'open' ? 'content' : 'access', ok, c.name === 'open' && /O_CREAT/.test(c.args));
|
record(absolute(c.pid, p, null), c.name === 'execve' ? (ok ? 'exec' : 'access') : c.name === 'open' ? 'content' : 'access', ok, c.name === 'open' && replacesFile(c.args));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
if (file === null) break;
|
if (file === null) break;
|
||||||
const target = file === '' && dirfd ? path.posix.normalize(dirfd) : absolute(c.pid, file, dirfd);
|
const target = file === '' && dirfd ? path.posix.normalize(dirfd) : absolute(c.pid, file, dirfd);
|
||||||
const creates = c.name === 'openat' && /O_CREAT/.test(c.args);
|
const creates = c.name === 'openat' && replacesFile(c.args);
|
||||||
record(target, c.name === 'execveat' ? (ok ? 'exec' : 'access') : c.name === 'openat' ? 'content' : 'access', ok, creates);
|
record(target, c.name === 'execveat' ? (ok ? 'exec' : 'access') : c.name === 'openat' ? 'content' : 'access', ok, creates);
|
||||||
if (ok && c.retPath && c.name === 'openat' && path.posix.isAbsolute(c.retPath)) record(path.posix.normalize(c.retPath), 'content', true, creates);
|
if (ok && c.retPath && c.name === 'openat' && path.posix.isAbsolute(c.retPath)) record(path.posix.normalize(c.retPath), 'content', true, creates);
|
||||||
}
|
}
|
||||||
@@ -217,7 +238,7 @@ export function parseStrace(text: string, ctx: TraceContext): ReadSet {
|
|||||||
paths: [...paths].sort(),
|
paths: [...paths].sort(),
|
||||||
dirs: Object.fromEntries([...dirs].sort(([a], [b]) => a.localeCompare(b)).map(([d, n]) => [d, [...n].sort()])),
|
dirs: Object.fromEntries([...dirs].sort(([a], [b]) => a.localeCompare(b)).map(([d, n]) => [d, [...n].sort()])),
|
||||||
ineligible: [...reasons].sort(),
|
ineligible: [...reasons].sort(),
|
||||||
present: [...reached].filter(p => !created.has(p)).sort(),
|
present: [...firstReach].filter(([, how]) => how === 'reached').map(([p]) => p).sort(),
|
||||||
tools: [...tools].sort(),
|
tools: [...tools].sort(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import * as path from 'node:path';
|
|||||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||||
import * as localKey from '../lib/test-impact/key';
|
import * as localKey from '../lib/test-impact/key';
|
||||||
import type { KeyOptions, KeyReport } from '../lib/test-impact/key';
|
import type { KeyOptions, KeyReport } from '../lib/test-impact/key';
|
||||||
import { STRACE_FLAGS, TRACE_VERSION, parseStrace, tracePath, type TraceRecord } from '../lib/test-impact/trace';
|
import { STRACE_FLAGS, TRACE_VERSION, envDigests, parseStrace, tracePath, type TraceRecord } from '../lib/test-impact/trace';
|
||||||
|
|
||||||
const USAGE = 'usage: test-impact.ts trace [--out <dir>] <test-file>... | key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>... | explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>';
|
const USAGE = 'usage: test-impact.ts trace [--out <dir>] <test-file>... | key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>... | explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>';
|
||||||
const DEFAULT_TRACES = path.join('.context', 'test-impact', 'traces');
|
const DEFAULT_TRACES = path.join('.context', 'test-impact', 'traces');
|
||||||
@@ -62,7 +62,7 @@ function trace(root: string, argv: string[]): number {
|
|||||||
const r = spawnSync('strace', [...STRACE_FLAGS, '-o', log, '--', process.execPath, ...bunIsolationArgs(), 'test', path.join(root, file), '--timeout=30000', '--max-concurrency=1'],
|
const r = spawnSync('strace', [...STRACE_FLAGS, '-o', log, '--', process.execPath, ...bunIsolationArgs(), 'test', path.join(root, file), '--timeout=30000', '--max-concurrency=1'],
|
||||||
{ cwd: root, env: { ...process.env, TMPDIR: tmp, TMP: tmp, TEMP: tmp, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, timeout: 600_000, maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
{ cwd: root, env: { ...process.env, TMPDIR: tmp, TMP: tmp, TEMP: tmp, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, timeout: 600_000, maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
const set = parseStrace(fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '', { repoRoot: root, home: os.homedir(), tmpDir: tmp, pathDirs: (process.env.PATH ?? '').split(path.delimiter) });
|
const set = parseStrace(fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '', { repoRoot: root, home: os.homedir(), tmpDir: tmp, pathDirs: (process.env.PATH ?? '').split(path.delimiter) });
|
||||||
const record: TraceRecord = { version: TRACE_VERSION, test: file, tracedAt: new Date().toISOString(), commit: head.status === 0 ? head.stdout.trim() : null, exitCode: r.status, runtime: localKey.runtimeManifest(), ...set };
|
const record: TraceRecord = { version: TRACE_VERSION, test: file, tracedAt: new Date().toISOString(), commit: head.status === 0 ? head.stdout.trim() : null, exitCode: r.status, runtime: localKey.runtimeManifest(), env: envDigests(process.env), ...set };
|
||||||
fs.writeFileSync(tracePath(out, file), JSON.stringify(record, null, 2) + '\n');
|
fs.writeFileSync(tracePath(out, file), JSON.stringify(record, null, 2) + '\n');
|
||||||
if (r.status !== 0) failed++;
|
if (r.status !== 0) failed++;
|
||||||
const verdict = set.ineligible.length ? `ineligible: ${set.ineligible.join('; ')}` : 'eligible';
|
const verdict = set.ineligible.length ? `ineligible: ${set.ineligible.join('; ')}` : 'eligible';
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# strace 6.8 capture (x86_64 Linux, bun 1.4.4) of test/cap.test.ts under env -i, made with scripts/test-impact.ts flags.
|
# strace 6.8 capture (x86_64 Linux, bun 1.4.4) of test/cap.test.ts under env -i.
|
||||||
# repo=/tmp/ti-capture/repo home=/tmp/ti-capture/home tmpdir=/tmp/ti-capture/run PATH=/usr/local/bin:/usr/bin:/bin
|
# repo=/tmp/ti-capture/repo home=/tmp/ti-capture/home tmpdir=/tmp/ti-capture/run PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
# The test reads lib/data.txt by a computed path, lists test/data, spawns bin/tool2 (cd to bin, source ./tool2-lib.sh),
|
# The test reads lib/data.txt by a computed path, lists test/data, spawns bin/tool2 (cd to bin, source ./tool2-lib.sh),
|
||||||
# reads $HOME/.gitconfig, /tmp/ti-shared-state and .git/HEAD, probes lib/missing.txt, talks to its own loopback server,
|
# reads $HOME/.gitconfig, /tmp/ti-shared-state and .git/HEAD, probes lib/missing.txt, talks to its own loopback server,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
|||||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||||
import { ALWAYS_RUN_PATH } from '../lib/pregate/lane-selection';
|
import { ALWAYS_RUN_PATH } from '../lib/pregate/lane-selection';
|
||||||
import { computeKeys, MAX_HOPS, type KeyResult } from '../lib/test-impact/key';
|
import { computeKeys, MAX_HOPS, type KeyResult } from '../lib/test-impact/key';
|
||||||
import { TRACE_VERSION, parseStrace, readTrace, tracePath, type TraceRecord } from '../lib/test-impact/trace';
|
import { TRACE_VERSION, envDigests, parseStrace, readTrace, tracePath, type TraceRecord } from '../lib/test-impact/trace';
|
||||||
import { cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
|
import { cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dir, '..');
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
@@ -28,7 +28,7 @@ function fixture(baseFiles: Record<string, string> = {}): FixtureRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Write `test`'s trace from strace lines that open `reads`, list `lists` and add `extra` raw calls, through parseStrace. */
|
/** Write `test`'s trace from strace lines that open `reads`, list `lists` and add `extra` raw calls, through parseStrace. */
|
||||||
function traceFrom(repo: FixtureRepo, test: string, opts: { reads?: string[]; lists?: Record<string, string[]>; extra?: string[]; tracedAt?: string } = {}): string {
|
function traceFrom(repo: FixtureRepo, test: string, opts: { reads?: string[]; lists?: Record<string, string[]>; extra?: string[]; tracedAt?: string; env?: Record<string, string | undefined> } = {}): string {
|
||||||
const w = repo.work;
|
const w = repo.work;
|
||||||
const lines = [test, ...(opts.reads ?? [])].map(p => `100 openat(AT_FDCWD<${w}>, "${w}/${p}", O_RDONLY|O_CLOEXEC) = 3<${w}/${p}>`);
|
const lines = [test, ...(opts.reads ?? [])].map(p => `100 openat(AT_FDCWD<${w}>, "${w}/${p}", O_RDONLY|O_CLOEXEC) = 3<${w}/${p}>`);
|
||||||
for (const [dir, names] of Object.entries(opts.lists ?? {})) lines.push(`100 getdents64(4<${w}/${dir}>, [${['.', '..', ...names].map(n => `{d_ino=1, d_off=1, d_reclen=24, d_type=DT_REG, d_name="${n}"}`).join(', ')}], 32768) = 96`);
|
for (const [dir, names] of Object.entries(opts.lists ?? {})) lines.push(`100 getdents64(4<${w}/${dir}>, [${['.', '..', ...names].map(n => `{d_ino=1, d_off=1, d_reclen=24, d_type=DT_REG, d_name="${n}"}`).join(', ')}], 32768) = 96`);
|
||||||
@@ -36,13 +36,13 @@ function traceFrom(repo: FixtureRepo, test: string, opts: { reads?: string[]; li
|
|||||||
const dir = path.join(repo.root, 'traces');
|
const dir = path.join(repo.root, 'traces');
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const set = parseStrace(lines.join('\n'), { repoRoot: w, home: repo.home, tmpDir: path.join(repo.root, 'run'), sharedTmp: [] });
|
const set = parseStrace(lines.join('\n'), { repoRoot: w, home: repo.home, tmpDir: path.join(repo.root, 'run'), sharedTmp: [] });
|
||||||
const record: TraceRecord = { version: TRACE_VERSION, test, tracedAt: opts.tracedAt ?? '2026-11-01T00:00:00Z', commit: null, exitCode: 0, runtime: RUNTIME, ...set };
|
const record: TraceRecord = { version: TRACE_VERSION, test, tracedAt: opts.tracedAt ?? '2026-11-01T00:00:00Z', commit: null, exitCode: 0, runtime: RUNTIME, env: envDigests(opts.env ?? {}), ...set };
|
||||||
fs.writeFileSync(tracePath(dir, test), JSON.stringify(record));
|
fs.writeFileSync(tracePath(dir, test), JSON.stringify(record));
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyAt(repo: FixtureRepo, tree: string, test: string, base: string | null = 'main'): KeyResult {
|
function keyAt(repo: FixtureRepo, tree: string, test: string, base: string | null = 'main', env: Record<string, string | undefined> = {}, now = NOW): KeyResult {
|
||||||
return computeKeys({ repoRoot: repo.work, tree, base, tests: [test], traceDir: path.join(repo.root, 'traces'), now: NOW, runtime: RUNTIME, env: {} }).results[0]!;
|
return computeKeys({ repoRoot: repo.work, tree, base, tests: [test], traceDir: path.join(repo.root, 'traces'), now, runtime: RUNTIME, env }).results[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cli(repo: FixtureRepo, args: string[]) {
|
function cli(repo: FixtureRepo, args: string[]) {
|
||||||
@@ -165,6 +165,18 @@ describe('test-impact key: ineligible', () => {
|
|||||||
expect(keyAt(repo, 'main', 'test/config-read.test.js').reasons).toEqual(['the traced run failed (exit 1)']);
|
expect(keyAt(repo, 'main', 'test/config-read.test.js').reasons).toEqual(['the traced run failed (exit 1)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a future-dated trace, or one made under other allowlisted env values or another runtime, is ineligible', () => {
|
||||||
|
const repo = fixture();
|
||||||
|
traceFrom(repo, 'test/util.test.js', { tracedAt: '2026-11-02T00:00:00Z' });
|
||||||
|
traceFrom(repo, 'test/retry.test.js', { env: { NODE_ENV: 'development' } });
|
||||||
|
expect(keyAt(repo, 'main', 'test/util.test.js').reasons[0]).toStartWith('stale trace (traced 2026-11-02T00:00:00Z');
|
||||||
|
expect(keyAt(repo, 'main', 'test/util.test.js', 'main', {}, Date.parse('2026-11-02T00:01:00Z')).reasons).toEqual([]);
|
||||||
|
expect(keyAt(repo, 'main', 'test/retry.test.js', 'main', { NODE_ENV: 'production' }).reasons).toEqual(['traced under other values of NODE_ENV']);
|
||||||
|
expect(keyAt(repo, 'main', 'test/retry.test.js', 'main', { NODE_ENV: 'development' }).reasons).toEqual([]);
|
||||||
|
const other = computeKeys({ repoRoot: repo.work, tree: 'main', base: 'main', tests: ['test/retry.test.js'], traceDir: path.join(repo.root, 'traces'), now: NOW, runtime: { ...RUNTIME, bun: '1.5.0' }, env: { NODE_ENV: 'development' } }).results[0]!;
|
||||||
|
expect(other.reasons).toEqual(['traced on another runtime (bun 1.4.4 vs 1.5.0)']);
|
||||||
|
});
|
||||||
|
|
||||||
test('a tree that edits key code makes every test ineligible', () => {
|
test('a tree that edits key code makes every test ineligible', () => {
|
||||||
const repo = fixture({ 'lib/test-impact/key.ts': 'export const v = 1;\n' });
|
const repo = fixture({ 'lib/test-impact/key.ts': 'export const v = 1;\n' });
|
||||||
traceFrom(repo, 'test/util.test.js');
|
traceFrom(repo, 'test/util.test.js');
|
||||||
@@ -201,6 +213,7 @@ describe('scripts/test-impact.ts', () => {
|
|||||||
expect(trusted.out).toContain('key code from main:lib/test-impact/key.ts');
|
expect(trusted.out).toContain('key code from main:lib/test-impact/key.ts');
|
||||||
expect(trusted.out).toContain('INELIGIBLE test/util.test.js: stub key code from the base ref');
|
expect(trusted.out).toContain('INELIGIBLE test/util.test.js: stub key code from the base ref');
|
||||||
|
|
||||||
|
traceFrom(repo, 'test/util.test.js', { env: { ...process.env, ...repo.env } });
|
||||||
const local = cli(repo, ['explain', '--tree', 'HEAD', '--traces', path.join(repo.root, 'traces'), 'test/util.test.js']);
|
const local = cli(repo, ['explain', '--tree', 'HEAD', '--traces', path.join(repo.root, 'traces'), 'test/util.test.js']);
|
||||||
expect(local.status).toBe(0);
|
expect(local.status).toBe(0);
|
||||||
expect(local.out).toMatch(/KEY test\/util\.test\.js [0-9a-f]{12}/);
|
expect(local.out).toMatch(/KEY test\/util\.test\.js [0-9a-f]{12}/);
|
||||||
@@ -226,9 +239,12 @@ describe('scripts/test-impact.ts', () => {
|
|||||||
const rec = readTrace(traces, t)!;
|
const rec = readTrace(traces, t)!;
|
||||||
expect(rec.paths).toEqual(expect.arrayContaining([t, 'bin/tool2', 'bin/tool2-lib.sh']));
|
expect(rec.paths).toEqual(expect.arrayContaining([t, 'bin/tool2', 'bin/tool2-lib.sh']));
|
||||||
expect(rec.ineligible).toEqual([]);
|
expect(rec.ineligible).toEqual([]);
|
||||||
const before = keyAt(repo, 'HEAD', t);
|
const env = { ...process.env, ...repo.env };
|
||||||
expect(before.eligible).toBe(true);
|
const runtime = { platform: process.platform, arch: process.arch, bun: Bun.version };
|
||||||
|
const keyNow = (tree: string) => computeKeys({ repoRoot: repo.work, tree, base: 'main', tests: [t], traceDir: traces, runtime, env }).results[0]!;
|
||||||
|
const before = keyNow('HEAD');
|
||||||
|
expect(before.reasons).toEqual([]);
|
||||||
commit(repo, { 'bin/tool2-lib.sh': 'greet() { echo hi; }\n# edited\n' });
|
commit(repo, { 'bin/tool2-lib.sh': 'greet() { echo hi; }\n# edited\n' });
|
||||||
expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key);
|
expect(keyNow('HEAD').key).not.toBe(before.key);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,3 +57,34 @@ describe('parseStrace on a captured bun test', () => {
|
|||||||
expect(r.paths).toEqual(['lib/a b.txt', 'sub', 'sub/x.json']);
|
expect(r.paths).toEqual(['lib/a b.txt', 'sub', 'sub/x.json']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseStrace soundness edges (ship review, #3801)', () => {
|
||||||
|
const ctx = { repoRoot: '/r', home: '/h', tmpDir: '/t', pathDirs: ['/h/bin', '/usr/bin'] };
|
||||||
|
const parse = (...lines: string[]) => parseStrace(lines.join('\n'), ctx);
|
||||||
|
|
||||||
|
test('only an open that creates or truncates a file makes it the run\'s own; reading an existing file first, or plain O_CREAT, keeps it an input', () => {
|
||||||
|
expect(parse('1 openat(AT_FDCWD</r>, "/r/dist/x", O_RDONLY) = 3</r/dist/x>', '1 openat(AT_FDCWD</r>, "/r/dist/x", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3</r/dist/x>').present).toEqual(['dist/x']);
|
||||||
|
expect(parse('1 openat(AT_FDCWD</r>, "/r/dist/s.json", O_RDWR|O_CREAT, 0666) = 3</r/dist/s.json>').present).toEqual(['dist/s.json']);
|
||||||
|
expect(parse('1 openat(AT_FDCWD</r>, "/r/out.json", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3</r/out.json>', '1 openat(AT_FDCWD</r>, "/r/out.json", O_RDONLY) = 3</r/out.json>').present).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a PATH directory under HOME exempts tool lookups and the tool itself, not other reads there; HOME itself may be stat\'ed but not listed', () => {
|
||||||
|
expect(parse('1 stat("/h/bin/git", 0x1) = -1 ENOENT (No such file or directory)', '1 execve("/h/bin/tool", ["tool"], []) = 0', '1 openat(AT_FDCWD</r>, "/h/bin/tool", O_RDONLY) = 3</h/bin/tool>', '1 newfstatat(AT_FDCWD</r>, "/h", {...}, 0) = 0').ineligible).toEqual([]);
|
||||||
|
expect(parse('1 openat(AT_FDCWD</r>, "/h/bin/settings.json", O_RDONLY) = 3</h/bin/settings.json>').ineligible).toEqual(['reads the real HOME (/h/bin/settings.json)']);
|
||||||
|
expect(parse('1 getdents64(3</h>, [{d_ino=1, d_off=1, d_reclen=24, d_type=DT_REG, d_name="x"}], 32768) = 24').ineligible).toEqual(['reads the real HOME (/h)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loopback counts as the run\'s own only for an endpoint a socket it bound reports; an outside service on a shared port number does not', () => {
|
||||||
|
const external = parse('1 connect(5<socket:[10]>, {sa_family=AF_INET, sin_port=htons(9000), sin_addr=inet_addr("127.0.0.1")}, 16) = 0', '1 getsockname(5<socket:[10]>, {sa_family=AF_INET, sin_port=htons(9000), sin_addr=inet_addr("127.0.0.2")}, [16]) = 0');
|
||||||
|
expect(external.ineligible).toEqual(['network: connect 127.0.0.1:9000']);
|
||||||
|
const own = parse('1 bind(4<socket:[11]>, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("0.0.0.0")}, 16) = 0', '1 getsockname(4<socket:[11]>, {sa_family=AF_INET, sin_port=htons(4100), sin_addr=inet_addr("0.0.0.0")}, [16]) = 0', '1 connect(6<socket:[12]>, {sa_family=AF_INET, sin_port=htons(4100), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress)');
|
||||||
|
expect(own.ineligible).toEqual([]);
|
||||||
|
expect(parse('1 sendmsg(7<socket:[13]>, {msg_name={sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("8.8.8.8")}, msg_namelen=16}, 0) = 40').ineligible).toEqual(['network: sendmsg 8.8.8.8:53']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a unix socket outside the repo and private TMPDIR, or an abstract one, is an external dependency; a failed lookup is not', () => {
|
||||||
|
expect(parse('1 connect(3<socket:[1]>, {sa_family=AF_UNIX, sun_path="/var/run/docker.sock"}, 110) = 0').ineligible).toEqual(['external socket: connect /var/run/docker.sock']);
|
||||||
|
expect(parse('1 connect(3<socket:[1]>, {sa_family=AF_UNIX, sun_path=@"dbus-x"}, 110) = 0').ineligible).toEqual(['external socket: connect @dbus-x']);
|
||||||
|
expect(parse('1 connect(3<socket:[1]>, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory)', '1 connect(3<socket:[2]>, {sa_family=AF_UNIX, sun_path="/t/browse.sock"}, 110) = 0').ineligible).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user