From 1d579ea5a2ba93fb7073e0656720cc9fe91e12e9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 16:05:19 -0500 Subject: [PATCH] Check the identity of the open file a runtime pack artifact is read from Staging stat'ed an artifact's path, read the path, and stat'ed it again. When the path was a symbolic link switched to another file of the same size during the read and back before the second stat, both identities were those of the verified file, so the bytes of the other file were staged without hashing. Staging now opens the artifact once and takes both identities from the open file with fstat, so they describe the file the bytes come from. A test switches the artifact's link to a same-sized file during the read and checks that the staged copy has the verified bytes. --- .../src/backend/runtime-pack-native.test.ts | 50 ++++++++++++++++++- .../src/backend/runtime-pack-native.ts | 20 ++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/backend/runtime-pack-native.test.ts b/packages/compiler/src/backend/runtime-pack-native.test.ts index 723deba39..293d814b8 100644 --- a/packages/compiler/src/backend/runtime-pack-native.test.ts +++ b/packages/compiler/src/backend/runtime-pack-native.test.ts @@ -1,5 +1,14 @@ import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + readFileSync, + renameSync, + statSync, + symlinkSync, + unlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -21,6 +30,10 @@ vi.mock("node:crypto", async (original) => { const crypto = await original(); return { ...crypto, createHash: vi.fn(crypto.createHash) }; }); +vi.mock("node:fs", async (original) => { + const fs = await original(); + return { ...fs, readFileSync: vi.fn(fs.readFileSync) }; +}); const dirs: string[] = []; afterEach(async () => @@ -192,3 +205,38 @@ test("staging does not hash an unchanged artifact again", async () => { expect(hashes() - start).toBe(firstStage - 1); expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); }); + +test.skipIf(process.platform === "win32")( + "staging reads an artifact's bytes from the file whose identity it checks", + async () => { + const f = await packFixture(); + // The artifact path is a symbolic link to the verified file. + const real = f.object + ".real"; + renameSync(f.object, real); + symlinkSync(real, f.object); + const selection = f.select(); + f.stage(selection); + // During the next read the link names another file of the same size, + // and it names the verified file again before the read ends. + const other = f.object + ".other"; + writeFileSync(other, "x".repeat(statSync(real).size)); + const read = vi.mocked(readFileSync); + const original = read.getMockImplementation()!; + read.mockImplementation(((source: Parameters[0], ...rest: unknown[]) => { + if (typeof source === "string" && !source.endsWith("base.o")) + return (original as (...args: unknown[]) => unknown)(source, ...rest); + read.mockImplementation(original); + unlinkSync(f.object); + symlinkSync(other, f.object); + try { + return (original as (...args: unknown[]) => unknown)(source, ...rest); + } finally { + unlinkSync(f.object); + symlinkSync(real, f.object); + } + }) as typeof readFileSync); + const staged = f.stage(selection); + expect(read.getMockImplementation()).toBe(original); + expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object"); + }, +); diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index f51b24221..0fff34455 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -9,11 +9,13 @@ import { closeSync, constants, existsSync, + fstatSync, mkdirSync, openSync, readFileSync, statSync, writeSync, + type Stats, } from "node:fs"; import { dirname, join } from "node:path"; import type { NativeLinkFeatures } from "./native-link-info.js"; @@ -62,7 +64,10 @@ interface FileIdentity { } function fileIdentity(path: string): FileIdentity { - const info = statSync(path); + return statsIdentity(statSync(path)); +} + +function statsIdentity(info: Stats): FileIdentity { return { dev: info.dev, ino: info.ino, @@ -256,9 +261,16 @@ function stageArtifact( let before: FileIdentity; let after: FileIdentity; try { - before = fileIdentity(source); - bytes = readFileSync(source); - after = fileIdentity(source); + // The identities are those of the open file the bytes come from, also + // when the path is a symbolic link that changes during the read. + const input = openSync(source, constants.O_RDONLY); + try { + before = statsIdentity(fstatSync(input)); + bytes = readFileSync(input); + after = statsIdentity(fstatSync(input)); + } finally { + closeSync(input); + } } catch { throw new RuntimePackError(`runtime pack artifact is missing: ${artifact.path}`, "invalid"); }