From ad078b0d5ec096a1fca9e41478c3274d4403a966 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:33:49 +0900 Subject: [PATCH 1/3] fix: compare Codex and remote workspace file identity with bigint stat NTFS file IDs can exceed 2^53, where adjacent IDs are equal as JS numbers. Take dev/ino from bigint stats in the Codex home, coordinator, main-account, native-main, residue, history, shim runtime and remote-workspace identity checks. Persisted shim state keeps its numeric fields. --- src/codex/codex-home-owner.ts | 17 ++--- src/codex/coordinator-doctor.ts | 22 +++--- src/codex/history-provider.ts | 21 +++--- src/codex/inject/bounded-config-reader.ts | 27 ++++---- src/codex/main-account.ts | 12 ++-- src/codex/native-main-lock-file.ts | 25 +++---- src/codex/native-residue.ts | 32 +++++---- src/codex/orca-auth-source.ts | 15 ++-- src/codex/shim-fingerprint.ts | 20 +++--- src/codex/shim-restore-lock.ts | 25 +++---- src/codex/shim-state-file.ts | 36 ++++++---- src/codex/transition-state.ts | 9 +-- src/remote-control/workspace-executor.ts | 27 ++++---- tests/clients/remote-workspace.test.ts | 48 ++++++++++++- .../codex-coordinator-doctor.test.ts | 20 +++++- .../codex-history-provider.test.ts | 50 +++++++++++++- .../codex-main-account-refresh.test.ts | 22 +++++- .../codex-native-residue.test.ts | 21 +++++- .../codex-shim-windows-revision.test.ts | 68 ++++++++++++++++++- .../codex-transition-state.test.ts | 22 +++++- .../native-main-owner-lifetime.test.ts | 31 ++++++++- tests/lib/file-identity-callers.test.ts | 27 +++++++- tests/lib/file-identity.test.ts | 38 ++++++++++- 23 files changed, 491 insertions(+), 144 deletions(-) diff --git a/src/codex/codex-home-owner.ts b/src/codex/codex-home-owner.ts index 75533d99b71..bd1816a00c6 100644 --- a/src/codex/codex-home-owner.ts +++ b/src/codex/codex-home-owner.ts @@ -1,6 +1,7 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config/paths"; +import { sameFileIdentity } from "../lib/file-identity"; import { samePathIdentity } from "./user-identity"; export const CODEX_HOME_JOURNAL_FILE = "opencodex-journal.json"; @@ -55,21 +56,21 @@ function validJournal(value: unknown): value is Record { export function readCodexHomeJournal(journalPath: string): CodexHomeJournalInspection { let fd: number | undefined; try { - const before = lstatSync(journalPath); - if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_JOURNAL_BYTES) return { kind: "unknown" }; + const before = lstatSync(journalPath, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(MAX_JOURNAL_BYTES)) return { kind: "unknown" }; fd = openSync(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); - const opened = fstatSync(fd); - if (!opened.isFile() || opened.size > MAX_JOURNAL_BYTES || before.dev !== opened.dev || before.ino !== opened.ino) return { kind: "unknown" }; - const bytes = Buffer.alloc(opened.size + 1); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > BigInt(MAX_JOURNAL_BYTES) || !sameFileIdentity(before, opened)) return { kind: "unknown" }; + const bytes = Buffer.alloc(Number(opened.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } - const after = lstatSync(journalPath); - if (length !== opened.size || !after.isFile() || after.dev !== opened.dev || after.ino !== opened.ino - || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return { kind: "unknown" }; + const after = lstatSync(journalPath, { bigint: true }); + if (BigInt(length) !== opened.size || !after.isFile() || !sameFileIdentity(after, opened) + || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) return { kind: "unknown" }; const journal: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); return validJournal(journal) ? { kind: "read", journal } : { kind: "unknown" }; } catch (error) { diff --git a/src/codex/coordinator-doctor.ts b/src/codex/coordinator-doctor.ts index 1c238bd922b..ef53b2d0662 100644 --- a/src/codex/coordinator-doctor.ts +++ b/src/codex/coordinator-doctor.ts @@ -12,11 +12,13 @@ import { realpathSync, renameSync, type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { Database, constants as sqliteConstants } from "bun:sqlite"; +import { sameFileIdentity, type FileIdentity as NodeIdentity } from "../lib/file-identity"; import { resolveCodexHomeDir } from "./home"; import { @@ -33,7 +35,11 @@ import { const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI; -export type FileIdentity = Pick; +export type FileIdentity = NodeIdentity & Pick; + +function milliseconds(ns: bigint): number { + return Number(ns / 1_000_000_000n) * 1000 + Number(ns % 1_000_000_000n) / 1e6; +} export interface CodexCoordinatorDiagnosticEvidence { sizeBytes: number; @@ -66,15 +72,14 @@ function errorCode(error: unknown): string { } function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { - return left.dev === right.dev - && left.ino === right.ino + return sameFileIdentity(left, right) && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; } function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean { - return left.dev === right.dev && left.ino === right.ino && left.size === right.size; + return sameFileIdentity(left, right) && left.size === right.size; } function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } { @@ -113,9 +118,9 @@ function inspectTarget( path: string, options: { allowSqliteSidecars?: boolean } = {}, ): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } { - let entry: Stats; + let entry: BigIntStats; try { - entry = lstatSync(path); + entry = lstatSync(path, { bigint: true }); } catch (cause) { if (errorCode(cause) === "ENOENT") return { kind: "absent" }; return { kind: "unsafe", reason: "the coordinator file cannot be inspected" }; @@ -132,7 +137,7 @@ function inspectTarget( } if (process.platform !== "win32") { const uid = process.getuid?.(); - if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) { + if (uid === undefined || Number(entry.uid) !== uid || (Number(entry.mode) & 0o777) !== 0o600) { return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" }; } } @@ -143,7 +148,8 @@ function inspectTarget( } } } - return { kind: "file", identity: entry }; + return { kind: "file", identity: { dev: entry.dev, ino: entry.ino, size: Number(entry.size), + mtimeMs: milliseconds(entry.mtimeNs), ctimeMs: milliseconds(entry.ctimeNs) } }; } function classifyOpenedDatabase( diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index e02edf40964..db57a0f3c17 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -7,6 +7,7 @@ import { Database } from "bun:sqlite"; import { resolveCodexStateDbPath } from "./paths"; import { openCodexStateForPreflight } from "./history-state-open"; import { atomicWriteFile, getConfigDir } from "../config"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { CODEX_HISTORY_RESUMABLE_SOURCES, codexHistoryBackupId, @@ -268,15 +269,15 @@ function readFirstLineProviderValue(path: string, expectedId: string): string | function patchFirstLineProviderInPlace( path: string, expectedId: string, provider: string, - expectedIdentity: { dev: number | bigint; ino: number | bigint }, + expectedIdentity: FileIdentity, ): FirstLineProviderResult { historyAppendHooks?.beforeFirstLineOpen?.(path); const fd = openSync(path, "r+"); try { const assertIdentity = (): void => { assertHistoryDescriptorIdentity(path, fd); - const held = fstatSync(fd); - if (held.dev !== expectedIdentity.dev || held.ino !== expectedIdentity.ino) { + const held = fstatSync(fd, { bigint: true }); + if (!sameFileIdentity(held, expectedIdentity)) { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } }; @@ -360,10 +361,10 @@ function assertLegacyHistoryRecord(line: string): void { } function assertHistoryDescriptorIdentity(path: string, fd: number): void { - const held = fstatSync(fd); - let current: ReturnType; - try { current = lstatSync(path); } catch { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } - if (!current.isFile() || current.isSymbolicLink() || current.dev !== held.dev || current.ino !== held.ino) { + const held = fstatSync(fd, { bigint: true }); + let current: import("node:fs").BigIntStats; + try { current = lstatSync(path, { bigint: true }); } catch { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } + if (!current.isFile() || current.isSymbolicLink() || !sameFileIdentity(current, held)) { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } } @@ -693,8 +694,8 @@ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupIn function historyFileIdentity(path: string): string | null { try { - const stat = statSync(path); - return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); + const stat = statSync(path, { bigint: true }); + return [stat.dev, stat.ino, Number(stat.size), Number(stat.mtimeNs) / 1e6, Number(stat.ctimeNs) / 1e6].join(":"); } catch { return null; } @@ -1132,7 +1133,7 @@ function updateSessionMeta( } = {}, ): SessionMetaUpdateResult { if (!path || !existsSync(path)) return { changed: false, durableProvider: false }; - const validatedIdentity = lstatSync(path); + const validatedIdentity = lstatSync(path, { bigint: true }); if (options.expectedFileIdentity !== undefined && historyFileIdentity(path) !== options.expectedFileIdentity) { return { changed: false, durableProvider: false, conflict: true }; diff --git a/src/codex/inject/bounded-config-reader.ts b/src/codex/inject/bounded-config-reader.ts index 78a7ce95030..ffab48e9da5 100644 --- a/src/codex/inject/bounded-config-reader.ts +++ b/src/codex/inject/bounded-config-reader.ts @@ -1,4 +1,5 @@ -import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, readSync, statSync, type BigIntStats } from "node:fs"; +import { sameFileIdentity } from "../../lib/file-identity"; const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; @@ -14,15 +15,15 @@ const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; export function readBoundedCodexConfig(path: string): string | null { let fd: number | undefined; try { - let namedBefore: Stats; + let namedBefore: BigIntStats; try { - namedBefore = statSync(path); + namedBefore = statSync(path, { bigint: true }); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === "ENOTDIR") return null; throw error; } - if (!namedBefore.isFile() || namedBefore.size > MAX_CODEX_CONFIG_BYTES) { + if (!namedBefore.isFile() || namedBefore.size > BigInt(MAX_CODEX_CONFIG_BYTES)) { throw new Error("config.toml is not a bounded regular file"); } // Deliberately no O_NOFOLLOW: Codex and the injector read through a symlinked @@ -33,27 +34,27 @@ export function readBoundedCodexConfig(path: string): string | null { ? 0 : (constants.O_NONBLOCK ?? 0); fd = openSync(path, constants.O_RDONLY | guardedFlags); - const before = fstatSync(fd); - if (before.dev !== namedBefore.dev || before.ino !== namedBefore.ino) { + const before = fstatSync(fd, { bigint: true }); + if (!sameFileIdentity(before, namedBefore)) { throw new Error("config.toml changed while it was read"); } - if (!before.isFile() || before.size > MAX_CODEX_CONFIG_BYTES) { + if (!before.isFile() || before.size > BigInt(MAX_CODEX_CONFIG_BYTES)) { throw new Error("config.toml is not a bounded regular file"); } - const buffer = Buffer.allocUnsafe(before.size + 1); + const buffer = Buffer.allocUnsafe(Number(before.size) + 1); let bytesRead = 0; while (bytesRead < buffer.length) { const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null); if (count === 0) break; bytesRead += count; } - const after = fstatSync(fd); - const namedAfter = statSync(path); - if (bytesRead !== before.size || after.size !== before.size - || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs + const after = fstatSync(fd, { bigint: true }); + const namedAfter = statSync(path, { bigint: true }); + if (BigInt(bytesRead) !== before.size || after.size !== before.size + || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs || !namedAfter.isFile() - || namedAfter.dev !== before.dev || namedAfter.ino !== before.ino) { + || !sameFileIdentity(namedAfter, before)) { throw new Error("config.toml changed while it was read"); } return buffer.toString("utf8", 0, bytesRead); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 50137e447dd..1d687c3c66d 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -4,6 +4,7 @@ import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chat import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; import { lstatSync, readFileSync, statSync } from "node:fs"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { dirname, join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; import { @@ -86,7 +87,7 @@ type MainAuthJsonCredential = { * the content hash notices those, and only once the new bytes are visible (#2999). * `null` when the target could not be stat'ed, which fails closed. */ - identity: { dev: number; ino: number } | null; + identity: FileIdentity | null; root: Record; tokens: Record; accessToken?: string; @@ -201,10 +202,10 @@ function sha256(value: string): string { * fail closed, because the whole point here is refusing to overwrite a file we can no * longer vouch for. */ -function statIdentity(path: string): { dev: number; ino: number } | null { +function statIdentity(path: string): FileIdentity | null { try { - const stat = statSync(path); - return { dev: Number(stat.dev), ino: Number(stat.ino) }; + const stat = statSync(path, { bigint: true }); + return { dev: stat.dev, ino: stat.ino }; } catch { return null; } @@ -316,8 +317,7 @@ function assertMainAuthJsonIdentityUnchanged(expected: MainAuthJsonCredential): const identity = statIdentity(expected.path); if (!identity || !expected.identity - || identity.dev !== expected.identity.dev - || identity.ino !== expected.identity.ino) { + || !sameFileIdentity(identity, expected.identity)) { throw new MainAuthJsonChangedDuringRefreshError(); } } diff --git a/src/codex/native-main-lock-file.ts b/src/codex/native-main-lock-file.ts index a61490cfab0..a8050881942 100644 --- a/src/codex/native-main-lock-file.ts +++ b/src/codex/native-main-lock-file.ts @@ -6,15 +6,16 @@ import { fstatSync, lstatSync, openSync, - type Stats, + type BigIntStats, } from "node:fs"; import { hardenSecretPathAsync } from "../lib/windows-secret-acl"; +import { sameFileIdentity } from "../lib/file-identity"; export interface StableLockFile { readonly fd: number; - readonly dev: number; - readonly ino: number; + readonly dev: bigint; + readonly ino: bigint; close(): void; } @@ -27,8 +28,8 @@ export class StableLockPathUnsafeError extends Error { interface StableLockEntry { readonly fd: number; - readonly dev: number; - readonly ino: number; + readonly dev: bigint; + readonly ino: bigint; refs: number; } @@ -55,7 +56,7 @@ function reference(path: string, entry: StableLockEntry): StableLockFile { }; } -function assertRegular(stats: Stats, path: string): void { +function assertRegular(stats: BigIntStats, path: string): void { if (!stats.isFile() || stats.isSymbolicLink()) { throw new StableLockPathUnsafeError(`unsafe SQLite lock path: ${path}`); } @@ -67,8 +68,8 @@ function errorCode(error: unknown): string | undefined { : undefined; } -function matches(handle: StableLockFile, stats: Stats): boolean { - return handle.dev === stats.dev && handle.ino === stats.ino; +function matches(handle: StableLockFile, stats: BigIntStats): boolean { + return sameFileIdentity(handle, stats); } export function openStableLockFile(path: string, platform: NodeJS.Platform = process.platform): StableLockFile { @@ -84,11 +85,11 @@ export function openStableLockFile(path: string, platform: NodeJS.Platform = pro throw error; } } - if (existsSync(path)) assertRegular(lstatSync(path), path); + if (existsSync(path)) assertRegular(lstatSync(path, { bigint: true }), path); const noFollow = platform === "win32" ? 0 : constants.O_NOFOLLOW; const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | noFollow, 0o600); try { - const descriptor = fstatSync(fd); + const descriptor = fstatSync(fd, { bigint: true }); assertRegular(descriptor, path); const entry: StableLockEntry = { fd, @@ -109,9 +110,9 @@ export function openStableLockFile(path: string, platform: NodeJS.Platform = pro } export function assertStableLockFile(path: string, handle: StableLockFile): void { - let pathStats: Stats; + let pathStats: BigIntStats; try { - pathStats = lstatSync(path); + pathStats = lstatSync(path, { bigint: true }); } catch (error) { if (errorCode(error) === "ENOENT") { throw new StableLockPathUnsafeError(`SQLite lock path identity changed: ${path}`); diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index a8cd47f58fe..c56ffe1cc31 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -10,7 +10,8 @@ import { readSync, statSync, } from "node:fs"; -import type { Stats } from "node:fs"; +import type { BigIntStats } from "node:fs"; +import { sameFileIdentity } from "../lib/file-identity"; import { basename, dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -57,7 +58,7 @@ type ReadResult = type PathResult = | { kind: "absent" } - | { kind: "path"; path: string; stat: Stats } + | { kind: "path"; path: string; stat: BigIntStats } | { kind: "indeterminate"; reason: string }; type CatalogTarget = { @@ -95,14 +96,17 @@ function errorReason(error: unknown): string { return String(error); } +function milliseconds(ns: bigint): number { + return Number(ns / 1_000_000_000n) * 1000 + Number(ns % 1_000_000_000n) / 1e6; +} + function sameStat( - left: Stats, - right: Stats, + left: BigIntStats, + right: BigIntStats, ): boolean { - return left.dev === right.dev - && left.ino === right.ino - && left.size === right.size - && left.mtimeMs === right.mtimeMs; + return sameFileIdentity(left, right) + && Number(left.size) === Number(right.size) + && milliseconds(left.mtimeNs) === milliseconds(right.mtimeNs); } function resolveRegularFile(path: string): PathResult { @@ -124,7 +128,7 @@ function resolveRegularFile(path: string): PathResult { } try { - const before = statSync(target); + const before = statSync(target, { bigint: true }); if (!before.isFile()) { return { kind: "indeterminate", reason: "surface is not a regular file" }; } @@ -141,12 +145,12 @@ function readRegularFile(path: string): ReadResult { // depend on who ran the suite. No read bit means the configured surface is // operationally unreadable to an ordinary Codex process and must remain // indeterminate even when the inspector itself has elevated privileges. - if (process.platform !== "win32" && (resolved.stat.mode & 0o444) === 0) { + if (process.platform !== "win32" && (Number(resolved.stat.mode) & 0o444) === 0) { return { kind: "indeterminate", reason: "EACCES: surface has no read permission bits" }; } try { const content = readFileSync(resolved.path, "utf8"); - const after = statSync(resolved.path); + const after = statSync(resolved.path, { bigint: true }); if (!sameStat(resolved.stat, after)) { return { kind: "indeterminate", reason: "surface changed while it was being observed" }; } @@ -497,11 +501,11 @@ function classifyReferencedRollout( latest = payload.payload; } } - const after = fstatSync(handle); + const after = fstatSync(handle, { bigint: true }); if (!sameStat(resolved.stat, after)) { return indeterminate(surface, resolved.path, "rollout changed while it was being observed"); } - const pathAfter = statSync(resolved.path); + const pathAfter = statSync(resolved.path, { bigint: true }); if (!sameStat(resolved.stat, pathAfter)) { return indeterminate(surface, resolved.path, "rollout pathname was replaced while it was being observed"); } @@ -591,7 +595,7 @@ function classifyHistoryDatabase(path: string, budget: RolloutInspectionBudget): budget, ); if (rollouts.kind !== "clean") return rollouts; - const after = statSync(resolved.path); + const after = statSync(resolved.path, { bigint: true }); if (!sameStat(resolved.stat, after)) { return indeterminate("history", resolved.path, "history database changed while it was being observed"); } diff --git a/src/codex/orca-auth-source.ts b/src/codex/orca-auth-source.ts index 55b5ea37cfc..3f49a74bd04 100644 --- a/src/codex/orca-auth-source.ts +++ b/src/codex/orca-auth-source.ts @@ -1,5 +1,6 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path"; +import { sameFileIdentity } from "../lib/file-identity"; const MAX_AUTH_BYTES = 256 * 1024; export const ORCA_ACCOUNT_DIRECTORY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -23,27 +24,27 @@ export function assertPlainLocalPath(path: string): string { export function readBoundedLocalFile(path: string, limit = MAX_AUTH_BYTES): string { assertPlainLocalPath(path); - const before = lstatSync(path); + const before = lstatSync(path, { bigint: true }); if (!before.isFile() || before.size > limit) throw new Error("Invalid local credential file."); const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); try { - const opened = fstatSync(fd); - if (!opened.isFile() || opened.size > limit || before.dev !== opened.dev || before.ino !== opened.ino) { + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > limit || !sameFileIdentity(before, opened)) { throw new Error("Local credential file changed."); } // The cap bounds the read, not the allocation: tiny registry files should not // allocate 32 MiB on each preview/recheck. One extra byte detects concurrent growth. - const bytes = Buffer.alloc(opened.size + 1); + const bytes = Buffer.alloc(Number(opened.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } - if (length !== opened.size) throw new Error("Local credential file changed."); + if (BigInt(length) !== opened.size) throw new Error("Local credential file changed."); assertPlainLocalPath(path); - const after = lstatSync(path); - if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) { + const after = lstatSync(path, { bigint: true }); + if (!sameFileIdentity(after, opened) || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs) { throw new Error("Local credential file changed."); } return bytes.subarray(0, length).toString("utf8"); diff --git a/src/codex/shim-fingerprint.ts b/src/codex/shim-fingerprint.ts index 51fbb5cf043..98c0dfea37e 100644 --- a/src/codex/shim-fingerprint.ts +++ b/src/codex/shim-fingerprint.ts @@ -13,6 +13,7 @@ import { unlinkSync, } from "node:fs"; import { posix, win32 } from "node:path"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates"; import type { ShimFileState } from "./shim-state-file"; @@ -21,6 +22,7 @@ const CODEX_SHIM_PROBE_BYTES = 16 * 1024; interface ShimPathFingerprint { dev: number; ino: number; + identity: FileIdentity; kind: "file" | "symlink"; mode: number; size: number; @@ -48,16 +50,17 @@ function readShimProbePrefix(path: string): string { function statFingerprint(path: string, follow: boolean): Omit | null { try { - const stat = follow ? statSync(path) : lstatSync(path); + const stat = follow ? statSync(path, { bigint: true }) : lstatSync(path, { bigint: true }); if (follow ? !stat.isFile() : (!stat.isFile() && !stat.isSymbolicLink())) return null; return { - dev: stat.dev, - ino: stat.ino, + dev: Number(stat.dev), + ino: Number(stat.ino), + identity: { dev: stat.dev, ino: stat.ino }, kind: stat.isSymbolicLink() ? "symlink" : "file", - mode: stat.mode, - size: stat.size, - mtimeMs: stat.mtimeMs, - ctimeMs: stat.ctimeMs, + mode: Number(stat.mode), + size: Number(stat.size), + mtimeMs: Number(stat.mtimeNs) / 1e6, + ctimeMs: Number(stat.ctimeNs) / 1e6, }; } catch { return null; @@ -68,8 +71,7 @@ function sameFingerprint( left: ShimPathFingerprint | Omit, right: ShimPathFingerprint | Omit, ): boolean { - return left.dev === right.dev - && left.ino === right.ino + return sameFileIdentity(left.identity, right.identity) && left.kind === right.kind && left.mode === right.mode && left.size === right.size diff --git a/src/codex/shim-restore-lock.ts b/src/codex/shim-restore-lock.ts index 3ee2e843acf..27d1eae8a78 100644 --- a/src/codex/shim-restore-lock.ts +++ b/src/codex/shim-restore-lock.ts @@ -10,11 +10,12 @@ import { rmdirSync, unlinkSync, writeFileSync, - type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; import { isProcessAlive } from "../lib/process-control"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { sameFingerprint, stableShimPathProbe, type ShimPathFingerprint } from "./shim-fingerprint"; import { fileErrorCode } from "./shim-state-file"; @@ -34,7 +35,7 @@ interface ShimRestoreLockRecord { interface ShimRestoreLockSnapshot { record: ShimRestoreLockRecord; ownerPath: string; - lockIdentity: Pick; + lockIdentity: FileIdentity; fingerprint: ShimPathFingerprint; } @@ -42,15 +43,11 @@ function restoreLockPath(): string { return join(getConfigDir(), "codex-shim.autorestore.lock"); } -function sameFileIdentity(left: Pick, right: Pick): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | null { - let lockIdentity: Stats; + let lockIdentity: BigIntStats; let entries: string[]; try { - lockIdentity = lstatSync(path); + lockIdentity = lstatSync(path, { bigint: true }); if (!lockIdentity.isDirectory()) return null; entries = readdirSync(path); } catch { @@ -66,7 +63,7 @@ function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | nu || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; if (entries[0] !== `${value.token}.json`) return null; - const currentLockIdentity = lstatSync(path); + const currentLockIdentity = lstatSync(path, { bigint: true }); if (!currentLockIdentity.isDirectory() || !sameFileIdentity(lockIdentity, currentLockIdentity)) return null; return { record: value as ShimRestoreLockRecord, @@ -112,7 +109,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL const path = restoreLockPath(); for (let attempt = 0; attempt < 2; attempt += 1) { let fd: number | null = null; - let identity: Stats | null = null; + let identity: BigIntStats | null = null; let createdDirectory = false; const record: ShimRestoreLockRecord = { version: 1, @@ -125,9 +122,9 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL mkdirSync(path, { mode: 0o700 }); createdDirectory = true; fd = openSync(ownerPath, "wx", 0o600); - identity = fstatSync(fd); + identity = fstatSync(fd, { bigint: true }); writeFileSync(fd, `${JSON.stringify(record)}\n`, "utf8"); - identity = fstatSync(fd); + identity = fstatSync(fd, { bigint: true }); let released = false; return { release(): void { @@ -137,7 +134,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL try { const current = readShimRestoreLockSnapshot(path); if (identity && current && current.record.token === record.token - && sameFileIdentity(identity, current.fingerprint)) { + && sameFileIdentity(identity, current.fingerprint.identity)) { unlinkSync(ownerPath); rmdirSync(path); } @@ -150,7 +147,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL try { const current = readShimRestoreLockSnapshot(path); if (identity && current && current.record.token === record.token - && sameFileIdentity(identity, current.fingerprint)) { + && sameFileIdentity(identity, current.fingerprint.identity)) { unlinkSync(ownerPath); rmdirSync(path); } diff --git a/src/codex/shim-state-file.ts b/src/codex/shim-state-file.ts index ba2753297e5..4b0cd06c07e 100644 --- a/src/codex/shim-state-file.ts +++ b/src/codex/shim-state-file.ts @@ -10,12 +10,14 @@ import { renameSync, unlinkSync, writeFileSync, + type BigIntStats, type Stats, } from "node:fs"; import { randomUUID } from "node:crypto"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { sameFileIdentity } from "../lib/file-identity"; export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024; @@ -99,9 +101,9 @@ function fileErrorCode(error: unknown): string | undefined { } function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer; content: string; stat: Stats } | { warning: string } | null { - let lexicalBefore: Stats; + let lexicalBefore: BigIntStats; try { - lexicalBefore = lstatSync(path); + lexicalBefore = lstatSync(path, { bigint: true }); if (lexicalBefore.isSymbolicLink() || !lexicalBefore.isFile()) { return { warning: `Codex shim state is not a direct regular file at ${path}; auto-restore skipped.` }; } @@ -117,12 +119,12 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer return { warning: `Codex shim state could not be opened as a regular file at ${path}.` }; } try { - const before = fstatSync(fd); + const before = fstatSync(fd, { bigint: true }); if (!before.isFile()) return { warning: `Codex shim state is not a regular file at ${path}; auto-restore skipped.` }; if (before.size > maxBytes) { return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; } - const buffer = Buffer.allocUnsafe(before.size); + const buffer = Buffer.allocUnsafe(Number(before.size)); let offset = 0; while (offset < buffer.length) { const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, offset); @@ -133,25 +135,31 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer if (readSync(fd, extra, 0, 1, offset) !== 0) { return { warning: `Codex shim state exceeds the 1 MiB startup limit at ${path}; auto-restore skipped.` }; } - const after = fstatSync(fd); - let lexicalAfter: Stats; + const after = fstatSync(fd, { bigint: true }); + let lexicalAfter: BigIntStats; try { - lexicalAfter = lstatSync(path); + lexicalAfter = lstatSync(path, { bigint: true }); } catch { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - 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 + || Number(before.mtimeNs) / 1e6 !== Number(after.mtimeNs) / 1e6 || Number(before.ctimeNs) / 1e6 !== Number(after.ctimeNs) / 1e6 || before.mode !== after.mode || before.uid !== after.uid - || lexicalBefore.dev !== before.dev || lexicalBefore.ino !== before.ino + || !sameFileIdentity(lexicalBefore, before) || lexicalBefore.mode !== before.mode || lexicalBefore.uid !== before.uid - || lexicalBefore.size !== before.size || lexicalBefore.mtimeMs !== before.mtimeMs || lexicalBefore.ctimeMs !== before.ctimeMs - || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || lexicalAfter.dev !== after.dev || lexicalAfter.ino !== after.ino + || lexicalBefore.size !== before.size || Number(lexicalBefore.mtimeNs) / 1e6 !== Number(before.mtimeNs) / 1e6 || Number(lexicalBefore.ctimeNs) / 1e6 !== Number(before.ctimeNs) / 1e6 + || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || !sameFileIdentity(lexicalAfter, after) || lexicalAfter.mode !== after.mode || lexicalAfter.uid !== after.uid - || lexicalAfter.size !== after.size || lexicalAfter.mtimeMs !== after.mtimeMs || lexicalAfter.ctimeMs !== after.ctimeMs) { + || lexicalAfter.size !== after.size || Number(lexicalAfter.mtimeNs) / 1e6 !== Number(after.mtimeNs) / 1e6 || Number(lexicalAfter.ctimeNs) / 1e6 !== Number(after.ctimeNs) / 1e6) { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } - return { bytes: buffer, content: buffer.toString("utf8"), stat: after }; + const stat = Object.assign(Object.create(Object.getPrototypeOf(after)), after, { + dev: Number(after.dev), ino: Number(after.ino), mode: Number(after.mode), nlink: Number(after.nlink), + uid: Number(after.uid), gid: Number(after.gid), rdev: Number(after.rdev), size: Number(after.size), + blksize: Number(after.blksize), blocks: Number(after.blocks), atimeMs: Number(after.atimeNs) / 1e6, + mtimeMs: Number(after.mtimeNs) / 1e6, ctimeMs: Number(after.ctimeNs) / 1e6, birthtimeMs: Number(after.birthtimeNs) / 1e6, + }) as Stats; + return { bytes: buffer, content: buffer.toString("utf8"), stat }; } finally { closeSync(fd); } diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index b23c68ef8c2..e1cd8ad4110 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -21,6 +21,7 @@ import { unlinkSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { fileIdentityKey } from "../lib/file-identity"; import { Database } from "bun:sqlite"; @@ -524,11 +525,11 @@ export function openCodexCoordinatorTransaction( ); } } - const opened = lstatSync(finalDatabasePath); + const opened = lstatSync(finalDatabasePath, { bigint: true }); if (opened.isSymbolicLink() || !opened.isFile()) { throw new CodexUserIdentityRefusal("The coordinator database path changed during open."); } - initialIdentity = `${opened.dev}:${opened.ino}`; + initialIdentity = fileIdentityKey(opened); database.exec("PRAGMA busy_timeout = 0; PRAGMA locking_mode = NORMAL; BEGIN IMMEDIATE"); transactionOpen = true; initialize(database, databaseWasAbsent || databaseWasEmpty); @@ -546,9 +547,9 @@ export function openCodexCoordinatorTransaction( }; const assertStablePath = (): void => { requireOpen(); - const entry = lstatSync(finalDatabasePath); + const entry = lstatSync(finalDatabasePath, { bigint: true }); if (entry.isSymbolicLink() || !entry.isFile() - || `${entry.dev}:${entry.ino}` !== initialIdentity + || fileIdentityKey(entry) !== initialIdentity || !samePathIdentity(realpathSync.native(finalDatabasePath), finalDatabasePath)) { throw new CodexUserIdentityRefusal("The coordinator database path was substituted."); } diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts index e165cdc59f2..fe505deb4aa 100644 --- a/src/remote-control/workspace-executor.ts +++ b/src/remote-control/workspace-executor.ts @@ -14,6 +14,7 @@ import { } from "node:fs"; import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { sameFileIdentity } from "../lib/file-identity"; import { REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, type RemoteWorkspaceToolName, @@ -65,8 +66,8 @@ export interface RemoteWorkspaceCommandRunner { interface ApprovedRoot { id: string; path: string; - dev: number; - ino: number; + dev: bigint; + ino: bigint; birthtimeMs: number; } @@ -170,20 +171,20 @@ function decodeUtf8(value: Uint8Array): string { } function assertOpenedRegularFile(root: string, target: string, descriptor: number, maximum: number) { - const opened = fstatSync(descriptor); - const linked = lstatSync(target); - if (opened.isFile() && linked.isFile() && (opened.nlink !== 1 || linked.nlink !== 1)) { + const opened = fstatSync(descriptor, { bigint: true }); + const linked = lstatSync(target, { bigint: true }); + if (opened.isFile() && linked.isFile() && (opened.nlink !== 1n || linked.nlink !== 1n)) { throw new Error("remote workspace hard-linked files are not allowed"); } if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() - || opened.dev !== linked.dev || opened.ino !== linked.ino - || opened.birthtimeMs !== linked.birthtimeMs) { + || !sameFileIdentity(opened, linked) + || Number(opened.birthtimeNs) / 1e6 !== Number(linked.birthtimeNs) / 1e6) { throw new Error("remote workspace file identity changed during access"); } const canonical = realpathSync(target); if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); if (opened.size > maximum) throw new Error("remote workspace file exceeds the read limit"); - return opened; + return { size: Number(opened.size), mode: Number(opened.mode) }; } function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { @@ -216,11 +217,11 @@ function assertStableWritableParent(root: string, target: string): void { } function assertApprovedRootIdentity(root: ApprovedRoot): void { - const linked = lstatSync(root.path); + const linked = lstatSync(root.path, { bigint: true }); const canonical = realpathSync(root.path); if (!linked.isDirectory() || linked.isSymbolicLink() - || linked.dev !== root.dev || linked.ino !== root.ino - || linked.birthtimeMs !== root.birthtimeMs + || !sameFileIdentity(linked, root) + || Number(linked.birthtimeNs) / 1e6 !== root.birthtimeMs || relative(root.path, canonical) !== "") { throw new Error("remote workspace approved root identity changed; pair the folder again"); } @@ -256,13 +257,13 @@ export class RemoteWorkspaceExecutor { const metadata = lstatSync(root.path); if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); const canonical = realpathSync(root.path); - const identity = lstatSync(canonical); + const identity = lstatSync(canonical, { bigint: true }); this.roots.set(root.id, { id: root.id, path: canonical, dev: identity.dev, ino: identity.ino, - birthtimeMs: identity.birthtimeMs, + birthtimeMs: Number(identity.birthtimeNs) / 1e6, }); } if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts index a03d536ec02..bd68c779e1d 100644 --- a/tests/clients/remote-workspace.test.ts +++ b/tests/clients/remote-workspace.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import { @@ -144,6 +145,51 @@ function responseValue(response: Awaited { + test("rejects an opened target with an adjacent NTFS-sized inode", async () => { + const state = fixture(); + const target = join(state.executorRoot, "project", "marker.txt"); + const id = 9_007_199_254_740_992n; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === target ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }); + }) as typeof fs.fstatSync); + try { + const result = responseValue(await state.coordinator.handle(state.request("read_file", { path: "project/marker.txt" }))); + expect(result.ok).toBe(false); + expect(result.error).toContain("file identity changed"); + } finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + + test("rejects an approved root with an adjacent NTFS-sized inode", async () => { + const state = fixture(); + const realLstat = fs.lstatSync; + const id = 9_007_199_254_740_992n; + let replacement = false; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + const ino = id + (replacement ? 1n : 0n); + return path === state.executorRoot ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? ino : Number(ino) }) : stat; + }) as typeof fs.lstatSync); + try { + const executor = new RemoteWorkspaceExecutor({ deviceId: "device", roots: [{ id: "root", path: state.executorRoot }] }); + replacement = true; + const result = await executor.invoke({ requestId: "request", sessionId: "session", executorDeviceId: "device", + rootId: "root", tool: "read_file", arguments: { path: "project/marker.txt" } }); + expect(result.ok).toBe(false); + expect(result.error).toContain("approved root identity changed"); + } finally { lstat.mockRestore(); } + }); +}); + describe("remote workspace coordinator and executor", () => { test.skipIf(process.platform === "win32")("refuses FIFO read and write preconditions without blocking", () => { const state = fixture(); diff --git a/tests/codex-integration/codex-coordinator-doctor.test.ts b/tests/codex-integration/codex-coordinator-doctor.test.ts index acbf312ca26..d7d85da1f14 100644 --- a/tests/codex-integration/codex-coordinator-doctor.test.ts +++ b/tests/codex-integration/codex-coordinator-doctor.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -80,6 +81,23 @@ test("doctor classifies and explicitly backs up a stable zero-byte coordinator", rmSync(recovered.backupPath, { force: true }); }); +test("doctor detects adjacent large file identities", () => { + privateFile(coordinatorPath); + const realLstat = fs.lstatSync; + const id = 2n ** 53n; + let observations = 0; + const spy = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + if (target !== coordinatorPath) return value; + const ino = id + BigInt(observations++ > 0); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + try { + expect(Number(id)).toBe(Number(id + 1n)); + expect(inspectCodexCoordinator().kind).toBe("changed"); + } finally { spy.mockRestore(); } +}); + test("doctor distinguishes unversioned, rowless, and authoritative coordinators", () => { let database = new Database(coordinatorPath, { create: true }); database.exec("CREATE TABLE temporary_probe (id INTEGER); DROP TABLE temporary_probe"); diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index 06b7a5e39e4..c4ac9817963 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -1,8 +1,9 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; -import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, legacyHistoryBackupPathFor, migrateHistoryToOpenai, resolveExistingHistoryBackupPath, restoreLegacyOpenaiHistory, restoredUserEventFor, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../../src/codex/history-provider"; import { codexHistoryBackupId, legacyCodexHistoryBackupId, sameCodexHistoryPath } from "../../src/codex/history-manifest"; import { INVALID_HISTORY_BACKUP_FIXTURES, validHistoryBackupFixture } from "../helpers/codex-history-manifest-fixtures"; @@ -106,6 +107,35 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { } describe("Codex history provider sync", () => { + test("descriptor guard rejects adjacent NTFS-sized file identities", () => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + const id = 9_007_199_254_740_992n; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync, realOpen = fs.openSync, realClose = fs.closeSync; + let armed = false; + const rolloutFds = new Set(); + const open = spyOn(fs, "openSync").mockImplementation(((path, flags, mode) => { + const fd = realOpen(path, flags, mode); + if (path === fixture.rollout) rolloutFds.add(fd); + return fd; + }) as typeof fs.openSync); + const close = spyOn(fs, "closeSync").mockImplementation((fd => { rolloutFds.delete(fd); realClose(fd); }) as typeof fs.closeSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === fixture.rollout && armed ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return armed && rolloutFds.has(fd) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }) : stat; + }) as typeof fs.fstatSync); + setHistoryAppendHooksForTests({ beforeWrite: () => { armed = true; } }); + try { + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ failed: true, integrityCode: "history_rollout_identity_changed" }); + } finally { close.mockRestore(); open.mockRestore(); fstat.mockRestore(); lstat.mockRestore(); } + }); test.each(["{broken", "null", "[]", "42", '"text"'])("invalid first record %s is a structured integrity failure", (first) => { const fixture = makeFixture(); noopSnapshotArtifacts.add(join(fixture.dbPath,"..")); @@ -1286,6 +1316,24 @@ describe("history lock retry", () => { }); describe("Design B migration helpers", () => { + test("no-op snapshot rejects adjacent NTFS-sized database identities", () => { + const fixture = makeFixture(); + const backup = historyBackupPathFor(fixture.dbPath); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + noopSnapshotArtifacts.add(backup); + const id = 9_007_199_254_740_992n; + const real = fs.statSync; + let replaced = false; + const stat = spyOn(fs, "statSync").mockImplementation(((path, options) => { + const result = options?.bigint ? real(path, { bigint: true }) : real(path); + const ino = id + (replaced ? 1n : 0n); + return path === fixture.dbPath ? Object.assign(Object.create(Object.getPrototypeOf(result)), result, + { ino: options?.bigint ? ino : Number(ino) }) : result; + }) as typeof fs.statSync); + setAfterNoopPendingCountForTests(() => { replaced = true; }); + try { expect(snapshotCodexHistoryNoop(fixture.dbPath, backup)).toMatchObject({ kind: "unknown", reason: "snapshot-race" }); } + finally { setAfterNoopPendingCountForTests(undefined); stat.mockRestore(); } + }); test("strict no-op snapshots distinguish absence from manifest uncertainty", () => { const dir = join(tmpdir(), `ocx-history-noop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true }); diff --git a/tests/codex-integration/codex-main-account-refresh.test.ts b/tests/codex-integration/codex-main-account-refresh.test.ts index 17ba7ac84fc..2535bc88242 100644 --- a/tests/codex-integration/codex-main-account-refresh.test.ts +++ b/tests/codex-integration/codex-main-account-refresh.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -32,6 +33,25 @@ afterEach(() => { }); describe("native main token refresh", () => { + test("rejects adjacent large file identities before publication", async () => { + const path = join(home, "auth.json"); + const original = JSON.stringify({ auth_mode: "chatgpt", tokens: { refresh_token: "old-refresh", account_id: "account-main" } }); + writeFileSync(path, original); + const realStat = fs.statSync; + const id = 2n ** 53n; + let replaced = false; + const spy = spyOn(fs, "statSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); + const ino = replaced ? id + 1n : id; + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + }) as typeof fs.statSync); + setMainAuthJsonBeforeRenameHookForTests(() => { replaced = true; }); + try { + expect(Number(id)).toBe(Number(id + 1n)); + expect(await getValidMainAccountToken({ refreshToken: async () => ({ access: "new-access", refresh: "new-refresh", expires: Date.now() + 3600000, accountId: "account-main" }) })).toMatchObject({ accessToken: "new-access" }); + expect(readFileSync(path, "utf8")).toBe(original); + } finally { spy.mockRestore(); } + }); test("refreshes a refresh-only auth file and atomically preserves unrelated fields", async () => { const authPath = join(home, "auth.json"); const original = { diff --git a/tests/codex-integration/codex-native-residue.test.ts b/tests/codex-integration/codex-native-residue.test.ts index 0a870265e65..3291cd5119d 100644 --- a/tests/codex-integration/codex-native-residue.test.ts +++ b/tests/codex-integration/codex-native-residue.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, @@ -68,6 +69,24 @@ function pathInCodexHome(name: string): string { return join(codexHome, name); } +test("residue inspection detects adjacent large file identities", () => { + const path = pathInCodexHome("config.toml"); + writeFileSync(path, 'model = "example"\n'); + const realStat = fs.statSync; + const id = 2n ** 53n; + let observations = 0; + const spy = spyOn(fs, "statSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); + if (target !== path) return value; + const ino = id + BigInt(observations++ > 0); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.statSync); + try { + expect(Number(id)).toBe(Number(id + 1n)); + expect(classifyNativeRoutedResidue()).toMatchObject({ kind: "indeterminate", surface: "config", reason: "surface changed while it was being observed" }); + } finally { spy.mockRestore(); } +}); + function canonicalPathInCodexHome(name: string): string { return join(realpathSync.native(codexHome), name); } diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index 2d6e08aff0b..c6dc6150891 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -8,10 +8,12 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, sep } from "node:path"; import { autoRestoreCodexShim, installCodexShim, uninstallCodexShim } from "../../src/codex/shim"; import { createWorkerCommitWindow, createCodexCliUpdateFence, codexCliUpdateSelectionGate } from "../../src/codex/cli-update-selection-gate"; import { stableShimPathProbe } from "../../src/codex/shim-fingerprint"; +import { readBoundedRegularFile } from "../../src/codex/shim-state-file"; +import { tryAcquireShimRestoreLock } from "../../src/codex/shim-restore-lock"; import { refreshObsoleteWindowsShim } from "../../src/codex/shim-windows-refresh"; import { buildWindowsCodexShim, buildWindowsPowerShellCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; @@ -50,6 +52,70 @@ afterEach(() => { removeTreeWithRetry(dir); }); +test("shim probe rejects adjacent NTFS-sized file identities", () => { + const path = join(dir, "launcher"); + fs.writeFileSync(path, "launcher fixture"); + const id = 9_007_199_254_740_992n; + const real = fs.lstatSync; + let calls = 0; + const stat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const result = options?.bigint ? real(target, { bigint: true }) : real(target); + if (target !== path) return result; + const ino = id + BigInt(calls++ > 0); + return Object.assign(Object.create(Object.getPrototypeOf(result)), result, + { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + try { expect(stableShimPathProbe(path)).toBeNull(); } + finally { stat.mockRestore(); } +}); + +test("shim state read rejects adjacent NTFS-sized file identities", () => { + const path = join(dir, "state.json"); + fs.writeFileSync(path, "{}"); + const id = 9_007_199_254_740_992n; + const realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const stat = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }); + }) as typeof fs.fstatSync); + try { expect(readBoundedRegularFile(path, 1024)).toMatchObject({ warning: expect.stringContaining("changed while being read") }); } + finally { fstat.mockRestore(); lstat.mockRestore(); } +}); + +test("shim restore lock preserves an owner with an adjacent NTFS-sized identity", () => { + const lockPath = join(dir, "codex-shim.autorestore.lock"); + const id = 9_007_199_254_740_992n; + const realOpen = fs.openSync, realFstat = fs.fstatSync, realLstat = fs.lstatSync; + let ownerFd: number | undefined; + const open = spyOn(fs, "openSync").mockImplementation(((path, flags, mode) => { + const fd = realOpen(path, flags, mode); + if (String(path).startsWith(lockPath) && flags === "wx") ownerFd = fd; + return fd; + }) as typeof fs.openSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return fd === ownerFd ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id : Number(id) }) : stat; + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return String(path).startsWith(lockPath + sep) ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? id + 1n : Number(id + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + const lock = tryAcquireShimRestoreLock(); + expect(lock).not.toBeNull(); + lock!.release(); + expect(fs.existsSync(lockPath)).toBe(true); + } finally { lstat.mockRestore(); fstat.mockRestore(); open.mockRestore(); } +}); + /** The pre-fix template: no Windows revision line, and the token load ahead of the bypass check. */ function previousTemplate(flavor: "cmd" | "ps1", backing: string): string { const current = flavor === "cmd" diff --git a/tests/codex-integration/codex-transition-state.test.ts b/tests/codex-integration/codex-transition-state.test.ts index 9032dfcbe47..5fc97aaea9e 100644 --- a/tests/codex-integration/codex-transition-state.test.ts +++ b/tests/codex-integration/codex-transition-state.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -59,6 +60,25 @@ function transition(txId: string) { }; } +test("coordinator detects adjacent large file identities", () => { + const realLstat = fs.lstatSync; + const id = 2n ** 53n; + let replaced = false; + const spy = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + const ino = replaced ? id + 1n : id; + return target === coordinatorPath ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + }) as typeof fs.lstatSync); + try { + const controller = openCodexCoordinatorTransaction(coordinatorPath); + try { + replaced = true; + expect(Number(id)).toBe(Number(id + 1n)); + expect(() => controller.assertStablePath()).toThrow("substituted"); + } finally { controller.close(); } + } finally { spy.mockRestore(); } +}); + test("resolve persists zero counts only for a verified-noop proof", () => { const started = beginCodexTransition( { nativeGeneration: 0, currentTxId: null }, diff --git a/tests/codex-integration/native-main-owner-lifetime.test.ts b/tests/codex-integration/native-main-owner-lifetime.test.ts index 018afa750ad..c47d572850d 100644 --- a/tests/codex-integration/native-main-owner-lifetime.test.ts +++ b/tests/codex-integration/native-main-owner-lifetime.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { Database } from "bun:sqlite"; import { existsSync, @@ -27,6 +28,34 @@ import { probeNativeProfileRecoveryState } from "../../src/codex/native-profile- import type { NativeProfileKey, NativeProfileKeyProvider } from "../../src/codex/native-profile-types"; import type { OcxConfig } from "../../src/types"; import { helperPath, repoRoot } from "../helpers/repo-root"; +import { assertStableLockFile, openStableLockFile } from "../../src/codex/native-main-lock-file"; + +test("stable lock rejects adjacent large file identities", () => { + const scratch = join(repoRoot(), ".tmp", "ino-audit"); + mkdirSync(scratch, { recursive: true }); + const root = mkdtempSync(join(scratch, "lock-")); + const path = join(root, "lock"); + const id = 2n ** 53n; + const realFstat = fs.fstatSync, realLstat = fs.lstatSync; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const value = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }); + }) as typeof fs.fstatSync); + let replaced = false; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + const ino = replaced ? id + 1n : id; + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? ino : Number(ino) }) : value; + }) as typeof fs.lstatSync); + try { + const handle = openStableLockFile(path); + try { + replaced = true; + expect(Number(id)).toBe(Number(id + 1n)); + expect(() => assertStableLockFile(path, handle)).toThrow("identity changed"); + } finally { handle.close(); } + } finally { fstat.mockRestore(); lstat.mockRestore(); rmSync(root, { recursive: true, force: true }); } +}); const roots: string[] = []; const previousCodexHome = process.env.CODEX_HOME; diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts index 9adb4fea11c..c53c550a521 100644 --- a/tests/lib/file-identity-callers.test.ts +++ b/tests/lib/file-identity-callers.test.ts @@ -6,6 +6,18 @@ import { repoPath } from "../helpers/repo-root"; // calls made with { bigint: true }. A lexical tripwire over the named declarations, not a // full parser: it catches the regression forms below and fails closed on scopes it cannot find. const scopes: Record = { + "src/codex/codex-home-owner.ts": ["readCodexHomeJournal"], + "src/codex/orca-auth-source.ts": ["readBoundedLocalFile"], + "src/codex/inject/bounded-config-reader.ts": ["readBoundedCodexConfig"], + "src/codex/main-account.ts": ["statIdentity"], + "src/codex/coordinator-doctor.ts": ["inspectTarget"], + "src/codex/native-main-lock-file.ts": ["openStableLockFile", "assertStableLockFile"], + "src/codex/native-residue.ts": ["readRegularFile", "classifyHistoryDatabase"], + "src/codex/history-provider.ts": ["patchFirstLineProviderInPlace", "assertHistoryDescriptorIdentity", "historyFileIdentity"], + // shim-fingerprint.ts and shim-state-file.ts keep numeric dev/ino fields for the persisted shim + // state format; their runtime comparisons use bigint identity and have behavioral coverage. + "src/codex/shim-restore-lock.ts": ["readShimRestoreLockSnapshot", "tryAcquireShimRestoreLock"], + "src/remote-control/workspace-executor.ts": ["assertOpenedRegularFile", "assertApprovedRootIdentity"], "src/config/initialize.ts": ["identifiesDescriptor"], "src/service/state-lock.ts": ["readLockSnapshot"], "src/oauth/store.ts": ["snapshot", "createOAuthFileLock"], @@ -104,9 +116,16 @@ function declarationBody(code: string, name: string): [number, number] | null { while (code[at] === " " || code[at] === "\t") at++; if (code[at] === ":") { at++; - while (/\s/.test(code[at] ?? "")) at++; - if (code[at] === "{") at = matching(code, at, "{", "}") + 1; - while (at < code.length && code[at] !== "{" && code[at] !== ";" && code[at] !== "\n") at++; + // A "{" after | & : < , ( opens an object type, not the body. + for (;;) { + while (at < code.length && code[at] !== "{" && code[at] !== ";" && code[at] !== "\n") at++; + if (code[at] !== "{") break; + let back = at - 1; + while (back >= 0 && (code[back] === " " || code[back] === "\t")) back--; + if (!"|&:<,(".includes(code[back] ?? "")) break; + at = matching(code, at, "{", "}") + 1; + if (at <= 0) break; + } } if (code[at] !== "{") continue; const end = matching(code, at, "{", "}"); @@ -182,6 +201,8 @@ describe("file identity caller stats", () => { expect(inspect("check()\n{ lstatSync(p, { bigint: true }); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) .toEqual(["numeric stat: lstatSync(path)"]); expect(inspect("function check() { return 1; }", ["check"])).toEqual(["no stat call in: check"]); + expect(inspect('function check(): { kind: "a" } | { kind: "b"; n: number } { return lstatSync(p).ino; }', ["check"])) + .toEqual(["numeric stat: lstatSync(p)"]); expect(inspect("function check() { lstatSync(root); }", ["check"], true)).toEqual(["no stat call in: check"]); }); diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts index cb4e63afe42..d5e474db615 100644 --- a/tests/lib/file-identity.test.ts +++ b/tests/lib/file-identity.test.ts @@ -1,5 +1,11 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { join } from "node:path"; import { fileIdentityKey, sameFileIdentity } from "../../src/lib/file-identity"; +import { readCodexHomeJournal } from "../../src/codex/codex-home-owner"; +import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; +import { readBoundedLocalFile } from "../../src/codex/orca-auth-source"; +import { repoPath } from "../helpers/repo-root"; test("compares and formats adjacent large file IDs", () => { const a = { dev: 7n, ino: 2n ** 53n }; @@ -10,3 +16,33 @@ test("compares and formats adjacent large file IDs", () => { expect(sameFileIdentity(a, { ...a, dev: 8n })).toBe(false); expect(fileIdentityKey(b).split(":").map(BigInt)).toEqual([b.dev, b.ino]); }); + +for (const [name, read] of [ + ["journal", (path: string) => expect(readCodexHomeJournal(path).kind).toBe("unknown")], + ["config", (path: string) => expect(() => readBoundedCodexConfig(path)).toThrow("changed while it was read")], + ["local file", (path: string) => expect(() => readBoundedLocalFile(path)).toThrow("Local credential file changed")], +] as const) { + test(`${name} compares adjacent large file IDs`, () => { + const scratch = repoPath(".tmp", "ino-audit"); + fs.mkdirSync(scratch, { recursive: true }); + const root = fs.mkdtempSync(join(scratch, "identity-")); + const path = join(root, "data"); + fs.writeFileSync(path, JSON.stringify({ version: 1, originalConfig: "", originalProfile: null })); + const id = 2n ** 53n; + const realStat = fs.statSync, realLstat = fs.lstatSync, realFstat = fs.fstatSync; + const stat = spyOn(fs, "statSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realStat(target, { bigint: true }) : realStat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; + }) as typeof fs.statSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? realLstat(target, { bigint: true }) : realLstat(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id : Number(id) }) : value; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const value = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ino: options?.bigint ? id + 1n : Number(id + 1n) }); + }) as typeof fs.fstatSync); + try { read(path); } + finally { fstat.mockRestore(); lstat.mockRestore(); stat.mockRestore(); fs.rmSync(root, { recursive: true, force: true }); } + }); +} From f6ab1c916ec5ac2eaa843367f8ed6cdd1f05e756 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:42:43 +0900 Subject: [PATCH 2/3] fix: keep stat millisecond values exact and compare shim runtime identity as bigint Derive *Ms from bigint *Ns with the runtime's own formula so values still match numeric and persisted stats; carry bigint identity through the shim wrapper ownership and inspection checks; fingerprints without identity compare unequal. --- src/codex/history-provider.ts | 4 +-- src/codex/shim-fingerprint.ts | 8 ++--- src/codex/shim-inspect.ts | 5 +-- src/codex/shim-state-file.ts | 12 +++---- src/codex/shim-wrapper-write.ts | 13 +++---- src/codex/shim.ts | 3 +- src/lib/file-identity.ts | 5 +++ src/oauth/store.ts | 3 +- src/remote-control/workspace-executor.ts | 8 ++--- src/service/state-lock.ts | 3 +- .../codex-shim-windows-revision.test.ts | 36 ++++++++++++++++++- tests/lib/file-identity-callers.test.ts | 1 + tests/lib/file-identity.test.ts | 23 +++++++++++- 13 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index db57a0f3c17..8996c4ad081 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -7,7 +7,7 @@ import { Database } from "bun:sqlite"; import { resolveCodexStateDbPath } from "./paths"; import { openCodexStateForPreflight } from "./history-state-open"; import { atomicWriteFile, getConfigDir } from "../config"; -import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; +import { sameFileIdentity, statTimeMs, type FileIdentity } from "../lib/file-identity"; import { CODEX_HISTORY_RESUMABLE_SOURCES, codexHistoryBackupId, @@ -695,7 +695,7 @@ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupIn function historyFileIdentity(path: string): string | null { try { const stat = statSync(path, { bigint: true }); - return [stat.dev, stat.ino, Number(stat.size), Number(stat.mtimeNs) / 1e6, Number(stat.ctimeNs) / 1e6].join(":"); + return [stat.dev, stat.ino, Number(stat.size), statTimeMs(stat.mtimeNs), statTimeMs(stat.ctimeNs)].join(":"); } catch { return null; } diff --git a/src/codex/shim-fingerprint.ts b/src/codex/shim-fingerprint.ts index 98c0dfea37e..763e5eedd98 100644 --- a/src/codex/shim-fingerprint.ts +++ b/src/codex/shim-fingerprint.ts @@ -13,7 +13,7 @@ import { unlinkSync, } from "node:fs"; import { posix, win32 } from "node:path"; -import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; +import { sameFileIdentity, statTimeMs, type FileIdentity } from "../lib/file-identity"; import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates"; import type { ShimFileState } from "./shim-state-file"; @@ -59,8 +59,8 @@ function statFingerprint(path: string, follow: boolean): Omit, right: ShimPathFingerprint | Omit, ): boolean { - return sameFileIdentity(left.identity, right.identity) + return !!left.identity && !!right.identity && sameFileIdentity(left.identity, right.identity) && left.kind === right.kind && left.mode === right.mode && left.size === right.size diff --git a/src/codex/shim-inspect.ts b/src/codex/shim-inspect.ts index dd696d5bcb0..3cc3dd7c620 100644 --- a/src/codex/shim-inspect.ts +++ b/src/codex/shim-inspect.ts @@ -1,6 +1,7 @@ import { lstatSync } from "node:fs"; import { extname, join, posix, win32 } from "node:path"; import { getConfigDir } from "../config"; +import { sameFileIdentity } from "../lib/file-identity"; import { fileErrorCode, readStateResult, stateFiles } from "./shim-state-file"; import { isHealthyShimProbe, @@ -111,7 +112,7 @@ export function inspectCodexShimBackingForCommand( const sameEffectiveIdentity = (fingerprint: ShimPathFingerprint | null): boolean => { if (!fingerprint) return false; const identity = fingerprint.target ?? fingerprint; - return identity.dev === selectedIdentity.dev && identity.ino === selectedIdentity.ino; + return sameFileIdentity(identity.identity, selectedIdentity.identity); }; const matches = files.flatMap(file => { const backingPath = file.realPath ?? file.backupPath; @@ -151,7 +152,7 @@ export function inspectCodexShimBackingForCommand( }); } const wrapperIdentity = wrapperProbe.fingerprint.target ?? wrapperProbe.fingerprint; - if (backing.dev === wrapperIdentity.dev && backing.ino === wrapperIdentity.ino) { + if (sameFileIdentity(backing.identity, wrapperIdentity.identity)) { return Object.freeze({ status: "unknown" as const, reason: "backing_mismatch" as const }); } const wrapperExt = extname(file.wrapperPath).toLowerCase(); diff --git a/src/codex/shim-state-file.ts b/src/codex/shim-state-file.ts index 4b0cd06c07e..aeb9e0cc389 100644 --- a/src/codex/shim-state-file.ts +++ b/src/codex/shim-state-file.ts @@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { getConfigDir } from "../config"; import { recordOwnedConfigPath } from "../lib/config-ownership"; -import { sameFileIdentity } from "../lib/file-identity"; +import { sameFileIdentity, statTimeMs } from "../lib/file-identity"; export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024; @@ -143,21 +143,21 @@ function readBoundedRegularFile(path: string, maxBytes: number): { bytes: Buffer return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } if (!sameFileIdentity(before, after) || before.size !== after.size - || Number(before.mtimeNs) / 1e6 !== Number(after.mtimeNs) / 1e6 || Number(before.ctimeNs) / 1e6 !== Number(after.ctimeNs) / 1e6 + || statTimeMs(before.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(before.ctimeNs) !== statTimeMs(after.ctimeNs) || before.mode !== after.mode || before.uid !== after.uid || !sameFileIdentity(lexicalBefore, before) || lexicalBefore.mode !== before.mode || lexicalBefore.uid !== before.uid - || lexicalBefore.size !== before.size || Number(lexicalBefore.mtimeNs) / 1e6 !== Number(before.mtimeNs) / 1e6 || Number(lexicalBefore.ctimeNs) / 1e6 !== Number(before.ctimeNs) / 1e6 + || lexicalBefore.size !== before.size || statTimeMs(lexicalBefore.mtimeNs) !== statTimeMs(before.mtimeNs) || statTimeMs(lexicalBefore.ctimeNs) !== statTimeMs(before.ctimeNs) || !lexicalAfter.isFile() || lexicalAfter.isSymbolicLink() || !sameFileIdentity(lexicalAfter, after) || lexicalAfter.mode !== after.mode || lexicalAfter.uid !== after.uid - || lexicalAfter.size !== after.size || Number(lexicalAfter.mtimeNs) / 1e6 !== Number(after.mtimeNs) / 1e6 || Number(lexicalAfter.ctimeNs) / 1e6 !== Number(after.ctimeNs) / 1e6) { + || lexicalAfter.size !== after.size || statTimeMs(lexicalAfter.mtimeNs) !== statTimeMs(after.mtimeNs) || statTimeMs(lexicalAfter.ctimeNs) !== statTimeMs(after.ctimeNs)) { return { warning: `Codex shim state changed while being read at ${path}; auto-restore skipped.` }; } const stat = Object.assign(Object.create(Object.getPrototypeOf(after)), after, { dev: Number(after.dev), ino: Number(after.ino), mode: Number(after.mode), nlink: Number(after.nlink), uid: Number(after.uid), gid: Number(after.gid), rdev: Number(after.rdev), size: Number(after.size), - blksize: Number(after.blksize), blocks: Number(after.blocks), atimeMs: Number(after.atimeNs) / 1e6, - mtimeMs: Number(after.mtimeNs) / 1e6, ctimeMs: Number(after.ctimeNs) / 1e6, birthtimeMs: Number(after.birthtimeNs) / 1e6, + blksize: Number(after.blksize), blocks: Number(after.blocks), atimeMs: statTimeMs(after.atimeNs), + mtimeMs: statTimeMs(after.mtimeNs), ctimeMs: statTimeMs(after.ctimeNs), birthtimeMs: statTimeMs(after.birthtimeNs), }) as Stats; return { bytes: buffer, content: buffer.toString("utf8"), stat }; } finally { diff --git a/src/codex/shim-wrapper-write.ts b/src/codex/shim-wrapper-write.ts index 8d3f39a9e52..e05b8950793 100644 --- a/src/codex/shim-wrapper-write.ts +++ b/src/codex/shim-wrapper-write.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import { chmodSync, lstatSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; +import { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { buildUnixCodexShim, @@ -54,7 +55,7 @@ export function windowsShimContent(wrapperPath: string, realCodexPath: string, r return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())); } -export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined { +export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): FileIdentity | undefined { const { bun, bunRuntimeSource, cli } = cliEntry(runtime); if (process.platform === "win32") { writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8"); @@ -74,7 +75,7 @@ export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: try { // "wx" fails if the staging path somehow exists, so we never inherit a file. writeFileSync(staged, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), { encoding: "utf8", flag: "wx", mode: 0o600 }); - const stagedStat = lstatSync(staged); + const stagedStat = lstatSync(staged, { bigint: true }); chmodSync(staged, 0o755); renameSync(staged, wrapperPath); renamed = true; @@ -96,14 +97,14 @@ export function writeShim(wrapperPath: string, realCodexPath: string, runtime?: */ export function ownedWrapperFingerprint( wrapperPath: string, - written: { dev: number; ino: number } | undefined, + written: FileIdentity | undefined, ): ShimPathFingerprint | undefined { const probe = stableShimPathProbe(wrapperPath); if (!probe) return undefined; // Platforms that still write in place (Windows) have no staged identity; fall // back to the marker check they have always used. if (!written) return probe.prefix.includes(SHIM_MARKER) ? probe.fingerprint : undefined; - if (probe.fingerprint.dev !== written.dev || probe.fingerprint.ino !== written.ino) return undefined; + if (!sameFileIdentity(probe.fingerprint.identity, written)) return undefined; return probe.fingerprint; } @@ -118,11 +119,11 @@ export function ownedWrapperFingerprint( */ export function wrapperInodeIsOurs( wrapper: StableShimPathProbe, - written: { dev: number; ino: number } | undefined, + written: FileIdentity | undefined, recorded: ShimPathFingerprint | undefined, ): boolean { // A different inode is conclusive: someone renamed their own file over ours. - if (written && (wrapper.fingerprint.dev !== written.dev || wrapper.fingerprint.ino !== written.ino)) { + if (written && !sameFileIdentity(wrapper.fingerprint.identity, written)) { return false; } // Same inode is not sufficient on its own — an in-place truncation (shell `>`, diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 99b525e2beb..24d0f8b99eb 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { delimiter, extname, join, posix, win32 } from "node:path"; import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime"; +import type { FileIdentity } from "../lib/file-identity"; import { assertSelectedRuntimeWritable, RuntimePreflightError, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight"; import { getConfigDir } from "../config"; import { isWslRuntime, wslAutomountRoot } from "./home"; @@ -264,7 +265,7 @@ interface FreshShimInstallJournalEntry { writtenWrapperFingerprint?: ShimPathFingerprint; wrapperWriteStarted: boolean; /** dev/ino of the file our `writeShim()` created, recorded before anything can fail. */ - writtenWrapperInode?: { dev: number; ino: number }; + writtenWrapperInode?: FileIdentity; } function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry[]): void { diff --git a/src/lib/file-identity.ts b/src/lib/file-identity.ts index 86474299131..4258a1cd742 100644 --- a/src/lib/file-identity.ts +++ b/src/lib/file-identity.ts @@ -1,6 +1,11 @@ /** Acquire stats with { bigint: true }; never widen a Number to form an identity. */ export type FileIdentity = Readonly<{ dev: bigint; ino: bigint }>; +/** Matches the numeric stat's *Ms value, so a bigint observation can be compared with a numeric or persisted one. */ +export function statTimeMs(ns: bigint): number { + return Number(ns / 1_000_000_000n) * 1e3 + Number(ns % 1_000_000_000n) / 1e6; +} + export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean { return a.dev === b.dev && a.ino === b.ino; } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 2985194917b..20a4b418dbf 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -35,6 +35,7 @@ import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, ha import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { statTimeMs } from "../lib/file-identity"; import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget"; import { publishAccountSelection, publishOAuthAccountPauseChange, publishOAuthAccountRoutingPolicyChange } from "../lib/account-selection-events"; import { @@ -433,7 +434,7 @@ interface LockSnapshot { bytes: string; dev: bigint; ino: bigint; mtimeMs: numbe export interface OAuthFileLockOptions { path: string; waitTimeoutMs?: number; staleAfterMs?: number; pollMinMs?: number; pollMaxMs?: number; sleep?: (ms: number) => Promise; now?: () => number; random?: () => number; beforeStaleUnlink?: () => void; beforeReleaseUnlink?: () => void; beforeFailedCreateUnlink?: () => void; writeMetadata?: (fd: number, bytes: string) => void } export interface OAuthFileLockGuard { readonly ownerId: string; release(): void } function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; } -function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path, { bigint: true }); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:Number(s.mtimeNs)/1e6, mtimeNs:s.mtimeNs, size:s.size }; } +function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path, { bigint: true }); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:statTimeMs(s.mtimeNs), mtimeNs:s.mtimeNs, size:s.size }; } function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } function sameFd(a: LockSnapshot,b: import("node:fs").BigIntStats): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeNs===b.mtimeNs&&a.size===b.size; } export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise } { diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts index fe505deb4aa..fa499dc2a93 100644 --- a/src/remote-control/workspace-executor.ts +++ b/src/remote-control/workspace-executor.ts @@ -14,7 +14,7 @@ import { } from "node:fs"; import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; -import { sameFileIdentity } from "../lib/file-identity"; +import { sameFileIdentity, statTimeMs } from "../lib/file-identity"; import { REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, type RemoteWorkspaceToolName, @@ -178,7 +178,7 @@ function assertOpenedRegularFile(root: string, target: string, descriptor: numbe } if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() || !sameFileIdentity(opened, linked) - || Number(opened.birthtimeNs) / 1e6 !== Number(linked.birthtimeNs) / 1e6) { + || statTimeMs(opened.birthtimeNs) !== statTimeMs(linked.birthtimeNs)) { throw new Error("remote workspace file identity changed during access"); } const canonical = realpathSync(target); @@ -221,7 +221,7 @@ function assertApprovedRootIdentity(root: ApprovedRoot): void { const canonical = realpathSync(root.path); if (!linked.isDirectory() || linked.isSymbolicLink() || !sameFileIdentity(linked, root) - || Number(linked.birthtimeNs) / 1e6 !== root.birthtimeMs + || statTimeMs(linked.birthtimeNs) !== root.birthtimeMs || relative(root.path, canonical) !== "") { throw new Error("remote workspace approved root identity changed; pair the folder again"); } @@ -263,7 +263,7 @@ export class RemoteWorkspaceExecutor { path: canonical, dev: identity.dev, ino: identity.ino, - birthtimeMs: Number(identity.birthtimeNs) / 1e6, + birthtimeMs: statTimeMs(identity.birthtimeNs), }); } if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts index d57b85b0b69..4f4fe6da189 100644 --- a/src/service/state-lock.ts +++ b/src/service/state-lock.ts @@ -15,6 +15,7 @@ import { type BigIntStats, } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { statTimeMs } from "../lib/file-identity"; const SERVICE_STATE_LOCK_WAIT_MS = 2_000; const SERVICE_STATE_LOCK_POLL_MS = 20; @@ -116,7 +117,7 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) || currentOwner.size !== ownerIdentity.size) return null; - return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: Number(ownerIdentity.mtimeNs) / 1e6 }; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: statTimeMs(ownerIdentity.mtimeNs) }; } catch { return null; } diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index c6dc6150891..dbd9c281c82 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -11,7 +11,8 @@ import { tmpdir } from "node:os"; import { basename, join, sep } from "node:path"; import { autoRestoreCodexShim, installCodexShim, uninstallCodexShim } from "../../src/codex/shim"; import { createWorkerCommitWindow, createCodexCliUpdateFence, codexCliUpdateSelectionGate } from "../../src/codex/cli-update-selection-gate"; -import { stableShimPathProbe } from "../../src/codex/shim-fingerprint"; +import { sameFingerprint, stableShimPathProbe } from "../../src/codex/shim-fingerprint"; +import { ownedWrapperFingerprint, wrapperInodeIsOurs } from "../../src/codex/shim-wrapper-write"; import { readBoundedRegularFile } from "../../src/codex/shim-state-file"; import { tryAcquireShimRestoreLock } from "../../src/codex/shim-restore-lock"; import { refreshObsoleteWindowsShim } from "../../src/codex/shim-windows-refresh"; @@ -69,6 +70,39 @@ test("shim probe rejects adjacent NTFS-sized file identities", () => { finally { stat.mockRestore(); } }); +for (const [name, owns] of [ + ["wrapperInodeIsOurs", (path, probe, written) => wrapperInodeIsOurs(probe, written, undefined)], + ["ownedWrapperFingerprint", (path, _probe, written) => ownedWrapperFingerprint(path, written) !== undefined], +] as const satisfies ReadonlyArray>, written: { dev: bigint; ino: bigint }) => boolean]>) test(`${name} rejects adjacent identities with equal numeric fields`, () => { + const path = join(dir, "launcher"); + fs.writeFileSync(path, "launcher fixture"); + const id = 2n ** 53n; + const real = fs.lstatSync; + const stat = spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const value = options?.bigint ? real(target, { bigint: true }) : real(target); + return target === path ? Object.assign(Object.create(Object.getPrototypeOf(value)), value, + { dev: options?.bigint ? 1n : 1, ino: options?.bigint ? id : Number(id) }) : value; + }) as typeof fs.lstatSync); + try { + const probe = stableShimPathProbe(path)!; + const written = { dev: 1n, ino: id + 1n }; + expect(probe.fingerprint.ino).toBe(Number(written.ino)); + expect(owns(path, probe, written)).toBe(false); + // A legacy numeric observation must not regain ownership after rounding. + const legacy = { dev: 1, ino: Number(written.ino) } as unknown as typeof written; + expect(owns(path, probe, legacy)).toBe(false); + } finally { stat.mockRestore(); } +}); + +test("fingerprint comparisons fail closed when identity is missing", () => { + const path = join(dir, "launcher"); + fs.writeFileSync(path, "launcher fixture"); + const fingerprint = stableShimPathProbe(path)!.fingerprint; + const missing = { ...fingerprint, identity: undefined } as unknown as typeof fingerprint; + expect(sameFingerprint(fingerprint, missing)).toBe(false); + expect(sameFingerprint(missing, fingerprint)).toBe(false); +}); + test("shim state read rejects adjacent NTFS-sized file identities", () => { const path = join(dir, "state.json"); fs.writeFileSync(path, "{}"); diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts index c53c550a521..d11920d7080 100644 --- a/tests/lib/file-identity-callers.test.ts +++ b/tests/lib/file-identity-callers.test.ts @@ -17,6 +17,7 @@ const scopes: Record = { // shim-fingerprint.ts and shim-state-file.ts keep numeric dev/ino fields for the persisted shim // state format; their runtime comparisons use bigint identity and have behavioral coverage. "src/codex/shim-restore-lock.ts": ["readShimRestoreLockSnapshot", "tryAcquireShimRestoreLock"], + "src/codex/shim-wrapper-write.ts": ["writeShim"], "src/remote-control/workspace-executor.ts": ["assertOpenedRegularFile", "assertApprovedRootIdentity"], "src/config/initialize.ts": ["identifiesDescriptor"], "src/service/state-lock.ts": ["readLockSnapshot"], diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts index d5e474db615..fee0aaac4b7 100644 --- a/tests/lib/file-identity.test.ts +++ b/tests/lib/file-identity.test.ts @@ -1,11 +1,32 @@ import { expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fileIdentityKey, sameFileIdentity } from "../../src/lib/file-identity"; +import { fileIdentityKey, sameFileIdentity, statTimeMs } from "../../src/lib/file-identity"; import { readCodexHomeJournal } from "../../src/codex/codex-home-owner"; import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; import { readBoundedLocalFile } from "../../src/codex/orca-auth-source"; import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("bigint timestamps match numeric stat timestamps", () => { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-stat-time-")); + try { + const path = join(root, "file"); + fs.writeFileSync(path, "fixture"); + let roundingDifferences = 0; + for (const time of [1793358320.9811772, 1793358320.5383171, 1700000000.123456789, 1.5]) { + fs.utimesSync(path, time, time); + const big = fs.lstatSync(path, { bigint: true }); + const numeric = fs.lstatSync(path); + expect(statTimeMs(big.mtimeNs)).toBe(numeric.mtimeMs); + expect(statTimeMs(big.ctimeNs)).toBe(numeric.ctimeMs); + if (Number(big.mtimeNs) / 1e6 !== numeric.mtimeMs) roundingDifferences++; + } + // Some platforms round both calculations identically for these timestamps. + if (roundingDifferences > 0) expect(roundingDifferences).toBeGreaterThan(0); + } finally { removeTreeWithRetry(root); } +}); test("compares and formats adjacent large file IDs", () => { const a = { dev: 7n, ino: 2n ** 53n }; From 018d7a461abb2fe6c254a5f5e5f588cc53cc9a0c Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:45:02 +0900 Subject: [PATCH 3/3] test: pin the stat millisecond formula with a deterministic case --- tests/lib/file-identity.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts index fee0aaac4b7..f91b709d100 100644 --- a/tests/lib/file-identity.test.ts +++ b/tests/lib/file-identity.test.ts @@ -14,20 +14,23 @@ test("bigint timestamps match numeric stat timestamps", () => { try { const path = join(root, "file"); fs.writeFileSync(path, "fixture"); - let roundingDifferences = 0; for (const time of [1793358320.9811772, 1793358320.5383171, 1700000000.123456789, 1.5]) { fs.utimesSync(path, time, time); const big = fs.lstatSync(path, { bigint: true }); const numeric = fs.lstatSync(path); expect(statTimeMs(big.mtimeNs)).toBe(numeric.mtimeMs); expect(statTimeMs(big.ctimeNs)).toBe(numeric.ctimeMs); - if (Number(big.mtimeNs) / 1e6 !== numeric.mtimeMs) roundingDifferences++; } - // Some platforms round both calculations identically for these timestamps. - if (roundingDifferences > 0) expect(roundingDifferences).toBeGreaterThan(0); } finally { removeTreeWithRetry(root); } }); +test("stat milliseconds use whole seconds plus the nanosecond remainder", () => { + // Dividing the whole nanosecond count rounds differently from the runtime's sec*1e3 + nsec/1e6. + const ns = 1793358320981177200n; + expect(statTimeMs(ns)).toBe(1793358320981.1772); + expect(Number(ns) / 1e6).toBe(1793358320981.177); +}); + test("compares and formats adjacent large file IDs", () => { const a = { dev: 7n, ino: 2n ** 53n }; const b = { dev: 7n, ino: a.ino + 1n };