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.
This commit is contained in:
Chris Tate
2026-10-24 16:05:19 -05:00
parent 32752f768e
commit 1d579ea5a2
2 changed files with 65 additions and 5 deletions
@@ -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<typeof import("node:crypto")>();
return { ...crypto, createHash: vi.fn(crypto.createHash) };
});
vi.mock("node:fs", async (original) => {
const fs = await original<typeof import("node:fs")>();
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<typeof readFileSync>[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");
},
);
@@ -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");
}