From 905565518c1a4bc55720bd17c8a34b21c1c81a83 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 15:43:42 +0200 Subject: [PATCH 1/2] fix(release): publish to npm only after attestation, keep one launcher copy per version The npm job ran beside `verify`, so a failed attestation still shipped an unattested version that npm will never take again. It now needs `verify`. The launcher keyed its copy by install time as well as version and size, so every fresh extract added a 95 MB copy that stayed for 30 days. It is keyed by version and size now; a published version's bytes never change. A launch whose copy was swept by another launcher between the check and the spawn falls back to the package's own binary instead of exiting 2. `runnable` takes the platform and LOCALAPPDATA as arguments, and npm/zerograph/test/launcher.test.js pins the slot rules on all three CI platforms: reuse, reinstall, the 30-day sweep and the daily mark. --- .github/workflows/ci.yml | 6 +-- .github/workflows/release.yml | 4 +- npm/zerograph/bin/zerograph.js | 53 +++++++++++-------- npm/zerograph/test/launcher.test.js | 79 +++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 26 deletions(-) create mode 100644 npm/zerograph/test/launcher.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbc59fc7..02c51124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: uses: actions/setup-node@v4 with: { node-version: '20' } - if: ${{ !cancelled() }} - run: node --test agent/hook.test.mjs + run: node --test agent/hook.test.mjs npm/zerograph/test/launcher.test.js # A default Git for Windows install puts `git` on PATH and not `sh` — the Unix tools are an # opt-in — so `ask --rerank` looks for one. Route 1: Git's shell directories @@ -281,8 +281,8 @@ jobs: npx zerograph --repo $R --no-dense trace write refund if ($LASTEXITCODE -ne 3) { throw "a verdict through the shim should exit 3, not $LASTEXITCODE" } - # An upgrade under a live server: the launcher runs a per-version copy, so the package's - # own binary can be overwritten while a detached `serve` answers, and `stop` ends it. + # An upgrade under a live server: the launcher runs a copy kept under LOCALAPPDATA, so the + # package's own binary can be overwritten while a detached `serve` answers, and `stop` ends it. if ($IsWindows) { npx zerograph --repo $R --no-dense serve --detach --idle 600 if ($LASTEXITCODE -ne 0) { throw "serve --detach through the launcher failed: $LASTEXITCODE" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b27c88..f785b365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,7 +97,9 @@ jobs: # can only be configured on a package that exists, so its first version was published by # hand (docs/bench/runbook.md). scripts/npm-pack.sh packs the same tarball. npm: - needs: build + # After `verify`, so a version whose attestation failed is never published: npm will not take + # the same version twice, and the docs promise every binary it ships verifies. + needs: [build, verify] # Only a tag has a release to download from and a version to publish under. if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest diff --git a/npm/zerograph/bin/zerograph.js b/npm/zerograph/bin/zerograph.js index 04ae2c74..1b627064 100644 --- a/npm/zerograph/bin/zerograph.js +++ b/npm/zerograph/bin/zerograph.js @@ -33,28 +33,26 @@ function binaryPath() { // failed with EBUSY. The binary runs from a copy under LOCALAPPDATA instead, and the package's own // file is never held open. Any failure here runs the package's file as before. // -// One slot per install, not per version: a project's devDependency and a global install, or two -// packs of one version, each keep their own copy rather than evicting each other's on every run. -// npm writes every file with one fixed mtime, so an install is told apart by its size and the time -// it created the file. +// One slot per version and size, so a project's devDependency and a global install at different +// versions keep their own copies instead of evicting each other on every run, and a reinstall of +// the same version reuses its copy rather than adding a 95 MB one. A published version's bytes +// never change, so the size only tells unpublished packs apart. const SLOT_TTL_MS = 30 * 24 * 3600 * 1000; const TOUCH_MS = 24 * 3600 * 1000; -function runnable(path) { - if (process.platform !== "win32" || !process.env.LOCALAPPDATA) return path; +function runnable(path, { platform = process.platform, localAppData = process.env.LOCALAPPDATA, now = Date.now() } = {}) { + if (platform !== "win32" || !localAppData) return path; try { const { version } = require("../package.json"); - const root = join(process.env.LOCALAPPDATA, "zerograph", "bin"); - const src = fs.statSync(path); - const slot = `${version}-${src.size}-${Math.trunc(src.ctimeMs)}`; + const root = join(localAppData, "zerograph", "bin"); + const slot = `${version}-${fs.statSync(path).size}`; const dir = join(root, slot); const copy = join(dir, "zerograph.exe"); const used = join(dir, "used"); - const now = Date.now(); if (fs.existsSync(copy)) { // Marked at most once a day, so a slot in use is never the one the sweep below removes. try { - if (now - fs.statSync(used).mtimeMs > TOUCH_MS) fs.utimesSync(used, new Date(), new Date()); + if (now - fs.statSync(used).mtimeMs > TOUCH_MS) fs.utimesSync(used, new Date(now), new Date(now)); } catch { fs.writeFileSync(used, ""); } @@ -75,6 +73,7 @@ function runnable(path) { if (!fs.existsSync(copy)) throw e; } fs.writeFileSync(used, ""); + fs.utimesSync(used, new Date(now), new Date(now)); // Slots nothing has launched for a month; one whose binary still runs fails the delete and // stays. for (const other of fs.readdirSync(root)) { @@ -92,16 +91,26 @@ function runnable(path) { } } -const found = binaryPath(); -if (found.error) { - console.error(found.error); - process.exit(2); +function main() { + const found = binaryPath(); + if (found.error) { + console.error(found.error); + process.exit(2); + } + const args = process.argv.slice(2); + const exe = runnable(found.path); + let run = spawnSync(exe, args, { stdio: "inherit" }); + // A sweep in another launcher can remove an old slot between our check and the spawn. + if (run.error && run.error.code === "ENOENT" && exe !== found.path) { + run = spawnSync(found.path, args, { stdio: "inherit" }); + } + if (run.error) { + console.error(`zerograph: ${run.error.message}`); + process.exit(2); + } + // A signal death has no status; report it the way a shell would. + process.exit(run.status === null ? 128 : run.status); } -const run = spawnSync(runnable(found.path), process.argv.slice(2), { stdio: "inherit" }); -if (run.error) { - console.error(`zerograph: ${run.error.message}`); - process.exit(2); -} -// A signal death has no status; report it the way a shell would. -process.exit(run.status === null ? 128 : run.status); +if (require.main === module) main(); +module.exports = { runnable }; diff --git a/npm/zerograph/test/launcher.test.js b/npm/zerograph/test/launcher.test.js new file mode 100644 index 00000000..ca1c64bf --- /dev/null +++ b/npm/zerograph/test/launcher.test.js @@ -0,0 +1,79 @@ +"use strict"; + +// The Windows copy logic runs on every platform here: `runnable` takes the platform and +// LOCALAPPDATA as arguments, so the slot rules are pinned without a Windows box. +const test = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const { join } = require("node:path"); +const { runnable } = require("../bin/zerograph.js"); +const { version } = require("../package.json"); + +const DAY = 24 * 3600 * 1000; + +function sandbox() { + const base = fs.mkdtempSync(join(os.tmpdir(), "zerograph-launcher-")); + const pkg = join(base, "pkg.exe"); + fs.writeFileSync(pkg, "x".repeat(1000)); + const app = join(base, "app"); + const root = join(app, "zerograph", "bin"); + return { pkg, app, root, win: (now = Date.now()) => ({ platform: "win32", localAppData: app, now }) }; +} + +function slot(root, name, lastUsed) { + const dir = join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(join(dir, "zerograph.exe"), "old"); + fs.writeFileSync(join(dir, "used"), ""); + fs.utimesSync(join(dir, "used"), new Date(lastUsed), new Date(lastUsed)); + return dir; +} + +test("off Windows the package's own binary runs", () => { + const s = sandbox(); + assert.strictEqual(runnable(s.pkg, { platform: "linux", localAppData: s.app }), s.pkg); + assert.strictEqual(fs.existsSync(s.root), false); +}); + +test("the binary runs from a copy, and a second launch reuses it", () => { + const s = sandbox(); + const first = runnable(s.pkg, s.win()); + assert.notStrictEqual(first, s.pkg); + assert.strictEqual(fs.readFileSync(first, "utf8"), fs.readFileSync(s.pkg, "utf8")); + assert.strictEqual(runnable(s.pkg, s.win()), first); +}); + +test("a reinstall of the same version reuses its copy instead of adding one", () => { + const s = sandbox(); + const first = runnable(s.pkg, s.win()); + fs.rmSync(s.pkg); + fs.writeFileSync(s.pkg, "x".repeat(1000)); + assert.strictEqual(runnable(s.pkg, s.win()), first); + assert.deepStrictEqual(fs.readdirSync(s.root), [`${version}-1000`]); +}); + +test("another version's copy used within the month stays", () => { + const s = sandbox(); + const now = Date.now(); + const other = slot(s.root, "0.6.0-999", now - 5 * DAY); + runnable(s.pkg, s.win(now)); + assert.ok(fs.existsSync(join(other, "zerograph.exe"))); +}); + +test("a copy nothing launched for a month is swept", () => { + const s = sandbox(); + const now = Date.now(); + const stale = slot(s.root, "0.6.0-999", now - 31 * DAY); + runnable(s.pkg, s.win(now)); + assert.strictEqual(fs.existsSync(stale), false); +}); + +test("launching an old copy marks it, so the next sweep keeps it", () => { + const s = sandbox(); + const now = Date.now(); + const mine = runnable(s.pkg, s.win(now - 29 * DAY)); + assert.strictEqual(runnable(s.pkg, s.win(now)), mine); + const used = fs.statSync(join(mine, "..", "used")).mtimeMs; + assert.ok(now - used < DAY, "the launch did not mark the slot"); +}); From c7ca33e7fcaf9dae5ddc3671ffd810626f6609a3 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 15:47:30 +0200 Subject: [PATCH 2/2] fix(npm): run a same-size rebuild's bytes, test the spawn fallback The linker pads a PE file to 512 bytes, so a rebuild of one version often keeps its size, and the version-and-size slot kept running the old copy. An install the slot has not seen is now hashed once against the copy; a match is remembered in `seen`, a mismatch replaces the copy, and a replace blocked by the old build still running runs the package's own binary. The ENOENT fallback moves into `spawnBinary`, and the tests pin it along with the same-size rebuild. --- npm/zerograph/bin/zerograph.js | 71 +++++++++++++++++++++-------- npm/zerograph/test/launcher.test.js | 31 ++++++++++++- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/npm/zerograph/bin/zerograph.js b/npm/zerograph/bin/zerograph.js index 1b627064..22cdfd55 100644 --- a/npm/zerograph/bin/zerograph.js +++ b/npm/zerograph/bin/zerograph.js @@ -35,28 +35,58 @@ function binaryPath() { // // One slot per version and size, so a project's devDependency and a global install at different // versions keep their own copies instead of evicting each other on every run, and a reinstall of -// the same version reuses its copy rather than adding a 95 MB one. A published version's bytes -// never change, so the size only tells unpublished packs apart. +// the same version reuses its copy rather than adding a 95 MB one. The size is not proof of the +// bytes (the linker pads a PE file to 512 bytes, so a rebuild often keeps it), so an install the +// slot has not seen is hashed once against the copy; `seen` remembers the installs that matched. +// npm writes every file with one fixed mtime, so an install is told apart by when it created it. const SLOT_TTL_MS = 30 * 24 * 3600 * 1000; const TOUCH_MS = 24 * 3600 * 1000; +function sha256(file) { + const hash = require("node:crypto").createHash("sha256"); + const fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(1 << 20); + let n; + while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) hash.update(buf.subarray(0, n)); + } finally { + fs.closeSync(fd); + } + return hash.digest("hex"); +} + function runnable(path, { platform = process.platform, localAppData = process.env.LOCALAPPDATA, now = Date.now() } = {}) { if (platform !== "win32" || !localAppData) return path; try { const { version } = require("../package.json"); const root = join(localAppData, "zerograph", "bin"); - const slot = `${version}-${fs.statSync(path).size}`; + const src = fs.statSync(path); + const slot = `${version}-${src.size}`; + const install = `${src.size}:${Math.trunc(src.ctimeMs)}`; const dir = join(root, slot); const copy = join(dir, "zerograph.exe"); const used = join(dir, "used"); - if (fs.existsSync(copy)) { - // Marked at most once a day, so a slot in use is never the one the sweep below removes. + const seen = join(dir, "seen"); + const digest = join(dir, "sha256"); + const read = (f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } }; + const mark = () => { + // At most once a day, so a slot in use is never the one the sweep below removes. try { - if (now - fs.statSync(used).mtimeMs > TOUCH_MS) fs.utimesSync(used, new Date(now), new Date(now)); + if (now - fs.statSync(used).mtimeMs <= TOUCH_MS) return; } catch { fs.writeFileSync(used, ""); } - return copy; + fs.utimesSync(used, new Date(now), new Date(now)); + }; + let hash; + if (fs.existsSync(copy)) { + if (read(seen).split("\n").includes(install)) { mark(); return copy; } + hash = sha256(path); + if (hash === read(digest).trim()) { + fs.appendFileSync(seen, `${install}\n`); + mark(); + return copy; + } } fs.mkdirSync(dir, { recursive: true }); // Left by a launcher killed mid-copy; one another launcher is still writing stays locked. @@ -68,12 +98,14 @@ function runnable(path, { platform = process.platform, localAppData = process.en fs.copyFileSync(path, tmp); fs.renameSync(tmp, copy); } catch (e) { - // A full disk, or another launcher that renamed the same bytes into place first. + // A full disk, an older build of these bytes still running, or another launcher that renamed + // the same bytes into place first: only the last leaves a copy that is ours to run. fs.rmSync(tmp, { force: true }); - if (!fs.existsSync(copy)) throw e; + if (!fs.existsSync(copy) || (hash ?? sha256(path)) !== sha256(copy)) return path; } - fs.writeFileSync(used, ""); - fs.utimesSync(used, new Date(now), new Date(now)); + fs.writeFileSync(digest, hash ?? sha256(path)); + fs.writeFileSync(seen, `${install}\n`); + mark(); // Slots nothing has launched for a month; one whose binary still runs fails the delete and // stays. for (const other of fs.readdirSync(root)) { @@ -91,19 +123,20 @@ function runnable(path, { platform = process.platform, localAppData = process.en } } +// A sweep in another launcher can remove an old slot between `runnable` and the spawn. +function spawnBinary(exe, pkg, args, spawn = spawnSync) { + const run = spawn(exe, args, { stdio: "inherit" }); + if (run.error && run.error.code === "ENOENT" && exe !== pkg) return spawn(pkg, args, { stdio: "inherit" }); + return run; +} + function main() { const found = binaryPath(); if (found.error) { console.error(found.error); process.exit(2); } - const args = process.argv.slice(2); - const exe = runnable(found.path); - let run = spawnSync(exe, args, { stdio: "inherit" }); - // A sweep in another launcher can remove an old slot between our check and the spawn. - if (run.error && run.error.code === "ENOENT" && exe !== found.path) { - run = spawnSync(found.path, args, { stdio: "inherit" }); - } + const run = spawnBinary(runnable(found.path), found.path, process.argv.slice(2)); if (run.error) { console.error(`zerograph: ${run.error.message}`); process.exit(2); @@ -113,4 +146,4 @@ function main() { } if (require.main === module) main(); -module.exports = { runnable }; +module.exports = { runnable, spawnBinary }; diff --git a/npm/zerograph/test/launcher.test.js b/npm/zerograph/test/launcher.test.js index ca1c64bf..5750d685 100644 --- a/npm/zerograph/test/launcher.test.js +++ b/npm/zerograph/test/launcher.test.js @@ -7,7 +7,7 @@ const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const { join } = require("node:path"); -const { runnable } = require("../bin/zerograph.js"); +const { runnable, spawnBinary } = require("../bin/zerograph.js"); const { version } = require("../package.json"); const DAY = 24 * 3600 * 1000; @@ -77,3 +77,32 @@ test("launching an old copy marks it, so the next sweep keeps it", () => { const used = fs.statSync(join(mine, "..", "used")).mtimeMs; assert.ok(now - used < DAY, "the launch did not mark the slot"); }); + +test("a rebuild of the same version and size runs the new bytes, not the old copy", () => { + const s = sandbox(); + runnable(s.pkg, s.win()); + fs.rmSync(s.pkg); + fs.writeFileSync(s.pkg, "y".repeat(1000)); + const copy = runnable(s.pkg, s.win()); + assert.strictEqual(fs.readFileSync(copy, "utf8"), "y".repeat(1000)); +}); + +test("a copy swept before the spawn falls back to the package's binary", () => { + const calls = []; + const spawn = (exe) => { + calls.push(exe); + return exe === "copy.exe" ? { error: Object.assign(new Error("gone"), { code: "ENOENT" }) } : { status: 0 }; + }; + assert.strictEqual(spawnBinary("copy.exe", "pkg.exe", [], spawn).status, 0); + assert.deepStrictEqual(calls, ["copy.exe", "pkg.exe"]); +}); + +test("a binary that fails for another reason is not run twice", () => { + const calls = []; + const spawn = (exe) => { + calls.push(exe); + return { error: Object.assign(new Error("denied"), { code: "EACCES" }) }; + }; + assert.strictEqual(spawnBinary("copy.exe", "pkg.exe", [], spawn).error.code, "EACCES"); + assert.deepStrictEqual(calls, ["copy.exe"]); +});