Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 46 additions & 45 deletions src/codex/cli-install-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
readSync,
realpathSync,
statSync,
type Stats,
type BigIntStats,
} from "node:fs";
import { sameFileIdentity, statTimeMs } from "../lib/file-identity";
import { posix, win32 } from "node:path";
import { getConfigDir } from "../config";
import { parseStrictSemver } from "../lib/strict-semver";
Expand Down Expand Up @@ -503,10 +504,10 @@ function readBoundedFile(
// injected stat/read behavior.
const inspectLexical = deps.lstat
?? (deps.stat === undefined && deps.readFile === undefined ? lstatSync : null);
let lexicalBefore: ReturnType<typeof lstatSync> | null = null;
let lexicalBefore: BigIntStats | null = null;
if (inspectLexical) {
try {
lexicalBefore = inspectLexical(path);
lexicalBefore = inspectLexical(path, { bigint: true });
if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) return null;
} catch {
return null;
Expand All @@ -520,9 +521,9 @@ function readBoundedFile(
? fsConstants.O_RDONLY
: fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK;
fd = openSync(path, flags);
const before = fstatSync(fd);
const before = fstatSync(fd, { bigint: true });
if (!before.isFile() || before.size > maxBytes) return null;
const bytes = Buffer.allocUnsafe(before.size);
const bytes = Buffer.allocUnsafe(Number(before.size));
let offset = 0;
while (offset < bytes.length) {
const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
Expand All @@ -531,16 +532,16 @@ function readBoundedFile(
}
const extra = Buffer.allocUnsafe(1);
if (readSync(fd, extra, 0, 1, offset) !== 0) return null;
const after = fstatSync(fd);
const lexicalAfter = inspectLexical ? inspectLexical(path) : null;
const after = fstatSync(fd, { bigint: true });
const lexicalAfter = inspectLexical ? inspectLexical(path, { bigint: true }) : null;
if (lexicalAfter?.isSymbolicLink()) return null;
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
|| before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs
if (!sameFileIdentity(before, after) || before.size !== after.size
|| statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs)
|| (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink()
|| lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino
|| lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino
|| lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs
|| lexicalAfter.ctimeMs !== after.ctimeMs))) return null;
|| !sameFileIdentity(lexicalBefore, before)
|| !sameFileIdentity(lexicalAfter, after)
|| lexicalAfter.size !== after.size || statTimeMs(lexicalAfter.mtimeNs) !== statTimeMs(after.mtimeNs)
|| statTimeMs(lexicalAfter.ctimeNs) !== statTimeMs(after.ctimeNs)))) return null;
return bytes;
} catch {
return null;
Expand All @@ -552,20 +553,19 @@ function readBoundedFile(
const stat = deps.stat;
const read = deps.readFile;
try {
const before = stat(path);
const before = stat(path, { bigint: true });
if (!before.isFile() || before.size > maxBytes) return null;
const bytes = read(path);
const after = stat(path);
const lexicalAfter = inspectLexical ? inspectLexical(path) : null;
const after = stat(path, { bigint: true });
const lexicalAfter = inspectLexical ? inspectLexical(path, { bigint: true }) : null;
if (
bytes.byteLength !== before.size
|| before.dev !== after.dev
|| before.ino !== after.ino
BigInt(bytes.byteLength) !== before.size
|| !sameFileIdentity(before, after)
|| before.size !== after.size
|| before.mtimeMs !== after.mtimeMs
|| statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs)
|| (lexicalBefore !== null && (lexicalAfter === null || lexicalAfter.isSymbolicLink()
|| lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino
|| lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino))
|| !sameFileIdentity(lexicalBefore, before)
|| !sameFileIdentity(lexicalAfter, after)))
) return null;
return bytes;
} catch {
Expand Down Expand Up @@ -830,16 +830,16 @@ function observeHeldFileSet(
const read = deps.readFile;
if (!stat || !read) return null;
const results = new Map<string, ObservedContentFile>();
const identities: { before: Stats; req: InstallContentFile }[] = [];
const identities: { before: BigIntStats; req: InstallContentFile }[] = [];
try {
for (const req of requests) {
const before = stat(req.path);
const before = stat(req.path, { bigint: true });
if (!before.isFile() || before.size > req.maxBytes) return null;
const bytes = read(req.path);
const after = stat(req.path);
if (bytes.byteLength !== before.size
|| before.dev !== after.dev || before.ino !== after.ino
|| before.size !== after.size || before.mtimeMs !== after.mtimeMs) return null;
const after = stat(req.path, { bigint: true });
if (BigInt(bytes.byteLength) !== before.size
|| !sameFileIdentity(before, after)
|| before.size !== after.size || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs)) return null;
identities.push({ before, req });
results.set(req.role, {
digest: sha256(fileContentDomain(req.role), bytes),
Expand All @@ -848,17 +848,17 @@ function observeHeldFileSet(
}
// A replacement that slipped in after a file's own read still failed.
for (const { before, req } of identities) {
const now = stat(req.path);
if (before.dev !== now.dev || before.ino !== now.ino
|| before.size !== now.size || before.mtimeMs !== now.mtimeMs) return null;
const now = stat(req.path, { bigint: true });
if (!sameFileIdentity(before, now)
|| before.size !== now.size || statTimeMs(before.mtimeNs) !== statTimeMs(now.mtimeNs)) return null;
}
if (finalize && !finalize()) return null;
return results;
} catch {
return null;
}
}
const held: { fd: number; req: InstallContentFile; before: Stats }[] = [];
const held: { fd: number; req: InstallContentFile; before: BigIntStats }[] = [];
const openedFds: number[] = [];
try {
const flags = process.platform === "win32"
Expand All @@ -870,7 +870,7 @@ function observeHeldFileSet(
// The descriptor joins the close set before any check that can bail, so
// an fstat failure or a refused type/size cannot leak it.
openedFds.push(fd);
const before = fstatSync(fd);
const before = fstatSync(fd, { bigint: true });
// O_NONBLOCK keeps a FIFO open nonblocking, so the type check must come
// first: anything that is not a regular file is refused before a read.
if (!before.isFile() || before.size > req.maxBytes) return null;
Expand All @@ -881,11 +881,12 @@ function observeHeldFileSet(
const hasher = createHash("sha256")
.update(fileContentDomain(req.role), "utf8")
.update("\0", "utf8");
const bytes = keepBytes(req.role) ? Buffer.allocUnsafe(before.size) : null;
const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(HELD_READ_CHUNK_BYTES, before.size)));
const size = Number(before.size);
const bytes = keepBytes(req.role) ? Buffer.allocUnsafe(size) : null;
const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(HELD_READ_CHUNK_BYTES, size)));
let offset = 0;
while (offset < before.size) {
const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - offset), offset);
while (offset < size) {
const count = readSync(fd, chunk, 0, Math.min(chunk.length, size - offset), offset);
if (count <= 0) return null;
hasher.update(chunk.subarray(0, count));
if (bytes) chunk.copy(bytes, offset, 0, count);
Expand All @@ -896,15 +897,15 @@ function observeHeldFileSet(
results.set(req.role, { digest: hasher.digest("hex"), bytes });
}
for (const { fd, req, before } of held) {
const after = fstatSync(fd);
const lexical = lstatSync(req.path);
const followed = statSync(req.path);
if (before.dev !== after.dev || before.ino !== after.ino
|| before.size !== after.size || before.mtimeMs !== after.mtimeMs
|| before.ctimeMs !== after.ctimeMs
const after = fstatSync(fd, { bigint: true });
const lexical = lstatSync(req.path, { bigint: true });
const followed = statSync(req.path, { bigint: true });
if (!sameFileIdentity(before, after)
|| before.size !== after.size || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs)
|| statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs)
|| lexical.isSymbolicLink() || !lexical.isFile()
|| lexical.dev !== before.dev || lexical.ino !== before.ino
|| followed.dev !== before.dev || followed.ino !== before.ino) return null;
|| !sameFileIdentity(lexical, before)
|| !sameFileIdentity(followed, before)) return null;
}
if (finalize && !finalize()) return null;
return results;
Expand Down
14 changes: 8 additions & 6 deletions src/codex/cli-update-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { parseStrictSemver } from "../lib/strict-semver";
import { sameFileIdentity, statTimeMs } from "../lib/file-identity";
import type { BigIntStats } from "node:fs";
import { codexCliContentFileDigest, codexCliContentSetDigest, codexCliPlatformBacking } from "./cli-install-provenance";
import { windowsArchivePathValidator, WINDOWS_CODEX_HEADER_BYTES, WINDOWS_CODEX_NATIVE_MEMBER } from "./cli-update-windows-paths";

