diff --git a/scripts/test-layout/layout-file-identity.json b/scripts/test-layout/layout-file-identity.json new file mode 100644 index 00000000000..e9595bddef2 --- /dev/null +++ b/scripts/test-layout/layout-file-identity.json @@ -0,0 +1,4 @@ +{ + "file-identity.test.ts": "lib", + "file-identity-callers.test.ts": "lib" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2442a618c9c..3232f870b0f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], + "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], "explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index c79eff473c1..617cbfeda62 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -189,15 +189,15 @@ function truncateUtf8(text: string, capBytes: number): string { async function openedFileIsConfined( handle: Awaited>, path: string, cwdCanonical: string, deadline: number, scope: ScanScope, ): Promise { - const opened = await withinDeadline(() => handle.stat(), deadline, scope); + const opened = await withinDeadline(() => handle.stat({ bigint: true }), deadline, scope); if (!opened.isFile()) return false; - const current = await withinDeadline(() => lstat(path), deadline, scope); + const current = await withinDeadline(() => lstat(path, { bigint: true }), deadline, scope); if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false; const resolved = await withinDeadline(() => realpath(path), deadline, scope); // The input path was canonical before open. A changed intermediate symlink changes this // result even though O_NOFOLLOW protects only the final component on macOS and Linux. if (!isContainedCanonicalPath(cwdCanonical, resolved) || resolved !== path) return false; - const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope); + const resolvedInfo = await withinDeadline(() => lstat(resolved, { bigint: true }), deadline, scope); return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; } diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts index 97579222def..4d33891862b 100644 --- a/src/claude/desktop-remote-store-io.ts +++ b/src/claude/desktop-remote-store-io.ts @@ -1,4 +1,4 @@ -import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync, type BigIntStats } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { hardenSecretDir } from "../lib/windows-secret-acl"; @@ -8,7 +8,7 @@ import { DesktopStoreError, digest } from "./desktop-remote-store-state"; export const MAX_DESKTOP_METADATA_ENTRIES = 256; export interface JsonFile { value: Record; hash: string; identity: string } -const identity = (s: NonNullable>): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`; +const identity = (s: BigIntStats): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}`; function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; } export function canonicalDirectory(path: string): string { const absolute = resolve(path); @@ -33,20 +33,20 @@ export class StoreIO { private bytes = 0; private readonly seenSizes = new Map(); read(path: string, maxBytes = 1024 * 1024, privateFile = false): JsonFile | null { - let stat: ReturnType; - try { stat = lstatSync(path); } + let stat: BigIntStats; + try { stat = lstatSync(path, { bigint: true }); } catch (error) { if (missing(error)) return null; throw new DesktopStoreError("unsafe"); } if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes - || (privateFile && process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new DesktopStoreError("unsafe"); + || (privateFile && process.platform !== "win32" && (stat.mode & 0o077n) !== 0n)) throw new DesktopStoreError("unsafe"); const counted = this.seenSizes.get(path) ?? 0; - this.bytes += Math.max(0, stat.size - counted); - this.seenSizes.set(path, Math.max(counted, stat.size)); + this.bytes += Math.max(0, Number(stat.size) - counted); + this.seenSizes.set(path, Math.max(counted, Number(stat.size))); if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe"); try { // The shared verified regular-file read keeps a swapped FIFO from parking this // open before the post-read identity recheck below can run. const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES)); - if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict"); + if (bytes.byteLength > maxBytes || identity(lstatSync(path, { bigint: true })) !== identity(stat)) throw new DesktopStoreError("conflict"); const value: unknown = JSON.parse(bytes.toString("utf8")); if (!isRecord(value)) throw new DesktopStoreError("unsafe"); return { value, hash: digest(bytes.toString("utf8")), identity: identity(stat) }; diff --git a/src/claude/intercept/proxy-auth.ts b/src/claude/intercept/proxy-auth.ts index a1131b5f913..80ea683119b 100644 --- a/src/claude/intercept/proxy-auth.ts +++ b/src/claude/intercept/proxy-auth.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, - openSync, readSync, unlinkSync, writeFileSync, type Stats, + openSync, readSync, unlinkSync, writeFileSync, type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../../lib/windows-secret-acl"; @@ -20,15 +20,15 @@ function invalidCredential(): Error { return new Error("Claude intercept credential is not a safe, valid owner-controlled file"); } -function assertOwnedNode(stat: Stats, directory = false): void { +function assertOwnedNode(stat: BigIntStats, directory = false): void { if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) throw invalidCredential(); - if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== process.geteuid()) { + if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== BigInt(process.geteuid())) { throw invalidCredential(); } } -function assertSameNode(path: string, expected: Stats, directory = false): void { - const current = lstatSync(path); +function assertSameNode(path: string, expected: BigIntStats, directory = false): void { + const current = lstatSync(path, { bigint: true }); assertOwnedNode(current, directory); if (current.dev !== expected.dev || current.ino !== expected.ino) throw invalidCredential(); } @@ -36,17 +36,17 @@ function assertSameNode(path: string, expected: Stats, directory = false): void /** Read only through the validated descriptor, with a fixed maximum allocation and no links. */ function readPinnedToken(configDir: string, harden: boolean): string { const dir = join(configDir, "claude-intercept"); - const directory = lstatSync(dir); + const directory = lstatSync(dir, { bigint: true }); assertOwnedNode(directory, true); - if (process.platform !== "win32" && (directory.mode & 0o077) !== 0) throw invalidCredential(); + if (process.platform !== "win32" && (directory.mode & 0o077n) !== 0n) throw invalidCredential(); const path = claudeInterceptProxyTokenPath(configDir); - const entry = lstatSync(path); + const entry = lstatSync(path, { bigint: true }); assertOwnedNode(entry); if (entry.size < TOKEN_LENGTH || entry.size > TOKEN_MAX_BYTES) throw invalidCredential(); // NONBLOCK prevents a raced-in FIFO from blocking before fstat can reject it. const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK); try { - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertOwnedNode(opened); if (opened.dev !== entry.dev || opened.ino !== entry.ino || opened.size < TOKEN_LENGTH || opened.size > TOKEN_MAX_BYTES) throw invalidCredential(); @@ -62,8 +62,8 @@ function readPinnedToken(configDir: string, harden: boolean): string { const ending = contents.slice(TOKEN_LENGTH); if (token.length !== TOKEN_LENGTH || !/^[A-Za-z0-9_-]+$/.test(token) || !["", "\n", "\r\n"].includes(ending)) throw invalidCredential(); - const afterRead = fstatSync(fd); - if (length !== afterRead.size || afterRead.mtimeMs !== opened.mtimeMs) throw invalidCredential(); + const afterRead = fstatSync(fd, { bigint: true }); + if (BigInt(length) !== afterRead.size || afterRead.mtimeNs !== opened.mtimeNs) throw invalidCredential(); assertSameNode(path, opened); assertSameNode(dir, directory, true); if (harden) { @@ -71,7 +71,7 @@ function readPinnedToken(configDir: string, harden: boolean): string { else fchmodSync(fd, 0o600); // Never chmod a followed/replaced pathname. assertSameNode(path, opened); assertSameNode(dir, directory, true); - } else if (process.platform !== "win32" && (afterRead.mode & 0o077) !== 0) { + } else if (process.platform !== "win32" && (afterRead.mode & 0o077n) !== 0n) { throw invalidCredential(); } return token; @@ -89,7 +89,7 @@ export function readClaudeInterceptProxyToken(configDir: string): string | null } } -function ensurePrivateDirectory(configDir: string): Stats { +function ensurePrivateDirectory(configDir: string): BigIntStats { const dir = join(configDir, "claude-intercept"); mkdirSync(configDir, { recursive: true, mode: 0o700 }); try { @@ -97,14 +97,14 @@ function ensurePrivateDirectory(configDir: string): Stats { } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } - const entry = lstatSync(dir); + const entry = lstatSync(dir, { bigint: true }); assertOwnedNode(entry, true); if (process.platform === "win32") { hardenSecretDir(dir, { required: true }); } else { const fd = openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW); try { - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertOwnedNode(opened, true); if (entry.dev !== opened.dev || entry.ino !== opened.ino) throw invalidCredential(); fchmodSync(fd, 0o700); @@ -130,7 +130,7 @@ export function ensureClaudeInterceptProxyToken(configDir: string): string { const token = randomBytes(32).toString("base64url"); const temp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600); - const created = fstatSync(fd); + const created = fstatSync(fd, { bigint: true }); try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); if (process.platform !== "win32") fchmodSync(fd, 0o600); diff --git a/src/codex/catalog/write-audit.ts b/src/codex/catalog/write-audit.ts index 37aeb932056..12b17934344 100644 --- a/src/codex/catalog/write-audit.ts +++ b/src/codex/catalog/write-audit.ts @@ -1,6 +1,6 @@ import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync, - type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; import { redactUserPath } from "../../lib/redact"; @@ -40,15 +40,15 @@ function auditLine(event: CatalogWriteAuditEvent): Buffer { })}\n`); } -function regularPrivateFile(stat: Stats): boolean { +function regularPrivateFile(stat: BigIntStats): boolean { // POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening. - return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1 - && stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600; + return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1n + && stat.uid === BigInt(process.getuid?.() ?? -1) && (stat.mode & 0o777n) === 0o600n; } -function sameFile(left: Stats, right: Stats): boolean { +function sameFile(left: BigIntStats, right: BigIntStats): boolean { return left.dev === right.dev && left.ino === right.ino && left.size === right.size - && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; + && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs; } /** Read at most the tail budget plus one byte to locate a complete first record. */ @@ -116,8 +116,8 @@ export function appendCatalogWriteAudit( if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped"; const path = codexCatalogAuditPath(codexHome); const windows = windowsSecretAclApplies(); - let before: Stats | undefined; - try { before = lstatSync(path); } + let before: BigIntStats | undefined; + try { before = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped"; } @@ -126,29 +126,29 @@ export function appendCatalogWriteAudit( const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL); fd = openSync(path, flags, 0o600); - let opened = fstatSync(fd); + let opened = fstatSync(fd, { bigint: true }); if (windows) { // Existing files have already been refused: no read-only NTFS privacy verifier exists. - if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0 - || !sameFile(opened, lstatSync(path))) return "skipped"; + if (!opened.isFile() || opened.nlink !== 1n || opened.size !== 0n + || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped"; if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped"; - const hardened = fstatSync(fd); + const hardened = fstatSync(fd, { bigint: true }); // ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline. - if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0 + if (!hardened.isFile() || hardened.nlink !== 1n || hardened.size !== 0n || opened.dev !== hardened.dev || opened.ino !== hardened.ino - || !sameFile(hardened, lstatSync(path))) return "skipped"; + || !sameFile(hardened, lstatSync(path, { bigint: true }))) return "skipped"; opened = hardened; } else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped"; - if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped"; - const tail = readTail(fd, opened.size); + if (!options.create && opened.size + BigInt(line.length) > CATALOG_AUDIT_MAX_BYTES) return "skipped"; + const tail = readTail(fd, Number(opened.size)); const parsed = completeRecords(tail); - if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped"; + if (!sameFile(opened, fstatSync(fd, { bigint: true })) || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped"; if (!options.create) { if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped"; writeAll(fd, line, null); } else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length && parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) { - writeAll(fd, line, opened.size); + writeAll(fd, line, Number(opened.size)); } else { const retained: Buffer[] = [line]; let bytes = line.length; diff --git a/src/config/initialize.ts b/src/config/initialize.ts index ed9d121eaae..e87a4d1fd2a 100644 --- a/src/config/initialize.ts +++ b/src/config/initialize.ts @@ -43,16 +43,16 @@ function hardenInitialConfig(fd: number, temp: string, target: string): void { } } -function identifiesDescriptor(fd: number, path: string): boolean { - const opened = fstatSync(fd); - const entry = lstatSync(path); +function identifiesDescriptor(fd: number, path: string, privateMode = false): boolean { + const opened = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); return opened.isFile() && entry.isFile() - && opened.dev === entry.dev && opened.ino === entry.ino; + && opened.dev === entry.dev && opened.ino === entry.ino + && (!privateMode || process.platform === "win32" || (opened.mode & 0o777n) === 0o600n); } function verifyPrivateTemp(fd: number, temp: string): void { - if (!identifiesDescriptor(fd, temp) - || (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) { + if (!identifiesDescriptor(fd, temp, true)) { throw new Error("Initial config temporary file identity or permissions changed."); } } diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts index f5091b4b549..5c5c170a917 100644 --- a/src/integrations/aside-profile-context.ts +++ b/src/integrations/aside-profile-context.ts @@ -74,7 +74,7 @@ function storeGuard(anchor: string, target: string): () => void { const identities = new Map(); const inspect = (path: string, directory: boolean): boolean => { try { - const stat = lstatSync(path); + const stat = lstatSync(path, { bigint: true }); if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe(); if (directory) { const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`; diff --git a/src/integrations/droid-settings.ts b/src/integrations/droid-settings.ts index 0c0989f8130..5bec667200e 100644 --- a/src/integrations/droid-settings.ts +++ b/src/integrations/droid-settings.ts @@ -1,5 +1,5 @@ /** Read-only guard for competing Factory settings before a managed write. */ -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs"; import { join, win32 } from "node:path"; const isWindowsRoot = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path); @@ -26,8 +26,8 @@ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, m } for (const name of ["config.json", "settings.local.json"]) { const path = isWindowsRoot(root) ? win32.join(root, name) : join(root, name); - let stat: ReturnType; - try { stat = lstatSync(path); } + let stat: BigIntStats; + try { stat = lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error(`Cannot inspect Factory ${name}`); @@ -37,7 +37,7 @@ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, m let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== stat.ino || opened.dev !== stat.dev) { throw new Error("file changed during inspection"); } diff --git a/src/integrations/hermes-profiles.ts b/src/integrations/hermes-profiles.ts index e4bda8d2efb..3ffc0dfc3ef 100644 --- a/src/integrations/hermes-profiles.ts +++ b/src/integrations/hermes-profiles.ts @@ -16,7 +16,7 @@ * `/hermes-profiles//`, so its records, journal and snapshots * can never be confused with the default profile's. */ -import { lstatSync, readdirSync, type Stats } from "node:fs"; +import { lstatSync, readdirSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { hermesHomeDir } from "../clients/config-export"; @@ -46,8 +46,8 @@ export class HermesProfileError extends Error { } } -function lstatOrNull(path: string): Stats | null { - try { return lstatSync(path); } +function lstatOrNull(path: string): BigIntStats | null { + try { return lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return null; throw new HermesProfileError("hermes_profiles_unavailable", 409, "Hermes profiles cannot be read"); diff --git a/src/lib/bounded-file-read.ts b/src/lib/bounded-file-read.ts index 42555c57ed2..f37045d0b2c 100644 --- a/src/lib/bounded-file-read.ts +++ b/src/lib/bounded-file-read.ts @@ -1,15 +1,9 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { sameFileIdentity } from "./file-identity"; const MAX_BOUNDED_READ_BYTES = 64 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; -function sameIdentity( - left: { dev: bigint; ino: bigint }, - right: { dev: bigint; ino: bigint }, -): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - export function readBoundedRegularFileSync(path: string, limit: number): Buffer { if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_BOUNDED_READ_BYTES) { throw new RangeError("invalid bounded file read limit"); @@ -25,7 +19,7 @@ export function readBoundedRegularFileSync(path: string, limit: number): Buffer try { const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("file is not bounded regular data"); - if (entryBefore && (!sameIdentity(entryBefore, opened) || opened.ino === 0n)) { + if (entryBefore && (!sameFileIdentity(entryBefore, opened) || opened.ino === 0n)) { throw new Error("file entry changed while opening"); } @@ -47,7 +41,7 @@ export function readBoundedRegularFileSync(path: string, limit: number): Buffer if (entryBefore) { const entryAfter = lstatSync(path, { bigint: true }); - if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameIdentity(opened, entryAfter)) { + if (entryAfter.isSymbolicLink() || !entryAfter.isFile() || !sameFileIdentity(opened, entryAfter)) { throw new Error("file entry changed while reading"); } } diff --git a/src/lib/file-identity.ts b/src/lib/file-identity.ts new file mode 100644 index 00000000000..86474299131 --- /dev/null +++ b/src/lib/file-identity.ts @@ -0,0 +1,10 @@ +/** Acquire stats with { bigint: true }; never widen a Number to form an identity. */ +export type FileIdentity = Readonly<{ dev: bigint; ino: bigint }>; + +export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino; +} + +export function fileIdentityKey(id: FileIdentity): string { + return `${id.dev}:${id.ino}`; +} diff --git a/src/lib/spend-pool-alias-validation.ts b/src/lib/spend-pool-alias-validation.ts index 344ad179967..e07c2dbcac2 100644 --- a/src/lib/spend-pool-alias-validation.ts +++ b/src/lib/spend-pool-alias-validation.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type Stats } from "node:fs"; +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type BigIntStats } from "node:fs"; import { join } from "node:path"; /** Read an existing single-owner salt without creating or hardening any file. */ @@ -8,14 +8,14 @@ export function readExistingSpendSalt(home: string): string | undefined { let salt: string | undefined; try { const path = join(home, "spend-ledger.salt"); - const before = lstatSync(path); - if (!before.isFile() || before.nlink !== 1) return undefined; - const matches = (stat: Stats): boolean => stat.isFile() && stat.nlink === 1 + const before = lstatSync(path, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n) return undefined; + const matches = (stat: BigIntStats): boolean => stat.isFile() && stat.nlink === 1n && stat.dev === before.dev && stat.ino === before.ino; fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - if (!matches(fstatSync(fd))) return undefined; + if (!matches(fstatSync(fd, { bigint: true }))) return undefined; const contents = readFileSync(fd, "utf8").trim(); - if (matches(fstatSync(fd)) && matches(lstatSync(path)) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents; + if (matches(fstatSync(fd, { bigint: true })) && matches(lstatSync(path, { bigint: true })) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents; } catch { salt = undefined; } finally { if (fd !== undefined) { diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 12c4339ab8b..2985194917b 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -429,18 +429,18 @@ function persist(store: AuthStore): void { } export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } } -interface LockSnapshot { bytes: string; dev: number; ino: number; mtimeMs: number; size: number } +interface LockSnapshot { bytes: string; dev: bigint; ino: bigint; mtimeMs: number; mtimeNs: bigint; size: bigint } 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); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:s.mtimeMs, size:s.size }; } -function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; } -function sameFd(a: LockSnapshot,b: ReturnType): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.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:Number(s.mtimeNs)/1e6, 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 } { const wait=options.waitTimeoutMs??5000, stale=options.staleAfterMs??120000, min=options.pollMinMs??25,max=options.pollMaxMs??100,sleep=options.sleep??(ms=>Bun.sleep(ms)),now=options.now??Date.now,random=options.random??Math.random,write=options.writeMetadata??((fd,b)=>writeFileSync(fd,b,"utf8")); if(wait<0||stale<=0||min<0||maxstale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } }; + return { async acquire() { hardenConfigDir(); recordOwnedConfigPath(getConfigDir(),options.path); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd,{bigint:true}); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd,{bigint:true});}catch{/* best effort: no identity, no cleanup */}try{closeSync(fd);}catch{/* best effort */}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{/* best-effort cleanup of our own failed create */}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); } + try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{/* unparsable metadata: fall back to mtime */}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } }; } /** Wait long enough for slow IdP refreshes (e.g. Cursor 15s × 3 attempts) before timing out. */ export const OAUTH_REFRESH_LOCK_WAIT_MS = 60_000; diff --git a/src/providers/cc-switch-import-source.ts b/src/providers/cc-switch-import-source.ts index 576ad2c7300..8f11dc834cf 100644 --- a/src/providers/cc-switch-import-source.ts +++ b/src/providers/cc-switch-import-source.ts @@ -971,10 +971,10 @@ function scanCcSwitchJsonBytesInternal(bytes: Uint8Array): RawExtractedRow[] { export function readSourceRows(sourcePath: string, requireSqlite = false): RawExtractedRow[] { const resolved = resolve(sourcePath); - let beforeStat: ReturnType; + let beforeStat: import("node:fs").BigIntStats; try { assertPlainLocalPath(resolved); - beforeStat = lstatSync(resolved); + beforeStat = lstatSync(resolved, { bigint: true }); } catch { throw new CcSwitchImportError("CC-Switch source path must be a plain regular local file without symlink traversal."); } @@ -993,7 +993,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile()) { throw new CcSwitchImportError("CC-Switch source path must be a regular file."); } @@ -1006,7 +1006,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx ) { throw new CcSwitchImportError("CC-Switch source file changed while opening."); } - size = opened.size; + size = Number(opened.size); const n = readSync(fd, header, 0, 16, 0); if (hasSqliteMagicBytes(header, n)) { isSqlite = true; @@ -1038,8 +1038,8 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx assertNoUncheckpointedSqliteWal(resolved); } assertPlainLocalPath(resolved); - const afterStat = lstatSync(resolved); - const afterFd = fstatSync(fd); + const afterStat = lstatSync(resolved, { bigint: true }); + const afterFd = fstatSync(fd, { bigint: true }); if (isSqlite && afterFd.nlink > 1) { throw new CcSwitchImportError("CC-Switch SQLite source has multiple hard links; import the original CC-Switch database path directly."); } @@ -1051,7 +1051,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx || afterStat.gid !== opened.gid || afterStat.mode !== opened.mode || afterStat.size !== opened.size - || afterStat.mtimeMs !== opened.mtimeMs + || afterStat.mtimeNs !== opened.mtimeNs || afterFd.dev !== opened.dev || afterFd.ino !== opened.ino || afterFd.nlink !== opened.nlink @@ -1059,7 +1059,7 @@ export function readSourceRows(sourcePath: string, requireSqlite = false): RawEx || afterFd.gid !== opened.gid || afterFd.mode !== opened.mode || afterFd.size !== opened.size - || afterFd.mtimeMs !== opened.mtimeMs + || afterFd.mtimeNs !== opened.mtimeNs ) { throw new CcSwitchImportError("CC-Switch source file changed while reading."); } diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts index 0327481abfa..d45c08f2dcd 100644 --- a/src/server/persistent-gui-session.ts +++ b/src/server/persistent-gui-session.ts @@ -60,13 +60,13 @@ function readRecords(): DeviceRecord[] { try { fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } try { - const stat = fstatSync(fd); - const entry = lstatSync(path); - if (!stat.isFile() || stat.nlink !== 1 || stat.size > STORE_LIMIT || entry.isSymbolicLink() + const stat = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); + if (!stat.isFile() || stat.nlink !== 1n || stat.size > STORE_LIMIT || entry.isSymbolicLink() || stat.dev !== entry.dev || stat.ino !== entry.ino) throw new Error("unsafe dashboard session store"); // Size the read from the stat we validated: a store that grows mid-read overflows // the +1 sentinel byte and is rejected instead of silently truncated at a fixed limit. - const bytes = Buffer.alloc(stat.size + 1); + const bytes = Buffer.alloc(Number(stat.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 > stat.size) throw new Error("dashboard session store changed during read"); diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index 1c9a56ec127..87ca19ef3bc 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -334,7 +334,7 @@ export async function readBoundedJsonRequestBody( } const parsed = JSON.parse(text); // Keep the serialized-size contract: normalization can expand numeric literals. - const parsedBytes = budget ? Buffer.byteLength(JSON.stringify(parsed), "utf8") : decoded.byteLength; + const parsedBytes = Buffer.byteLength(JSON.stringify(parsed), "utf8"); budget?.observeAcceptedRequestCopy(parsedBytes); updateInboundBodyReservation(req, Math.max(raw.byteLength, decoded.byteLength, parsedBytes)); return parsed; diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts index 6bc2fabfb92..d57b85b0b69 100644 --- a/src/service/state-lock.ts +++ b/src/service/state-lock.ts @@ -12,7 +12,7 @@ import { rmdirSync, unlinkSync, writeFileSync, - type Stats, + type BigIntStats, } from "node:fs"; import { basename, dirname, join } from "node:path"; @@ -32,8 +32,8 @@ interface ServiceStateLockRecord { interface ServiceStateLockSnapshot { readonly record: ServiceStateLockRecord; readonly ownerPath: string; - readonly lockIdentity: Pick; - readonly ownerIdentity: Pick; + readonly lockIdentity: Pick; + readonly ownerIdentity: Pick; readonly mtimeMs: number; } @@ -72,8 +72,8 @@ function lockOwnerProcessAlive(pid: number): boolean { } function sameIdentity( - left: Pick, - right: Pick, + left: Pick, + right: Pick, ): boolean { return left.dev === right.dev && left.ino === right.ino; } @@ -90,10 +90,10 @@ function parseOwnerFileName(name: string): { pid: number; pathToken: string } | } function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { - let lockIdentity: Stats; + let lockIdentity: BigIntStats; let entries: string[]; try { - lockIdentity = lstatSync(lockPath); + lockIdentity = lstatSync(lockPath, { bigint: true }); if (!lockIdentity.isDirectory()) return null; entries = readdirSync(lockPath); } catch { @@ -102,7 +102,7 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null; const ownerPath = join(lockPath, entries[0]!); try { - const ownerIdentity = lstatSync(ownerPath); + const ownerIdentity = lstatSync(ownerPath, { bigint: true }); if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null; const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial; if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0 @@ -111,12 +111,12 @@ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; const record = value as ServiceStateLockRecord; if (entries[0] !== ownerFileName(record)) return null; - const currentLock = lstatSync(lockPath); - const currentOwner = lstatSync(ownerPath); + const currentLock = lstatSync(lockPath, { bigint: true }); + const currentOwner = lstatSync(ownerPath, { bigint: true }); if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) || currentOwner.size !== ownerIdentity.size) return null; - return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs }; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: Number(ownerIdentity.mtimeNs) / 1e6 }; } catch { return null; } diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index ff55305299f..b8625f04444 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -105,7 +105,7 @@ discarding active leases or FIFO waiters; lowering capacity cannot erase outstan checks, before any request body is read. Default and smaller allowances do not consume this budget. Missing or small Content-Length and compressed wire bodies do not reduce the upload reservation. After successful bounded JSON parsing, the reader reduces it to the largest observed wire/decoded -size, including normalized JSON bytes when the existing translator measurement is enabled. +size, including normalized JSON bytes whether or not the route uses a translator budget. This reduction never exceeds the original allowance. The measured charge remains live; parsing never frees the whole lease. An already-aborted request or explicitly oversized declaration keeps the existing abort/413 path. diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 3ab33edae87..1595da1f9b4 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -25,6 +25,8 @@ import { firstPartyDesired } from "../../src/claude/first-party-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { resolveCurrentWindowsPrincipal } from "../../src/lib/windows-user-principal"; let root = ""; let lifecycleLock: ReturnType; @@ -72,10 +74,23 @@ beforeEach(() => { process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeFileSync(join(root, "config.json"), JSON.stringify(config())); + // The local-CA ACL inspection spawns PowerShell under the shared 30 s principal-lookup budget, + // which a loaded hosted Windows runner can exhaust (local_ca_path_unsafe). This file tests + // first-party apply; claude-intercept-local-ca.test.ts runs the native inspection. Report the + // private ACL for the current SID, which production resolves (and caches) before this runner. + if (process.platform === "win32") { + setLocalCaWindowsAclRunnerForTests((_path, timeoutMs) => { + const sid = resolveCurrentWindowsPrincipal(timeoutMs).replace(/^\*/, ""); + return { success: true, exitCode: 0, timedOut: false, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) }; + }); + } }); afterEach(async () => { - await flushConfigDirHardeningAndReaps(root); + try { await flushConfigDirHardeningAndReaps(root); } + finally { setLocalCaWindowsAclRunnerForTests(null); } lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; diff --git a/tests/codex-integration/codex-catalog-write-audit.test.ts b/tests/codex-integration/codex-catalog-write-audit.test.ts index a2ce4b036c6..a75d2b1c9a2 100644 --- a/tests/codex-integration/codex-catalog-write-audit.test.ts +++ b/tests/codex-integration/codex-catalog-write-audit.test.ts @@ -595,11 +595,11 @@ describe("Windows audit privacy (r4176128642)", () => { const realFstat = fs.fstatSync; const realLstat = fs.lstatSync; // Deterministic ACL metadata transition; no dependency on filesystem clock resolution. - const postAcl = (stat: fs.Stats) => Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, - { ctimeMs: stat.ctimeMs + (hardened ? 1 : 0) }) as fs.Stats; - const fstat = spyOn(fs, "fstatSync").mockImplementation(fd => postAcl(realFstat(fd))); - const lstat = spyOn(fs, "lstatSync").mockImplementation(path => - path === auditPath() ? postAcl(realLstat(path)) : realLstat(path)); + const postAcl = (stat: fs.BigIntStats) => Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ctimeNs: stat.ctimeNs + (hardened ? 1_000_000n : 0n) }) as fs.BigIntStats; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => options?.bigint ? postAcl(realFstat(fd, { bigint: true })) : realFstat(fd)) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => + path === auditPath() && options?.bigint ? postAcl(realLstat(path, { bigint: true })) : realLstat(path)) as typeof fs.lstatSync); const write = spyOn(fs, "writeSync"); const open = spyOn(fs, "openSync"); harden.mockImplementation((path, options) => { @@ -620,6 +620,25 @@ describe("Windows audit privacy (r4176128642)", () => { } finally { fstat.mockRestore(); lstat.mockRestore(); write.mockRestore(); open.mockRestore(); } }); + test("file identity preserves adjacent NTFS-sized values", () => { + const realFstat = fs.fstatSync; + const realLstat = fs.lstatSync; + const identity = 9_007_199_254_740_992n; + 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 ? identity : Number(identity) }); + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === auditPath() ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + expect(appendUnderK()).toBe("skipped"); + expect(harden).not.toHaveBeenCalled(); + } finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + for (const code of ["EICACLS", "ETIMEDOUT", "returned-failure"]) { test(`${code} leaves a blank file and preserves catalog publication`, () => { // Only the audit file's ACL fails. On real Windows the catalog writer also hardens its own diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 278ed3d80a9..4b3103cc0bb 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.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 { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -285,6 +286,28 @@ test("real link collision preserves the winner and does not advance generation o expect(initTemps()).toEqual([]); }); +test("initial config file identity preserves adjacent NTFS-sized values", () => { + const identity = 9_007_199_254_740_992n; + let ownedFd = -1, tempPath = ""; + const realFstat = fs.fstatSync, realLstat = fs.lstatSync; + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return fd === ownedFd ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }) : stat; + }) as typeof fs.fstatSync); + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + return path === tempPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.lstatSync); + try { + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + harden(fd, temp) { ownedFd = fd; tempPath = temp; }, + })).toThrow(InitialConfigPublicationError); + expect(existsSync(getConfigPath())).toBe(false); + } finally { fstat.mockRestore(); lstat.mockRestore(); } +}); + test("exclusive temp collision does not remove or modify somebody else's file", () => { const sequence = nextAtomicTempSequence() + 1; const occupied = `${getConfigPath()}.ocx.${process.pid}.${sequence}.tmp`; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7939d5f9243..b97dd7e3e9c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -132,6 +132,8 @@ "messaging-remote-enrollment-gaps.test.ts": "codex-integration", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", + "file-identity.test.ts": "lib", + "file-identity-callers.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts new file mode 100644 index 00000000000..9adb4fea11c --- /dev/null +++ b/tests/lib/file-identity-callers.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +// NTFS file IDs exceed 2^53; these modules compare file identity, which must come from stat +// 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/config/initialize.ts": ["identifiesDescriptor"], + "src/service/state-lock.ts": ["readLockSnapshot"], + "src/oauth/store.ts": ["snapshot", "createOAuthFileLock"], + "src/claude/intercept/proxy-auth.ts": ["assertSameNode", "readPinnedToken", "ensurePrivateDirectory", "ensureClaudeInterceptProxyToken"], + "src/claude/desktop-remote-store-io.ts": ["read"], + "src/server/persistent-gui-session.ts": ["readRecords"], + "src/lib/spend-pool-alias-validation.ts": ["readExistingSpendSalt"], + "src/integrations/droid-settings.ts": ["assertDroidSettingsUnambiguous"], + "src/integrations/aside-profile-context.ts": ["storeGuard"], + "src/integrations/hermes-profiles.ts": ["lstatOrNull"], + "src/providers/cc-switch-import-source.ts": ["readSourceRows"], + "src/adapters/command-code-project-context.ts": ["openedFileIsConfined"], + "src/codex/catalog/write-audit.ts": ["appendCatalogWriteAudit"], +}; + +const REGEX_PRECEDES = new Set(["", "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^"]); +const REGEX_KEYWORDS = /(?:^|[^\w$])(?:return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/; + +/** + * Blank literal text (strings, template text, regex literals, comments) keeping offsets, so only + * code is balanced or matched. Template ${...} expressions stay code. + */ +function maskNonCode(source: string): string { + const out = source.split(""); + const blank = (from: number, to: number) => { for (let at = from; at < to; at++) if (out[at] !== "\n") out[at] = " "; }; + const regexMayStart = (at: number): boolean => { + let back = at - 1; + while (back >= 0 && /\s/.test(out[back]!)) back--; + if (back < 0 || REGEX_PRECEDES.has(out[back]!)) return true; + return REGEX_KEYWORDS.test(out.slice(Math.max(0, back - 11), back + 1).join("")); + }; + const templateDepths: number[] = []; + let braces = 0; + let index = 0; + const scanTemplate = (from: number): number => { + let at = from; + while (at < source.length) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "`") { blank(from, at); return at + 1; } + if (source[at] === "$" && source[at + 1] === "{") { blank(from, at); templateDepths.push(braces); braces++; return at + 2; } + at++; + } + blank(from, at); + return at; + }; + while (index < source.length) { + const char = source[index]!; + const next = source[index + 1]; + if (char === "/" && next === "/") { const end = source.indexOf("\n", index); const stop = end < 0 ? source.length : end; blank(index, stop); index = stop; continue; } + if (char === "/" && next === "*") { const end = source.indexOf("*/", index + 2); const stop = end < 0 ? source.length : end + 2; blank(index, stop); index = stop; continue; } + if (char === "'" || char === '"') { + let at = index + 1; + while (at < source.length && source[at] !== char && source[at] !== "\n") at += source[at] === "\\" ? 2 : 1; + blank(index + 1, at); index = at + 1; continue; + } + if (char === "`") { index = scanTemplate(index + 1); continue; } + if (char === "/" && regexMayStart(index)) { + let at = index + 1; + let inClass = false; + while (at < source.length && source[at] !== "\n" && (inClass || source[at] !== "/")) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "[") inClass = true; + else if (source[at] === "]") inClass = false; + at++; + } + blank(index + 1, at); index = at + 1; continue; + } + if (char === "{") braces++; + if (char === "}") { + braces--; + if (templateDepths.length > 0 && braces === templateDepths[templateDepths.length - 1]) { + templateDepths.pop(); index = scanTemplate(index + 1); continue; + } + } + index++; + } + return out.join(""); +} + +function matching(code: string, open: number, left: string, right: string): number { + let depth = 0; + for (let at = open; at < code.length; at++) { + if (code[at] === left) depth++; + else if (code[at] === right && --depth === 0) return at; + } + return -1; +} + +/** The body of NAME's declaration: a function, or a method head whose body opens on the same line. */ +function declarationBody(code: string, name: string): [number, number] | null { + const head = new RegExp(`(?:\\bfunction\\s+|^[ \\t]*(?:(?:export|async|public|private|protected|static|override)\\s+)*)${name}\\s*\\(`, "gm"); + for (const match of code.matchAll(head)) { + const close = matching(code, match.index! + match[0].length - 1, "(", ")"); + if (close < 0) continue; + let at = close + 1; + 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++; + } + if (code[at] !== "{") continue; + const end = matching(code, at, "{", "}"); + if (end > 0) return [at, end + 1]; + } + return null; +} + +/** The last top-level argument of a call's masked argument text. */ +function lastArgument(args: string): string { + let depth = 0; + let start = 0; + for (let at = 0; at < args.length; at++) { + const char = args[at]!; + if ("([{".includes(char)) depth++; + else if (")]}".includes(char)) depth--; + else if (char === "," && depth === 0) start = at + 1; + } + return args.slice(start).trim(); +} + +function inspect(source: string, names: string[], droid = false): string[] { + const code = maskNonCode(source); + const problems: string[] = []; + for (const name of names) { + const range = declarationBody(code, name); + if (!range) { problems.push(`missing declaration: ${name}`); continue; } + let observed = 0; + for (const match of code.slice(range[0], range[1]).matchAll(/\b(?:lstatSync|fstatSync|statSync|lstat|stat|handle\.stat)\s*\(/g)) { + const start = range[0] + match.index!; + const close = matching(code, start + match[0].length - 1, "(", ")"); + if (close < 0) { problems.push(`unbalanced stat call in: ${name}`); continue; } + const call = source.slice(start, close + 1); + if (droid && /^lstatSync\(root\)$/.test(call)) continue; + observed++; + if (!/^\{\s*bigint\s*:\s*true\s*\}$/.test(lastArgument(code.slice(start + match[0].length, close)))) problems.push(`numeric stat: ${call}`); + } + if (observed === 0) problems.push(`no stat call in: ${name}`); + } + if (/\bNumber\s*\([^)]*\.(?:ino|dev)\b/.test(code)) problems.push("numeric identity"); + return problems; +} + +describe("file identity caller stats", () => { + test("detects a numeric identity observation", () => { + expect(inspect("function check() { const s = lstatSync(path); return s.ino === 1; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + expect(inspect("function check() { const s = lstatSync(join(root, name)); return s.ino === 1; }", ["check"])) + .toEqual(["numeric stat: lstatSync(join(root, name))"]); + expect(inspect("function check() { return lstatSync(join(root, name), { bigint: true }).ino; }", ["check"])).toEqual([]); + expect(inspect("function check() { return lstatSync(p, convert({ bigint: true })).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(p, convert({ bigint: true }))"]); + expect(inspect("function check() { const s = lstatSync(path, { bigint: true }); return Number(s.ino); }", ["check"])) + .toEqual(["numeric identity"]); + }); + + test("strings, templates, regexes and comments cannot hide or invent a call", () => { + expect(inspect('function check() { const a = lstatSync(join(root, "(")); const b = lstatSync(p, { bigint: true }); }', ["check"])) + .toEqual(['numeric stat: lstatSync(join(root, "("))']); + expect(inspect('function check() { const b = lstatSync(join(root, ")"), { bigint: true }); /* lstatSync(x) */ }', ["check"])).toEqual([]); + expect(inspect("function check() { const b = lstatSync(p, { bigint: true }); return `${lstatSync(q).ino}`; }", ["check"])) + .toEqual(["numeric stat: lstatSync(q)"]); + expect(inspect("function check() { const r = /'(/; const a = lstatSync(p); // '\n}", ["check"])) + .toEqual(["numeric stat: lstatSync(p)"]); + expect(inspect("function check() { lstatSync(p, { bigint: true }); if (x) return /'/.test(y); lstatSync(q); // '\n}", ["check"])) + .toEqual(["numeric stat: lstatSync(q)"]); + expect(inspect("function check() { const half = a / b; const s = lstatSync(p, { bigint: true }); return half / 2; }", ["check"])).toEqual([]); + }); + + test("resolves the declaration, not an earlier call, and needs a real observation", () => { + expect(inspect("function caller() { return check(); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); + 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() { lstatSync(root); }", ["check"], true)).toEqual(["no stat call in: check"]); + }); + + for (const [path, names] of Object.entries(scopes)) { + test(path, () => expect(inspect(readFileSync(repoPath(path), "utf8"), names, path.endsWith("droid-settings.ts"))).toEqual([])); + } +}); diff --git a/tests/lib/file-identity.test.ts b/tests/lib/file-identity.test.ts new file mode 100644 index 00000000000..cb4e63afe42 --- /dev/null +++ b/tests/lib/file-identity.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; +import { fileIdentityKey, sameFileIdentity } from "../../src/lib/file-identity"; + +test("compares and formats adjacent large file IDs", () => { + const a = { dev: 7n, ino: 2n ** 53n }; + const b = { dev: 7n, ino: a.ino + 1n }; + expect(Number(a.ino)).toBe(Number(b.ino)); + expect(sameFileIdentity(a, a)).toBe(true); + expect(sameFileIdentity(a, b)).toBe(false); + expect(sameFileIdentity(a, { ...a, dev: 8n })).toBe(false); + expect(fileIdentityKey(b).split(":").map(BigInt)).toEqual([b.dev, b.ino]); +}); diff --git a/tests/providers/cc-switch-import.test.ts b/tests/providers/cc-switch-import.test.ts index a4608177aba..e308da771d2 100644 --- a/tests/providers/cc-switch-import.test.ts +++ b/tests/providers/cc-switch-import.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,6 +25,7 @@ import { setCcSwitchSourceOpenHookForTests, } from "../../src/providers/cc-switch-import"; import { cmdProviderCcSwitchImport } from "../../src/cli/provider-cc-switch-import"; +import { readSourceRows } from "../../src/providers/cc-switch-import-source"; import { removeTreeWithRetry } from "../helpers/remove-tree"; // Runtime-assembled canary so privacy:scan never sees a literal token prefix in the source file. @@ -105,6 +107,27 @@ afterEach(async () => { }); describe("CC-Switch keyless provider import", () => { + test("file identity preserves adjacent NTFS-sized values", () => { + const path = join(sourceDir, "source.json"); + writeFileSync(path, JSON.stringify({ codex: { providers: {} } })); + const identity = 9_007_199_254_740_992n; + let opening = false; + 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 ? identity : Number(identity) }) : stat; + }) as typeof fs.lstatSync); + const fstat = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stat = options?.bigint ? realFstat(fd, { bigint: true }) : realFstat(fd); + return opening ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof fs.fstatSync); + setCcSwitchSourceOpenHookForTests(() => { opening = true; }); + try { expect(() => readSourceRows(path)).toThrow("CC-Switch source file changed while opening."); } + finally { fstat.mockRestore(); lstat.mockRestore(); } + }); + test("SQLite preview and apply strip TOML comments, drop free-text names, keep source read-only, and never materialize secrets", async () => { const codexToml = [ `model = "gpt-5.6-sol" # inline_comment_token = "${CANARY_SECRET}" and ${PREFIXLESS_HEX_SECRET}`, diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index c0b37b38dfd..cd1deee5f69 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -80,6 +80,35 @@ afterEach(() => { }); describe("loadCommandCodeProjectContext", () => { + test("file identity preserves adjacent NTFS-sized values", async () => { + const root = makeTempDir("ocx-cc-ctx-identity-"); + const agentsPath = join(root, "AGENTS.md"); + const identity = 9_007_199_254_740_992n; + try { + writeFileSync(agentsPath, "project memory", "utf8"); + lstatMock.mockImplementation((async (path: Parameters[0], options?: { bigint?: boolean }) => { + const stat = options?.bigint ? await realLstat(path, { bigint: true }) : await realLstat(path); + return String(path) === agentsPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity + 1n : Number(identity + 1n) }) : stat; + }) as typeof realLstat); + openMock.mockImplementation(async path => { + const handle = await realOpen(path); + if (String(path) !== agentsPath) return handle; + return { ...handle, read: handle.read.bind(handle), close: handle.close.bind(handle), + stat: async (options?: { bigint?: boolean }) => { + const stat = options?.bigint ? await handle.stat({ bigint: true }) : await handle.stat(); + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? identity : Number(identity) }); + }, + } as Awaited>; + }); + expect((await loadCommandCodeProjectContext(root)).memory).toBe(""); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + test("undefined cwd returns empty context", async () => { const result = await loadCommandCodeProjectContext(undefined); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); @@ -594,10 +623,12 @@ describe("loadCommandCodeProjectContext", () => { const root = makeTempDir("ocx-cc-ctx-read-timeout-"); const agentsPath = join(root, "AGENTS.md"); let closeCalls = 0; + let readCalls = 0; let releaseRead: () => void = () => {}; const hangingFile = { - stat: async () => statSync(agentsPath), + stat: async (options?: { bigint?: boolean }) => options?.bigint ? statSync(agentsPath, { bigint: true }) : statSync(agentsPath), read: () => new Promise<{ bytesRead: number; buffer: Buffer }>(resolve => { + readCalls++; releaseRead = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }); }), close: async () => { @@ -618,6 +649,7 @@ describe("loadCommandCodeProjectContext", () => { expect(result).not.toBe("timeout"); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(readCalls).toBe(1); expect(closeCalls).toBe(1); } finally { openMock.mockImplementation(realOpen); diff --git a/tests/server/inbound-body-admission.test.ts b/tests/server/inbound-body-admission.test.ts index 2e42acef4ee..3939ed67f9a 100644 --- a/tests/server/inbound-body-admission.test.ts +++ b/tests/server/inbound-body-admission.test.ts @@ -590,7 +590,7 @@ describe("raised inbound body admission lifetime", () => { } for (const normalized of [false, true]) { - test(`retains measured JSON bytes with translator normalization ${normalized}`, async () => { + test(`retains normalized JSON bytes with translator budget ${normalized}`, async () => { const text = "1e20"; const req = admissionRequest("/v1/responses", text); const translator = normalized ? createTranslatorBudget() : undefined; @@ -601,7 +601,7 @@ describe("raised inbound body admission lifetime", () => { assert.equal(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT), 1e20); return new Response("held"); }, req); - const expected = normalized ? Buffer.byteLength(JSON.stringify(1e20)) : Buffer.byteLength(text); + const expected = Buffer.byteLength(JSON.stringify(1e20)); assert.deepEqual(testBudget.snapshot(), { reservedBytes: expected, activeLeases: 1, queuedRequests: 0 }); await response.text(); } finally { diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 6b8be911d31..ee4565599c6 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -7,10 +7,12 @@ * therefore lasted until the next repair — from a tray helper, from `ocx update`, from a * doctor suggestion — and nothing said it had gone. */ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { repoPath } from "../helpers/repo-root"; +import { withServiceStateLocks } from "../../src/service/state-lock"; import { desktopOwnsService, inspectServiceStateEvidence, @@ -587,6 +589,24 @@ describe("the anchor lock", () => { expect(existsSync(serviceStatePath())).toBe(false); }); + test("lock identity preserves adjacent NTFS-sized values", () => { + const statePath = serviceStatePath(); + const lockPath = `${statePath}.lock`; + const identity = 9_007_199_254_740_992n; + let changed = false; + const realLstat = fs.lstatSync; + const lstat = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stat = options?.bigint ? realLstat(path, { bigint: true }) : realLstat(path); + const ino = identity + (changed ? 1n : 0n); + return path === lockPath ? Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, + { ino: options?.bigint ? ino : Number(ino) }) : stat; + }) as typeof fs.lstatSync); + try { + withServiceStateLocks([statePath], () => {}, { hooks: { beforeRelease: () => { changed = true; } } }); + expect(existsSync(lockPath)).toBe(true); + } finally { lstat.mockRestore(); } + }); + test("a late release cannot remove a successor lock", () => { const statePath = serviceStatePath(); const lockPath = `${statePath}.lock`;