diff --git a/docs/agents/release-and-notices.md b/docs/agents/release-and-notices.md index eb3df2878..8003c365c 100644 --- a/docs/agents/release-and-notices.md +++ b/docs/agents/release-and-notices.md @@ -4,6 +4,7 @@ Licenses, distribution notices, and package rehearsal. | Rule | Enforced by | |---|---| +| Reinstall fixtures replace a previously executed optional binary by publishing a fresh executable inode rather than truncating it | `replaceBinary` stages complete executable contents and renames them into the fixture path. `binary replacement retains a running executable's inode` holds a real Linux executable open, proves in-place writing gets ETXTBSY, and requires an IPC acknowledgment from the original process after executing the replacement, failing if it exits first. Filesystem executable lifetime cannot be encoded by a path type | | A launcher lifecycle fixture publishes a complete positive child PID before the test probes or signals it | The stand-in writes a sibling file and atomically renames it into place; `stopping the launcher stops the binary` validates the record and requires ESRCH after launcher exit. Atomic publication removes the empty-file-to-PID-0 process-group race; cleanup signals only a validated PID. A numeric conversion alone cannot distinguish a complete record from an empty file | | The installer and Node launcher reject the same missing, wrong-version, non-executable or failed-check platform binary, including a Unix version process that ignores termination | `nativeBinary` owns validation and uses SIGKILL on Unix at its timeout. `native.test.mjs` executes recovery without an environment override and runs a real SIGTERM-ignoring child with the production signal option. External package contents and process signals cannot be enforced by a type | | Dependencies keep to the license policy | `cargo deny check` and the fuzz projects' license check (`deny.yml`); `scripts/svt_av1_licenses.py` checks the native SVT dependency against its recipe identity and independently pinned notice bytes (`svt-av1-native.yml`). Its rewritten-manifest regressions reject changing both the text and its recorded hash | diff --git a/packages/cli/test/native.test.mjs b/packages/cli/test/native.test.mjs index b2a259e76..de6eff791 100644 --- a/packages/cli/test/native.test.mjs +++ b/packages/cli/test/native.test.mjs @@ -5,7 +5,8 @@ // of this package's version); the fixture cases also COREAV_FIXTURE_DIRS. import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; @@ -79,6 +80,42 @@ function installed(scratch, binary) { return copy; } +function replaceBinary(binary, contents) { + const replacement = `${binary}.replacement`; + writeFileSync(replacement, contents); + chmodSync(replacement, 0o755); + renameSync(replacement, binary); +} + +test("binary replacement retains a running executable's inode", { skip: process.platform !== "linux" && "Linux executable write protection", timeout: 10000 }, async (t) => { + const scratch = mkdtempSync(join(tmpdir(), "coreav-running-inode-")); + const binary = join(scratch, "binary"); + cpSync(process.execPath, binary); + const child = spawn(binary, ["-e", 'process.on("message", () => process.send("alive")); process.send("ready");'], { stdio: ["ignore", "ignore", "pipe", "ipc"] }); + const closed = once(child, "close"); + const nextMessage = () => Promise.race([ + once(child, "message", { signal: t.signal }), + closed.then(() => assert.fail("the original executable exited before responding")), + ]); + try { + const [ready] = await nextMessage(); + assert.equal(ready, "ready"); + assert.throws(() => writeFileSync(binary, "replacement"), { code: "ETXTBSY" }); + replaceBinary(binary, "#!/bin/sh\necho replacement\n"); + const done = run(binary, ["--version"]); + assert.equal(done.status, 0, done.stderr.toString()); + assert.equal(done.stdout.toString(), "replacement\n"); + const response = nextMessage(); + child.send("ping"); + const [alive] = await response; + assert.equal(alive, "alive"); + } finally { + child.kill("SIGKILL"); + await closed; + rmSync(scratch, { recursive: true, force: true }); + } +}); + test("the install script makes the command the native binary", { skip: needsBin || (process.platform === "win32" && "the launcher stays on Windows") }, () => { const scratch = mkdtempSync(join(tmpdir(), "coreav-install-")); try { @@ -145,9 +182,9 @@ test("reinstall restores the WASI launcher after the optional binary is removed assert.equal(run(process.execPath, [join(copy, "install.js")]).status, 0); assert.equal(realpathSync(command), realpathSync(binary)); if (failure === "removed") rmSync(dirname(dirname(binary)), { recursive: true, force: true }); - else if (failure === "wrong version") writeFileSync(binary, "#!/bin/sh\necho coreav 9.9.9\n"); + else if (failure === "wrong version") replaceBinary(binary, "#!/bin/sh\necho coreav 9.9.9\n"); else if (failure === "cannot execute") chmodSync(binary, 0o644); - else writeFileSync(binary, `#!/bin/sh\necho coreav ${pkg.version}\necho rejected-native-binary >&2\nexit 1\n`); + else replaceBinary(binary, `#!/bin/sh\necho coreav ${pkg.version}\necho rejected-native-binary >&2\nexit 1\n`); const done = run(process.execPath, [join(copy, "install.js")]); assert.equal(done.status, 0, done.stderr.toString()); assert.equal(realpathSync(command), realpathSync(join(copy, "lib", "main.js")));