Expand Down Expand Up @@ -105,10 +107,10 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod
options: { windowsX64?: boolean } = {}): Promise<CodexArchiveFiles> {
if (!isSha512Integrity(target.integrity)) throw new Error("artifact_integrity_mismatch");
let fd: number | undefined;
let before: ReturnType<typeof fstatSync> | undefined;
let before: BigIntStats | undefined;
if (typeof source === "string") {
fd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try { before = fstatSync(fd); } catch (error) { closeSync(fd); throw error; }
try { before = fstatSync(fd, { bigint: true }); } catch (error) { closeSync(fd); throw error; }
if (!before.isFile() || before.size > MAX_CODEX_ARCHIVE_BYTES) { closeSync(fd); throw new Error("artifact_file_unverified"); }
}
let input: Readable;
Expand Down Expand Up @@ -208,10 +210,10 @@ export async function verifyCodexCliArchive(source: Buffer | string, target: Cod
if (!ended || trailer < 512 || current || padding || headerSize) throw new Error("artifact_tar_truncated");
if ("sha512-" + archiveHash.digest("base64") !== target.integrity) throw new Error("artifact_integrity_mismatch");
if (fd !== undefined && before && typeof source === "string") {
const after = fstatSync(fd), lexical = lstatSync(source);
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
|| before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || lexical.isSymbolicLink()
|| lexical.dev !== before.dev || lexical.ino !== before.ino) throw new Error("artifact_file_changed");
const after = fstatSync(fd, { bigint: true }), lexical = lstatSync(source, { bigint: true });
if (!sameFileIdentity(before, after) || before.size !== after.size
|| statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs) || lexical.isSymbolicLink()
|| !sameFileIdentity(lexical, before)) throw new Error("artifact_file_changed");
}
verified = true;
} finally {
Expand Down
48 changes: 44 additions & 4 deletions tests/codex-integration/codex-cli-install-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,13 +696,37 @@ describe("Codex CLI install provenance", () => {
});

describe("install content binding", () => {
test("a manifest path identity change above numeric precision refuses", async () => {
const fs = virtualManagedNpmGlobal();
let changed = false;
const deps = virtualInspectionDeps(fs, {
lstat: (path: string, options?: { bigint?: boolean }) => {
const value = fs.lstat(path, options) as object;
return path === VIRTUAL_MANIFEST
? { ...value, ino: options?.bigint ? 9007199254740992n + BigInt(changed) : 9007199254740992 }
: value;
},
stat: (path: string, options?: { bigint?: boolean }) => {
const value = fs.stat(path, options) as object;
return path === VIRTUAL_MANIFEST ? { ...value, ino: options?.bigint ? 9007199254740992n : 9007199254740992 } : value;
},
readFile: (path: string) => {
const value = fs.readFile(path);
if (path === VIRTUAL_MANIFEST) changed = true;
return value;
},
});
expect((await inspectCodexCliInstall(deps)).managed).toBe(false);
expect(changed).toBe(true);
});

test("an ownership-only read never opens the backing bytes", async () => {
const fs = virtualManagedNpmGlobal();
const probed: string[] = [];
const deps = virtualInspectionDeps(fs, {
lstat: (path: string) => {
lstat: (path: string, options?: { bigint?: boolean }) => {
if (path.includes("@openai/codex-")) probed.push(path);
return fs.lstat(path);
return fs.lstat(path, options);
},
readFile: (path: string) => {
if (path === VIRTUAL_BACKING_BINARY) {
Expand Down Expand Up @@ -795,8 +819,8 @@ describe("install content binding", () => {
const stat = fs.stat;
const deps = virtualInspectionDeps(fs, {
observeInstallContent: true,
stat: (path: string) => {
const result = stat(path);
stat: (path: string, options?: { bigint?: boolean }) => {
const result = stat(path, options);
if (path === VIRTUAL_BACKING_BINARY) {
backingStats.count += 1;
if (backingStats.count === 1) fs.bumpIdentity(path);
Expand All @@ -808,6 +832,22 @@ describe("install content binding", () => {
expect(report.installContentDigest).toBeNull();
});

test("a backing identity change above numeric precision refuses", async () => {
const fs = virtualManagedNpmGlobal();
let count = 0;
const deps = virtualInspectionDeps(fs, {
observeInstallContent: true,
stat: (path: string, options?: { bigint?: boolean }) => {
const value = fs.stat(path, options) as object;
if (path !== VIRTUAL_BACKING_BINARY) return value;
const changed = count++ > 0;
return { ...value, ino: options?.bigint ? 9007199254740992n + BigInt(changed) : 9007199254740992 };
},
});
expect((await inspectCodexCliInstall(deps)).installContentDigest).toBeNull();
expect(count).toBeGreaterThan(1);
});

test("the package-vendor fallback binds when no resolve hit exists and globals are fully probed", async () => {
const fs = virtualPosixFs({
[VIRTUAL_LAUNCHER]: { link: VIRTUAL_ENTRYPOINT },
Expand Down
17 changes: 17 additions & 0 deletions tests/codex-integration/codex-cli-update-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ describe("Codex CLI apply artifacts", () => {
const f = fixtureArtifactSet(); const changed = Buffer.from(f.main); changed[changed.length - 1] ^= 1;
await expect(verifyCodexCliArchive(changed, f.artifacts.main)).rejects.toThrow();
});
test("rejects an archive path identity differing above numeric precision", async () => {
const root = createCodexCliTestRoot("ocx-archive-identity-");
const f = fixtureArtifactSet(), path = join(root, "artifact.tgz");
writeFileSync(path, f.main);
const fstat = fs.fstatSync, lstat = fs.lstatSync;
const opened = spyOn(fs, "fstatSync").mockImplementation(((fd: number, options?: { bigint?: boolean }) => {
const value = fstat(fd, options as never);
return { ...value, isFile: () => value.isFile(), dev: options?.bigint ? 42n : 42, ino: options?.bigint ? 9007199254740992n : 9007199254740992 };
}) as typeof fs.fstatSync);
const lexical = spyOn(fs, "lstatSync").mockImplementation(((name: fs.PathLike, options?: { bigint?: boolean }) => {
const value = lstat(name, options as never);
return { ...value, isSymbolicLink: () => value.isSymbolicLink(), dev: options?.bigint ? 42n : 42, ino: options?.bigint ? 9007199254740993n : 9007199254740992 };
}) as typeof fs.lstatSync);
try {
await expect(verifyCodexCliArchive(path, f.artifacts.main)).rejects.toThrow("artifact_file_changed");
} finally { opened.mockRestore(); lexical.mockRestore(); removeTreeWithRetry(root); }
});
test.each(["valid", "corrupt"] as const)("real-file archive %s closes its stream before return and preserves a reused sentinel fd", async kind => {
const root = createCodexCliTestRoot("ocx-archive-lifetime-");
const f = fixtureArtifactSet(), path = join(root, "artifact.tgz"), sentinelPath = join(root, "sentinel");
Expand Down
14 changes: 9 additions & 5 deletions tests/helpers/codex-cli-apply-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ const lstat = fs.lstatSync, uid = process.geteuid?.() ?? 1234;
const originalUid = Object.getOwnPropertyDescriptor(process, "geteuid");
let barrier = false;
Object.defineProperty(process, "geteuid", { configurable: true, value: () => uid });
const stat = spyOn(fs, "lstatSync").mockImplementation(((path: fs.PathLike) => {
const value = lstat(path);
return value.isDirectory() ? Object.assign(value, { uid,
mode: 0o40700 | (path === temporaryRoot && lane === "unsafe-stage" ? 0o002 : 0)
| (barrier && path === mutationDirectory && lane === "unsafe-install" ? 0o020 : 0) }) : value;
// Honor the options argument: identity checks request bigint stats.
const stat = spyOn(fs, "lstatSync").mockImplementation(((path: fs.PathLike, options?: fs.StatOptions) => {
const value = lstat(path, options as fs.StatSyncOptions & { bigint: false });
if (!value?.isDirectory()) return value;
const mode = 0o40700 | (path === temporaryRoot && lane === "unsafe-stage" ? 0o002 : 0)
| (barrier && path === mutationDirectory && lane === "unsafe-install" ? 0o020 : 0);
return options?.bigint
? Object.assign(value, { uid: BigInt(uid), mode: BigInt(mode) })
: Object.assign(value, { uid, mode });
}) as typeof fs.lstatSync);
let arms = 0, unavailable = false;
const recoveryTimers: (() => void)[] = [];
Expand Down
Loading
Loading