-
Notifications
You must be signed in to change notification settings - Fork 985
fix(test): root the test-run lock in a machine-local user runtime dir #2962
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,8 @@ | ||
| import { randomUUID } from "node:crypto"; | ||
| import { createHash, randomUUID } from "node:crypto"; | ||
| import { | ||
| accessSync, | ||
| constants, | ||
| lstatSync, | ||
| mkdirSync, | ||
| readFileSync, | ||
| readdirSync, | ||
|
|
@@ -8,15 +11,43 @@ import { | |
| statSync, | ||
| writeFileSync, | ||
| } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { hostname, tmpdir } from "node:os"; | ||
| import { isAbsolute, join, posix, win32 } from "node:path"; | ||
|
|
||
| export const TEST_RUN_ID_ENV = "OCX_TEST_RUN_ID"; | ||
| export const TEST_RUN_NO_QUEUE_ENV = "OCX_TEST_NO_QUEUE"; | ||
| const DEFAULT_LOCK_PATH = join(tmpdir(), "opencodex-bun-test.lock"); | ||
| const OWNER_FILE = "owner.json"; | ||
| const MEMBERS_DIR = "members"; | ||
| const INCOMPLETE_OWNER_GRACE_MS = 10_000; | ||
| const POSIX_PRIVATE_MODE = 0o700; | ||
|
|
||
| interface RuntimeDirectoryEntry { | ||
| uid: number; | ||
| mode: number; | ||
| isDirectory(): boolean; | ||
| isSymbolicLink(): boolean; | ||
| } | ||
|
|
||
| export interface TestRunRuntimeFileSystem { | ||
| lstatSync(path: string): RuntimeDirectoryEntry; | ||
| mkdirSync(path: string, options: { mode: number }): void; | ||
| accessSync(path: string, mode: number): void; | ||
| } | ||
|
|
||
| export interface ResolveDefaultTestRunLockPathOptions { | ||
| platform?: NodeJS.Platform; | ||
| env?: NodeJS.ProcessEnv; | ||
| uid?: number; | ||
| tempDir?: string; | ||
| hostName?: string; | ||
| fileSystem?: TestRunRuntimeFileSystem; | ||
| } | ||
|
|
||
| const runtimeFileSystem: TestRunRuntimeFileSystem = { | ||
| lstatSync, | ||
| mkdirSync(path, options) { mkdirSync(path, options); }, | ||
| accessSync, | ||
| }; | ||
|
|
||
| export interface TestRunLockOwner { | ||
| version: 1; | ||
|
|
@@ -48,6 +79,138 @@ export interface BareTestRunIdentity { | |
| runId: string; | ||
| } | ||
|
|
||
| function errorCode(error: unknown): string { | ||
| if (!error || typeof error !== "object" || !("code" in error)) return "unknown error"; | ||
| return String((error as { code?: unknown }).code ?? "unknown error"); | ||
| } | ||
|
|
||
| function inspectRuntimeDirectory(options: { | ||
| path: string; | ||
| fileSystem: TestRunRuntimeFileSystem; | ||
| expectedUid?: number; | ||
| requirePrivateMode?: boolean; | ||
| }): string | null { | ||
| let entry: RuntimeDirectoryEntry; | ||
| try { | ||
| entry = options.fileSystem.lstatSync(options.path); | ||
| } catch (error) { | ||
| return `cannot be inspected (${errorCode(error)})`; | ||
| } | ||
| if (entry.isSymbolicLink() || !entry.isDirectory()) return "is not a real directory"; | ||
| if (options.expectedUid !== undefined && entry.uid !== options.expectedUid) { | ||
| return "is not owned by the current uid"; | ||
| } | ||
| if (options.requirePrivateMode && (entry.mode & 0o777) !== POSIX_PRIVATE_MODE) { | ||
| return "does not have mode 0700"; | ||
| } | ||
| try { | ||
| options.fileSystem.accessSync(options.path, constants.W_OK | constants.X_OK); | ||
| } catch (error) { | ||
| return `is not writable/searchable (${errorCode(error)})`; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function machineDiscriminator(hostName: string): string { | ||
| const normalized = hostName.trim().toLowerCase(); | ||
| if (!normalized) throw new Error("the OS hostname is empty"); | ||
| return createHash("sha256").update(normalized).digest("hex").slice(0, 16); | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a user-scoped, machine-local default lock path without relying on HOME. | ||
| * | ||
| * POSIX XDG runtime directories are accepted only after an ownership and access | ||
| * check. The fallback is a private UID namespace under the OS temp directory. | ||
| * The hostname digest remains part of the lock name in either case: even if an | ||
| * administrator redirects either root to shared storage, host-local PID liveness | ||
| * checks can never reclaim or join another machine's lock. | ||
| */ | ||
| export function resolveDefaultTestRunLockPath( | ||
| options: ResolveDefaultTestRunLockPathOptions = {}, | ||
| ): string { | ||
| const platform = options.platform ?? process.platform; | ||
| const env = options.env ?? process.env; | ||
| const tempDir = options.tempDir ?? tmpdir(); | ||
| const fileSystem = options.fileSystem ?? runtimeFileSystem; | ||
| let discriminator: string; | ||
| try { | ||
| discriminator = machineDiscriminator(options.hostName ?? hostname()); | ||
| } catch (cause) { | ||
| throw new Error( | ||
| "Cannot resolve a safe user-scoped Bun test lock: the machine identity is unavailable.", | ||
| { cause }, | ||
| ); | ||
| } | ||
|
|
||
| if (platform === "win32") { | ||
| if (!win32.isAbsolute(tempDir)) { | ||
| throw new Error( | ||
| "Cannot resolve a safe user-scoped Bun test lock: the Windows OS temp/profile path is not absolute.", | ||
| ); | ||
| } | ||
| const issue = inspectRuntimeDirectory({ path: tempDir, fileSystem }); | ||
| if (issue) { | ||
| throw new Error( | ||
| `Cannot resolve a safe user-scoped Bun test lock: the Windows OS temp/profile directory ${issue}.`, | ||
| ); | ||
| } | ||
| return win32.join(tempDir, `opencodex-bun-test-${discriminator}.lock`); | ||
|
Comment on lines
+152
to
+158
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: # Inspect the runtime-directory validator, resolver branches, and lock acquisition path.
sed -n '70,215p' scripts/test-run-lock.ts
sed -n '325,385p' scripts/test-run-lock.ts
sed -n '440,465p' scripts/test.ts
sed -n '1,75p' tests/test-runner.test.tsRepository: lidge-jun/opencodex Length of output: 11379 Denial of Service (CWE-377): Insecure Temporary File Reachability: External · Exploitability: Moderate Require exclusive access to every accepted runtime root.
Require mode 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined); | ||
| if (!Number.isInteger(uid) || (uid ?? -1) < 0) { | ||
| throw new Error( | ||
| "Cannot resolve a safe user-scoped Bun test lock: the current POSIX uid is unavailable.", | ||
| ); | ||
| } | ||
|
|
||
| const failures: string[] = []; | ||
| const xdgRuntimeDir = env.XDG_RUNTIME_DIR?.trim(); | ||
| if (xdgRuntimeDir) { | ||
| if (!isAbsolute(xdgRuntimeDir)) { | ||
| failures.push("XDG_RUNTIME_DIR is not absolute"); | ||
| } else { | ||
| const issue = inspectRuntimeDirectory({ | ||
| path: xdgRuntimeDir, | ||
| fileSystem, | ||
| expectedUid: uid, | ||
| }); | ||
|
Comment on lines
+174
to
+178
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| if (!issue) return posix.join(xdgRuntimeDir, `opencodex-bun-test-${discriminator}.lock`); | ||
| failures.push(`XDG_RUNTIME_DIR ${issue}`); | ||
| } | ||
| } | ||
|
|
||
| if (!isAbsolute(tempDir)) { | ||
| failures.push("the OS temporary directory is not absolute"); | ||
| } else { | ||
| const fallback = posix.join(tempDir, `opencodex-test-runtime-${uid}`); | ||
| try { | ||
| fileSystem.mkdirSync(fallback, { mode: POSIX_PRIVATE_MODE }); | ||
| } catch (error) { | ||
| if (errorCode(error) !== "EEXIST") { | ||
| failures.push(`the temporary UID runtime directory cannot be created (${errorCode(error)})`); | ||
| } | ||
| } | ||
| if (!failures.some(failure => failure.startsWith("the temporary UID runtime directory cannot be created"))) { | ||
| const issue = inspectRuntimeDirectory({ | ||
| path: fallback, | ||
| fileSystem, | ||
| expectedUid: uid, | ||
| requirePrivateMode: true, | ||
| }); | ||
| if (!issue) return posix.join(fallback, `opencodex-bun-test-${discriminator}.lock`); | ||
| failures.push(`the temporary UID runtime directory ${issue}`); | ||
| } | ||
| } | ||
|
|
||
| throw new Error( | ||
| "Cannot resolve a safe user-scoped Bun test lock. " | ||
| + "Ensure XDG_RUNTIME_DIR is an existing writable directory owned by the current uid, " | ||
| + `or make the OS temporary directory usable for a mode-0700 UID runtime (${failures.join("; ")}).`, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Give one bare Bun invocation a stable identity without conflating sibling commands. | ||
| * | ||
|
|
@@ -151,7 +314,7 @@ function ownsLock(lockPath: string, owner: TestRunLockOwner): boolean { | |
| } | ||
|
|
||
| /** | ||
| * Acquire the machine-wide OpenCodex Bun-test lock. | ||
| * Acquire the user-scoped, machine-local OpenCodex Bun-test lock. | ||
| * | ||
| * `mkdir` is the cross-platform atomic primitive. The owner PID makes a lock left by | ||
| * SIGKILL recoverable, while the run ID lets every worker belonging to one bare | ||
|
|
@@ -165,7 +328,8 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr | |
| return { acquired: false, owner: null, release() {} }; | ||
| } | ||
|
|
||
| const lockPath = options.lockPath ?? DEFAULT_LOCK_PATH; | ||
| const usesDefaultLockPath = options.lockPath === undefined; | ||
| const lockPath = options.lockPath ?? resolveDefaultTestRunLockPath({ env }); | ||
| const ownerPid = options.ownerPid ?? process.pid; | ||
| const pollMs = Math.max(1, options.pollMs ?? 5_000); | ||
| const maxWaitMs = Math.max(pollMs, options.maxWaitMs ?? 45 * 60 * 1000); | ||
|
|
@@ -198,7 +362,17 @@ export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Pr | |
| }, | ||
| }; | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; | ||
| const code = (error as NodeJS.ErrnoException).code; | ||
| if (code !== "EEXIST") { | ||
| if (usesDefaultLockPath && ["EACCES", "ENOENT", "EPERM", "EROFS"].includes(code ?? "")) { | ||
| throw new Error( | ||
| "Cannot acquire the user-scoped Bun test lock because its validated runtime directory " | ||
| + "became unavailable or unwritable. Check XDG_RUNTIME_DIR and the OS temporary directory.", | ||
| { cause: error }, | ||
| ); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| const current = readOwner(lockPath); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When two Windows accounts resolve
tmpdir()to the same directory—such as whenTEMP/TMPare unset and both fall back to the system temp directory—the lock name contains only the hostname, so their test runs still share one lock and can block or reclaim each other. Include an OS-derived user identity such as the account SID in the name, or select a directory whose ACL is verified as account-private.AGENTS.md reference: scripts/AGENTS.md:L14-L15
Useful? React with 👍 / 👎.