From f36aedf42ba958f4f32a2137667c12b233a6acda Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:46:07 +0900 Subject: [PATCH 1/2] fix(codex): harden fresh shim rollback transactions --- src/codex/shim.ts | 217 +++++++++++++----- src/lib/rename-no-replace.c | 52 +++++ src/lib/rename-no-replace.ts | 117 ++++++++++ tests/codex-shim.test.ts | 386 +++++++++++++++++++++++++++++++- tests/rename-no-replace.test.ts | 112 +++++++++ 5 files changed, 821 insertions(+), 63 deletions(-) create mode 100644 src/lib/rename-no-replace.c create mode 100644 src/lib/rename-no-replace.ts create mode 100644 tests/rename-no-replace.test.ts diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 312c946a0..e2be9edf3 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -31,6 +31,7 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { windowsEnvIndirectBatchValue } from "../lib/win-paths"; import { isWslRuntime, wslAutomountRoot } from "./home"; import { truncateRetainedUtf8 } from "../lib/admission"; +import { renameNoReplace } from "../lib/rename-no-replace"; const SHIM_MARKER = "opencodex codex autostart shim"; const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; @@ -403,6 +404,32 @@ function stableShimPathProbe(path: string): StableShimPathProbe | null { return contentSize > 0 ? { fingerprint, prefix } : null; } +function shimPathFingerprint(path: string): ShimPathFingerprint | null { + const before = statFingerprint(path, false); + if (!before) return null; + if (before.kind !== "symlink") { + const after = statFingerprint(path, false); + return after && sameFingerprint(before, after) ? before : null; + } + const targetBefore = statFingerprint(path, true); + if (!targetBefore) return null; + const targetAfter = statFingerprint(path, true); + const after = statFingerprint(path, false); + if (!targetAfter || !after + || !sameFingerprint(targetBefore, targetAfter) + || !sameFingerprint(before, after)) return null; + return { ...before, target: targetBefore }; +} + +function pathEntryExists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (error) { + return fileErrorCode(error) !== "ENOENT"; + } +} + function sameStableShimPathProbe(left: StableShimPathProbe, right: StableShimPathProbe): boolean { return left.prefix === right.prefix && sameFingerprint(left.fingerprint, right.fingerprint); } @@ -644,6 +671,7 @@ let codexShimProbeHookForTests: (() => void) | null = null; let codexShimProbeShellForTests: string | null = null; let codexShimGuardedWriteHookForTests: (() => void) | null = null; let codexShimFreshWriteHookForTests: (() => void) | null = null; +let codexShimFreshBackupHookForTests: ((target: ShimFileState, index: number) => void) | null = null; let codexShimProbeObservationMs = CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS; /** Narrow deterministic seam for transaction rollback tests. */ @@ -671,6 +699,13 @@ export function setCodexShimFreshWriteHookForTests(hook: (() => void) | null): v codexShimFreshWriteHookForTests = hook; } +/** @internal Test-only hook for fresh-install backup reservation races. */ +export function setCodexShimFreshBackupHookForTests( + hook: ((target: ShimFileState, index: number) => void) | null, +): void { + codexShimFreshBackupHookForTests = hook; +} + function readProbeMetadata(path: string, maxBytes: number): string | null { try { if (!existsSync(path)) return ""; @@ -829,10 +864,10 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry errors.push(error instanceof Error ? error : new Error(String(error))); } try { - if (entry.originalMovedToBackup && existsSync(target.backupPath)) { - const movedOriginal = stableShimPathProbe(target.backupPath); + if (entry.originalMovedToBackup) { + const movedOriginal = shimPathFingerprint(target.backupPath); if (!movedOriginal || !entry.movedOriginalFingerprint - || !sameFingerprint(movedOriginal.fingerprint, entry.movedOriginalFingerprint)) { + || !sameFingerprint(movedOriginal, entry.movedOriginalFingerprint)) { throw new Error("Codex shim fresh-install backup changed during rollback"); } if (sourceOccupied) { @@ -842,7 +877,13 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry // lose the command entirely. Keep it in that case — a stray // `codex.opencodex-real` is recoverable, a deleted launcher is not. if (ownsWrapperNow) unlinkSync(target.backupPath); - } else renameSync(target.backupPath, target.originalPath); + } else { + renameNoReplace(target.backupPath, target.originalPath); + const restoredOriginal = shimPathFingerprint(target.originalPath); + if (!restoredOriginal || !sameFingerprintAfterRename(restoredOriginal, movedOriginal)) { + throw new Error("Codex shim fresh-install original changed during rollback restore"); + } + } } } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); @@ -1052,59 +1093,71 @@ function gitBashPath(path: string): string { } /** - * Write the wrapper and return the identity of the inode this call created, or - * `undefined` where the platform still writes the destination in place. + * Write the wrapper through a private same-directory staging entry and return + * the identity of the inode this call created. The one legacy Windows refresh + * path that intentionally replaces an existing wrapper keeps its prior direct + * write behavior and therefore returns `undefined`. * * Callers must derive ownership from the returned identity rather than from a * later `stat` of `wrapperPath`: the shim markers are public, so a concurrent * updater's wrapper can carry them, and a replacement landing between the write * and the observation is otherwise indistinguishable from our own file. */ -function writeShim(wrapperPath: string, realCodexPath: string): { dev: number; ino: number } | undefined { +function writeShim( + wrapperPath: string, + realCodexPath: string, + options: { replaceExisting?: boolean } = {}, +): { dev: number; ino: number } | undefined { const { bun, bunRuntimeSource, cli } = cliEntry(); + let source: string; + let executable = false; if (process.platform === "win32") { const lower = wrapperPath.toLowerCase(); if (lower.endsWith(".ps1")) { // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI // codepage, which mangles non-ASCII paths embedded in the shim. - writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8"); + source = `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`; } else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) { - writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8"); + source = buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource); } else { // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. - writeFileSync( - wrapperPath, - buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())), - "utf8", + source = buildUnixCodexShim( + gitBashPath(realCodexPath), + gitBashPath(bun), + gitBashPath(cli), + bunRuntimeSource, + gitBashPath(serviceApiTokenFilePath()), ); } - return undefined; } else { - // Stage the wrapper as its own inode and rename it into place, so ownership - // comes from the write itself rather than from observing the path afterwards. - // Writing the destination directly leaves a window in which a concurrent - // updater can replace the file between our write and our fingerprint; we would - // then adopt that replacement as ours and unlink it during rollback, deleting - // an executable we never wrote. - // Hidden and non-executable while staged, so a crash between the write and the - // rename cannot leave an executable `codex*` artifact that a glob or a shell - // completion would surface. - const staged = join(dirname(wrapperPath), `.${basename(wrapperPath)}.opencodex-staging.${process.pid}.${randomUUID()}`); - let renamed = false; - 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); - chmodSync(staged, 0o755); - renameSync(staged, wrapperPath); - renamed = true; - // rename() preserves dev/ino and updates ctime, so identity is the inode - // pair, captured from the file we created rather than from the destination. - return { dev: stagedStat.dev, ino: stagedStat.ino }; - } finally { - if (!renamed) { - try { unlinkSync(staged); } catch { /* best-effort: nothing to clean up */ } - } + source = buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource); + executable = true; + } + + // Preserve the old Windows overwrite behavior only for the direct refresh + // path whose destination intentionally exists. Fresh and transactional paths + // always publish a private inode with no-replace semantics. + if (process.platform === "win32" && options.replaceExisting) { + writeFileSync(wrapperPath, source, "utf8"); + return undefined; + } + + // Hidden and non-executable while staged, so a crash before publication does + // not leave an executable `codex*` artifact that a glob or shell completion + // would surface. The no-replace move makes destination ownership linearizable. + const staged = join(dirname(wrapperPath), `.${basename(wrapperPath)}.opencodex-staging.${process.pid}.${randomUUID()}`); + let renamed = false; + try { + writeFileSync(staged, source, { encoding: "utf8", flag: "wx", mode: 0o600 }); + if (executable) chmodSync(staged, 0o755); + const stagedStat = lstatSync(staged); + if (options.replaceExisting) renameSync(staged, wrapperPath); + else renameNoReplace(staged, wrapperPath); + renamed = true; + return { dev: stagedStat.dev, ino: stagedStat.ino }; + } finally { + if (!renamed) { + try { unlinkSync(staged); } catch { /* best-effort: nothing to clean up */ } } } } @@ -1224,7 +1277,7 @@ function refreshShimFile(file: ShimFileState): boolean { } if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) { replaceOwnedBackup(file.originalPath, file.backupPath); - writeShim(file.wrapperPath, file.realPath ?? file.backupPath); + writeShim(file.wrapperPath, file.realPath ?? file.backupPath, { replaceExisting: true }); return true; } return false; @@ -1817,11 +1870,14 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i if (!targets) return { installed: false, message: lastShimDiscoveryError ?? "Could not find a codex executable on PATH." }; for (const target of targets) { - if (existsSync(target.backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${target.backupPath}` }; + if (pathEntryExists(target.backupPath)) { + return { installed: false, message: `Refusing to overwrite existing backup: ${target.backupPath}` }; + } } const freshJournal: FreshShimInstallJournalEntry[] = []; let freshApplyError: Error | null = null; - for (const target of targets) { + let occupiedBackupPath: string | null = null; + for (const [index, target] of targets.entries()) { const entry: FreshShimInstallJournalEntry = { target, originalMovedToBackup: false, @@ -1830,38 +1886,79 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i freshJournal.push(entry); try { if (existsSync(target.originalPath)) { - renameSync(target.originalPath, target.backupPath); + codexShimFreshBackupHookForTests?.(target, index); + try { + renameNoReplace(target.originalPath, target.backupPath); + } catch (error) { + if (fileErrorCode(error) === "EEXIST") occupiedBackupPath = target.backupPath; + throw error; + } entry.originalMovedToBackup = true; + const movedOriginalFingerprint = shimPathFingerprint(target.backupPath); + if (!movedOriginalFingerprint) { + throw new Error("Codex shim fresh install could not fingerprint the staged launcher"); + } + entry.movedOriginalFingerprint = movedOriginalFingerprint; if (process.platform !== "win32") { const movedOriginal = stableShimPathProbe(target.backupPath); if (!movedOriginal) throw new Error("Codex shim fresh install could not fingerprint the staged launcher"); - entry.movedOriginalFingerprint = movedOriginal.fingerprint; + if (!sameFingerprint(movedOriginal.fingerprint, movedOriginalFingerprint)) { + throw new Error("Codex shim fresh install staged launcher changed while being fingerprinted"); + } } } - if (!target.preserveOnly) { + } catch (error) { + freshApplyError = error instanceof Error ? error : new Error(String(error)); + break; + } + } + if (!freshApplyError) { + for (const entry of freshJournal) { + const target = entry.target; + try { + if (target.preserveOnly) continue; entry.wrapperWriteStarted = true; const writtenInode = writeShim(target.wrapperPath, target.realPath ?? target.backupPath); + if (!writtenInode) throw new Error("Codex shim fresh install could not identify the generated wrapper"); entry.writtenWrapperInode = writtenInode; + entry.writtenWrapperFingerprint = ownedWrapperFingerprint(target.wrapperPath, writtenInode); + if (!entry.writtenWrapperFingerprint) { + throw new Error("Codex shim fresh install could not fingerprint the generated wrapper"); + } codexShimFreshWriteHookForTests?.(); - if (process.platform !== "win32") { - if (!writtenInode) throw new Error("Codex shim fresh install could not fingerprint the generated wrapper"); - entry.writtenWrapperFingerprint = ownedWrapperFingerprint(target.wrapperPath, writtenInode); + const currentWrapper = ownedWrapperFingerprint(target.wrapperPath, writtenInode); + if (!currentWrapper || !sameFingerprint(currentWrapper, entry.writtenWrapperFingerprint)) { + throw new Error("Codex shim fresh install generated wrapper changed after publication"); } + } catch (error) { + freshApplyError = error instanceof Error ? error : new Error(String(error)); + break; } - } catch (error) { - freshApplyError = error instanceof Error ? error : new Error(String(error)); - break; } } - if (process.platform !== "win32") { - if (freshApplyError) { - try { - rollbackFreshShimInstall(freshJournal); - } catch (rollbackError) { - throw new AggregateError([freshApplyError, rollbackError], "Codex shim installation and rollback failed"); - } - throw freshApplyError; + if (!freshApplyError) { + const changedEntry = freshJournal.find(entry => { + if (entry.target.preserveOnly) return false; + if (!entry.writtenWrapperInode || !entry.writtenWrapperFingerprint) return true; + const current = ownedWrapperFingerprint(entry.target.wrapperPath, entry.writtenWrapperInode); + return !current || !sameFingerprint(current, entry.writtenWrapperFingerprint); + }); + if (changedEntry) { + freshApplyError = new Error("Codex shim fresh install generated wrapper set changed before commit"); } + } + if (freshApplyError) { + try { + rollbackFreshShimInstall(freshJournal); + } catch (rollbackError) { + throw new AggregateError([freshApplyError, rollbackError], "Codex shim installation and rollback failed"); + } + if (occupiedBackupPath) { + return { installed: false, message: `Refusing to overwrite existing backup: ${occupiedBackupPath}` }; + } + throw freshApplyError; + } + if (process.platform !== "win32") { let unsafe: UnixShimProbeResult = null; let probeError: Error | null = null; try { @@ -1903,8 +2000,6 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i : `Refusing Codex autostart shim because ${reason}. The original launcher was restored; reinstall Codex as a concrete executable before enabling codexAutoStart.`, }; } - } else if (freshApplyError) { - throw freshApplyError; } writeState(primaryState(targets)); return { diff --git a/src/lib/rename-no-replace.c b/src/lib/rename-no-replace.c new file mode 100644 index 000000000..aa0a0aa36 --- /dev/null +++ b/src/lib/rename-no-replace.c @@ -0,0 +1,52 @@ +#ifdef _WIN32 +typedef int BOOL; +typedef unsigned long DWORD; +typedef unsigned short WCHAR; + +BOOL MoveFileW(const WCHAR *source, const WCHAR *destination); +DWORD GetLastError(void); + +int ocx_rename_noreplace(const WCHAR *source, const WCHAR *destination) { + if (MoveFileW(source, destination)) return 0; + return (int)GetLastError(); +} +#elif defined(__linux__) +#define _GNU_SOURCE +#include +#include +#include +#include + +#ifndef RENAME_NOREPLACE +#define RENAME_NOREPLACE 1 +#endif + +int ocx_rename_noreplace(const char *source, const char *destination) { +#ifdef SYS_renameat2 + if (syscall(SYS_renameat2, AT_FDCWD, source, AT_FDCWD, destination, RENAME_NOREPLACE) == 0) return 0; + return errno; +#else + return ENOTSUP; +#endif +} +#elif defined(__APPLE__) +#include +#include + +#ifndef RENAME_EXCL +#define RENAME_EXCL 0x00000004 +#endif + +int ocx_rename_noreplace(const char *source, const char *destination) { + if (renamex_np(source, destination, RENAME_EXCL) == 0) return 0; + return errno; +} +#else +#include + +int ocx_rename_noreplace(const char *source, const char *destination) { + (void)source; + (void)destination; + return ENOTSUP; +} +#endif diff --git a/src/lib/rename-no-replace.ts b/src/lib/rename-no-replace.ts new file mode 100644 index 000000000..98e191d2b --- /dev/null +++ b/src/lib/rename-no-replace.ts @@ -0,0 +1,117 @@ +import { cc, ptr } from "bun:ffi"; +import { isAbsolute, normalize, toNamespacedPath } from "node:path"; + +type NativeRenameNoReplace = (source: string, destination: string) => number; + +export type RenameNoReplaceErrorCode = + | "EACCES" + | "EEXIST" + | "EINVAL" + | "EIO" + | "ENAMETOOLONG" + | "ENOENT" + | "ENOTSUP" + | "EXDEV"; + +let nativeRenameNoReplace: NativeRenameNoReplace | null | undefined; +let nativeLibrary: unknown; +let nativeOverrideForTests: NativeRenameNoReplace | null | undefined; + +function utf8(path: string): Buffer { + return Buffer.from(`${path}\0`, "utf8"); +} + +function windowsPath(path: string): string { + if (!isAbsolute(path)) return path; + return toNamespacedPath(normalize(path)); +} + +function utf16(path: string): Buffer { + return Buffer.from(`${windowsPath(path)}\0`, "utf16le"); +} + +function loadNative(): NativeRenameNoReplace | null { + if (nativeOverrideForTests !== undefined) return nativeOverrideForTests; + if (nativeRenameNoReplace !== undefined) return nativeRenameNoReplace; + try { + const compiled = cc({ + source: new URL("./rename-no-replace.c", import.meta.url), + library: process.platform === "win32" ? ["kernel32"] : [], + symbols: { + ocx_rename_noreplace: { args: ["ptr", "ptr"] as const, returns: "i32" as const }, + }, + }); + nativeLibrary = compiled; + const native = compiled.symbols.ocx_rename_noreplace; + nativeRenameNoReplace = (source, destination) => { + const from = process.platform === "win32" ? utf16(source) : utf8(source); + const to = process.platform === "win32" ? utf16(destination) : utf8(destination); + return Number(native(ptr(from), ptr(to))); + }; + } catch { + nativeRenameNoReplace = null; + } + return nativeRenameNoReplace; +} + +/** @internal Deterministic backend seam for focused fail-closed/error tests. */ +export function setRenameNoReplaceBackendForTests( + backend: NativeRenameNoReplace | null | undefined, +): void { + nativeOverrideForTests = backend; +} + +export function portableRenameNoReplaceErrorCode( + platform: NodeJS.Platform, + nativeCode: number, +): RenameNoReplaceErrorCode { + if (nativeCode === -1) return "ENOTSUP"; + if (platform === "win32") { + if (nativeCode === 80 || nativeCode === 183) return "EEXIST"; + if (nativeCode === 2 || nativeCode === 3) return "ENOENT"; + if (nativeCode === 5) return "EACCES"; + // Win32 ERROR_NOT_SAME_DEVICE; POSIX errno 17 below means EEXIST. + if (nativeCode === 17) return "EXDEV"; + if (nativeCode === 50) return "ENOTSUP"; + if (nativeCode === 87) return "EINVAL"; + if (nativeCode === 206) return "ENAMETOOLONG"; + return "EIO"; + } + if (nativeCode === 17) return "EEXIST"; + if (nativeCode === 2) return "ENOENT"; + if (nativeCode === 13) return "EACCES"; + if (nativeCode === 18) return "EXDEV"; + if (nativeCode === 36 || nativeCode === 63) return "ENAMETOOLONG"; + // Linux EINVAL/ENOSYS/EOPNOTSUPP and Darwin ENOTSUP/EOPNOTSUPP. + if ([22, 38, 45, 78, 95, 102].includes(nativeCode)) return "ENOTSUP"; + return "EIO"; +} + +function nativeRenameError( + source: string, + destination: string, + nativeCode: number, +): NodeJS.ErrnoException & { dest?: string } { + const code = portableRenameNoReplaceErrorCode(process.platform, nativeCode); + const error = new Error(`Atomic no-replace rename failed (${code})`) as NodeJS.ErrnoException & { dest?: string }; + error.code = code; + error.errno = nativeCode; + error.path = source; + error.dest = destination; + return error; +} + +/** Atomically rename one directory entry while refusing to replace another. */ +export function renameNoReplace(source: string, destination: string): void { + if (source.includes("\0") || destination.includes("\0")) { + throw Object.assign(new Error("Path contains a NUL byte"), { + code: "EINVAL", + path: source, + dest: destination, + }); + } + const native = loadNative(); + const nativeCode = native ? native(source, destination) : -1; + if (nativeCode === 0) return; + throw nativeRenameError(source, destination, nativeCode); +} diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index dbf74d38a..4097348d3 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { tmpdir } from "node:os"; -import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, uninstallCodexShim } from "../src/codex/shim"; +import { autoRestoreCodexShim, buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim, diagnoseCodexShim, findCodexOnPath, installCodexShim, isWindowsInteropDir, lastCodexDiscoveryError, setCodexShimFreshBackupHookForTests, setCodexShimFreshWriteHookForTests, setCodexShimGuardedWriteHookForTests, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, setCodexShimProbeShellForTests, uninstallCodexShim } from "../src/codex/shim"; const SHIM_MARKER = "opencodex codex autostart shim"; const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 2"; @@ -220,7 +220,9 @@ describe("Codex autostart shim", () => { expect(source).toContain('const gitBashLauncher = join(dir, "codex");'); expect(source).toContain("for (const path of [cmd, ps1, gitBashLauncher])"); - expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath()))"); + expect(source).toContain("source = buildUnixCodexShim("); + expect(source).toContain("gitBashPath(realCodexPath),"); + expect(source).toContain("gitBashPath(serviceApiTokenFilePath()),"); }); test("Unix shim accepts an injected token-file path (Git-Bash shims need forward slashes everywhere)", () => { @@ -454,6 +456,260 @@ exit 126 }, ); + test("fresh install never overwrites a backup created during reservation", () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-racing-backup-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-racing-backup-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + const backupPath = process.platform === "win32" + ? join(binDir, "codex.opencodex-real.cmd") + : `${codexPath}.opencodex-real`; + const original = successfulLauncher("reservation race original"); + const concurrentBackup = successfulLauncher("reservation race winner"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, { encoding: "utf8", mode: 0o755 }); + setCodexShimFreshBackupHookForTests((_, index) => { + if (index === 0) writeFileSync(backupPath, concurrentBackup, { encoding: "utf8", flag: "wx", mode: 0o755 }); + }); + + expect(installCodexShim()).toEqual({ + installed: false, + message: `Refusing to overwrite existing backup: ${backupPath}`, + }); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(readFileSync(backupPath, "utf8")).toBe(concurrentBackup); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimFreshBackupHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")( + "fresh install never overwrites a dangling backup created during reservation", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-racing-dangling-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-racing-dangling-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const backupPath = `${codexPath}.opencodex-real`; + const original = successfulLauncher("dangling reservation original"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, { encoding: "utf8", mode: 0o755 }); + setCodexShimFreshBackupHookForTests((_, index) => { + if (index === 0) symlinkSync(join(binDir, "missing"), backupPath, "file"); + }); + + expect(installCodexShim()).toEqual({ + installed: false, + message: `Refusing to overwrite existing backup: ${backupPath}`, + }); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(lstatSync(backupPath).isSymbolicLink()).toBe(true); + expect(existsSync(backupPath)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimFreshBackupHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform !== "win32")( + "Windows fresh install rolls back earlier reservations when a later backup races", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-multi-reservation-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-multi-reservation-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const originals = [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")]; + const backups = [join(binDir, "codex.opencodex-real.cmd"), join(binDir, "codex.opencodex-real.ps1"), join(binDir, "codex.opencodex-real")]; + const contents = originals.map((_, index) => successfulLauncher(`reservation original ${index}`)); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + originals.forEach((path, index) => writeFileSync(path, contents[index]!, "utf8")); + let earlierReservationObserved = false; + let contendedBackup: string | undefined; + setCodexShimFreshBackupHookForTests((target, index) => { + if (index > 0) earlierReservationObserved = true; + if (target.originalPath === join(binDir, "codex.ps1")) { + contendedBackup = target.backupPath; + writeFileSync(target.backupPath, "concurrent backup\r\n", { flag: "wx" }); + } + }); + + expect(installCodexShim()).toEqual({ + installed: false, + message: `Refusing to overwrite existing backup: ${backups[1]}`, + }); + expect(earlierReservationObserved).toBe(true); + expect(contendedBackup).toBe(backups[1]); + originals.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(contents[index])); + backups.forEach(path => { + if (path !== contendedBackup) expect(existsSync(path)).toBe(false); + }); + expect(readFileSync(backups[1]!, "utf8")).toBe("concurrent backup\r\n"); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimFreshBackupHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform !== "win32")( + "Windows fresh install removes an earlier wrapper when a later write fails", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-write-rollback-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-write-rollback-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const originals = [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")]; + const backups = [join(binDir, "codex.opencodex-real.cmd"), join(binDir, "codex.opencodex-real.ps1"), join(binDir, "codex.opencodex-real")]; + const contents = originals.map((_, index) => successfulLauncher(`write rollback original ${index}`)); + let writes = 0; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + originals.forEach((path, index) => writeFileSync(path, contents[index]!, "utf8")); + setCodexShimFreshWriteHookForTests(() => { + writes += 1; + if (writes === 1) throw new Error("synthetic Windows sibling write failure"); + }); + + expect(() => installCodexShim()).toThrow("synthetic Windows sibling write failure"); + + expect(writes).toBe(1); + originals.forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(contents[index])); + backups.forEach(path => expect(existsSync(path)).toBe(false)); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(readdirSync(binDir).filter(name => name.includes("opencodex-staging"))).toEqual([]); + } finally { + setCodexShimFreshWriteHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform !== "win32")( + "Windows fresh install refuses to adopt a wrapper replaced after publication", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-write-replacement-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-write-replacement-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const originals = [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")]; + const backups = [join(binDir, "codex.opencodex-real.cmd"), join(binDir, "codex.opencodex-real.ps1"), join(binDir, "codex.opencodex-real")]; + const contents = originals.map((_, index) => successfulLauncher(`write replacement original ${index}`)); + const replacement = "concurrent Windows updater\r\n"; + let writes = 0; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + originals.forEach((path, index) => writeFileSync(path, contents[index]!, "utf8")); + setCodexShimFreshWriteHookForTests(() => { + writes += 1; + if (writes !== 1) return; + const staged = `${originals[0]}.concurrent`; + writeFileSync(staged, replacement, "utf8"); + renameSync(staged, originals[0]!); + }); + + expect(() => installCodexShim()) + .toThrow("Codex shim fresh install generated wrapper changed after publication"); + + expect(writes).toBe(1); + expect(readFileSync(originals[0]!, "utf8")).toBe(replacement); + expect(readFileSync(backups[0]!, "utf8")).toBe(contents[0]); + originals.slice(1).forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(contents[index + 1])); + backups.slice(1).forEach(path => expect(existsSync(path)).toBe(false)); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(readdirSync(binDir).filter(name => name.includes("opencodex-staging"))).toEqual([]); + } finally { + setCodexShimFreshWriteHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform !== "win32")( + "Windows fresh install revalidates earlier wrappers after later sibling writes", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-sibling-replacement-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-sibling-replacement-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const originals = [join(binDir, "codex.cmd"), join(binDir, "codex.ps1"), join(binDir, "codex")]; + const backups = [join(binDir, "codex.opencodex-real.cmd"), join(binDir, "codex.opencodex-real.ps1"), join(binDir, "codex.opencodex-real")]; + const contents = originals.map((_, index) => successfulLauncher(`sibling replacement original ${index}`)); + const replacement = "later Windows updater\r\n"; + let writes = 0; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + originals.forEach((path, index) => writeFileSync(path, contents[index]!, "utf8")); + setCodexShimFreshWriteHookForTests(() => { + writes += 1; + if (writes !== 2) return; + const staged = `${originals[0]}.later-concurrent`; + writeFileSync(staged, replacement, "utf8"); + renameSync(staged, originals[0]!); + }); + + expect(() => installCodexShim()) + .toThrow("Codex shim fresh install generated wrapper set changed before commit"); + + expect(writes).toBe(3); + expect(readFileSync(originals[0]!, "utf8")).toBe(replacement); + expect(readFileSync(backups[0]!, "utf8")).toBe(contents[0]); + originals.slice(1).forEach((path, index) => expect(readFileSync(path, "utf8")).toBe(contents[index + 1])); + backups.slice(1).forEach(path => expect(existsSync(path)).toBe(false)); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(readdirSync(binDir).filter(name => name.includes("opencodex-staging"))).toEqual([]); + } finally { + setCodexShimFreshWriteHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + test("Unix install honors the injected probe shell path", () => { if (process.platform === "win32") return; @@ -791,6 +1047,132 @@ wait "$child" } }); + test.skipIf(process.platform === "win32")( + "Unix fresh install restores an original that cannot be content-probed", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-empty-original-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-empty-original-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const backupPath = `${codexPath}.opencodex-real`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, "", "utf8"); + chmodSync(codexPath, 0o755); + + expect(() => installCodexShim()) + .toThrow("Codex shim fresh install could not fingerprint the staged launcher"); + + expect(readFileSync(codexPath, "utf8")).toBe(""); + expect(lstatSync(codexPath).mode & 0o777).toBe(0o755); + expect(existsSync(backupPath)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "Unix fresh install reports a dangling staged backup as rollback validation failure", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-dangling-backup-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-dangling-backup-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const targetPath = join(binDir, "real-codex"); + const backupPath = `${codexPath}.opencodex-real`; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(targetPath, successfulLauncher("dangling backup target"), "utf8"); + chmodSync(targetPath, 0o755); + symlinkSync(targetPath, codexPath); + setCodexShimFreshWriteHookForTests(() => { + rmSync(targetPath, { force: true }); + throw new Error("synthetic dangling backup rollback"); + }); + + let thrown: unknown; + try { + installCodexShim(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AggregateError); + const topLevel = thrown as AggregateError; + expect(topLevel.message).toBe("Codex shim installation and rollback failed"); + expect(topLevel.errors.map(error => String(error))).toContainEqual( + expect.stringContaining("synthetic dangling backup rollback"), + ); + const rollbackError = topLevel.errors.find(error => error instanceof AggregateError) as AggregateError | undefined; + expect(rollbackError).toBeInstanceOf(AggregateError); + expect(rollbackError?.errors.map(error => String(error))).toContainEqual( + expect.stringContaining("Codex shim fresh-install backup changed during rollback"), + ); + expect(existsSync(codexPath)).toBe(false); + expect(lstatSync(backupPath).isSymbolicLink()).toBe(true); + expect(existsSync(backupPath)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + setCodexShimFreshWriteHookForTests(null); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "Unix fresh install preserves a pre-existing dangling backup", + () => { + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-existing-dangling-backup-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-existing-dangling-backup-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const missingTarget = join(binDir, "missing-original"); + const backupPath = `${codexPath}.opencodex-real`; + const original = successfulLauncher("pre-existing dangling backup original"); + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); + symlinkSync(missingTarget, backupPath); + + const result = installCodexShim(); + + expect(result).toEqual({ + installed: false, + message: `Refusing to overwrite existing backup: ${backupPath}`, + }); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(lstatSync(backupPath).isSymbolicLink()).toBe(true); + expect(existsSync(backupPath)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + rmSync(binDir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + test("Unix fresh install removes its marker-bearing partial wrapper before rollback", () => { if (process.platform === "win32") return; diff --git a/tests/rename-no-replace.test.ts b/tests/rename-no-replace.test.ts new file mode 100644 index 000000000..6a448c733 --- /dev/null +++ b/tests/rename-no-replace.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + portableRenameNoReplaceErrorCode, + renameNoReplace, + setRenameNoReplaceBackendForTests, +} from "../src/lib/rename-no-replace"; + +afterEach(() => setRenameNoReplaceBackendForTests(undefined)); + +describe("atomic no-replace rename", () => { + test("normalizes platform-specific native errors", () => { + expect(portableRenameNoReplaceErrorCode("win32", 183)).toBe("EEXIST"); + expect(portableRenameNoReplaceErrorCode("win32", 17)).toBe("EXDEV"); + expect(portableRenameNoReplaceErrorCode("win32", 206)).toBe("ENAMETOOLONG"); + expect(portableRenameNoReplaceErrorCode("linux", 17)).toBe("EEXIST"); + expect(portableRenameNoReplaceErrorCode("linux", 38)).toBe("ENOTSUP"); + expect(portableRenameNoReplaceErrorCode("darwin", 102)).toBe("ENOTSUP"); + }); + + test("moves an absent destination and never overwrites an existing one", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-rename-no-replace-")); + try { + const source = join(dir, "source"); + const destination = join(dir, "destination"); + writeFileSync(source, "source", "utf8"); + renameNoReplace(source, destination); + expect(existsSync(source)).toBe(false); + expect(readFileSync(destination, "utf8")).toBe("source"); + + writeFileSync(source, "second source", "utf8"); + let thrown: unknown; + try { renameNoReplace(source, destination); } catch (error) { thrown = error; } + expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("EEXIST"); + expect((thrown as NodeJS.ErrnoException | undefined)?.path).toBe(source); + expect((thrown as NodeJS.ErrnoException & { dest?: string } | undefined)?.dest).toBe(destination); + expect(readFileSync(source, "utf8")).toBe("second source"); + expect(readFileSync(destination, "utf8")).toBe("source"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")("moves the symlink entry rather than its target", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-rename-no-replace-link-")); + try { + const target = join(dir, "target"); + const source = join(dir, "source-link"); + const destination = join(dir, "destination-link"); + writeFileSync(target, "target", "utf8"); + symlinkSync(target, source, "file"); + renameNoReplace(source, destination); + expect(existsSync(source)).toBe(false); + expect(lstatSync(destination).isSymbolicLink()).toBe(true); + expect(readlinkSync(destination)).toBe(target); + expect(readFileSync(target, "utf8")).toBe("target"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform !== "win32")("moves absolute Windows paths longer than MAX_PATH", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-rename-no-replace-long-")); + try { + let dir = root; + while (dir.length < 280) dir = join(dir, "segment-0123456789abcdef"); + mkdirSync(dir, { recursive: true }); + const source = join(dir, "source"); + const destination = join(dir, "destination"); + expect(source.length).toBeGreaterThan(260); + writeFileSync(source, "long path", "utf8"); + renameNoReplace(source, destination); + expect(existsSync(source)).toBe(false); + expect(readFileSync(destination, "utf8")).toBe("long path"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects NUL paths with source and destination metadata", () => { + let thrown: unknown; + try { renameNoReplace("source\0suffix", "destination"); } catch (error) { thrown = error; } + expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("EINVAL"); + expect((thrown as NodeJS.ErrnoException | undefined)?.path).toBe("source\0suffix"); + expect((thrown as NodeJS.ErrnoException & { dest?: string } | undefined)?.dest).toBe("destination"); + }); + + test("fails closed when the native backend is unavailable", () => { + setRenameNoReplaceBackendForTests(null); + let thrown: unknown; + try { renameNoReplace("source", "destination"); } catch (error) { thrown = error; } + expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("ENOTSUP"); + expect((thrown as NodeJS.ErrnoException | undefined)?.path).toBe("source"); + }); + + test("preserves native missing-source metadata", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-rename-no-replace-missing-")); + try { + const source = join(dir, "missing"); + const destination = join(dir, "destination"); + let thrown: unknown; + try { renameNoReplace(source, destination); } catch (error) { thrown = error; } + expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("ENOENT"); + expect((thrown as NodeJS.ErrnoException | undefined)?.path).toBe(source); + expect((thrown as NodeJS.ErrnoException & { dest?: string } | undefined)?.dest).toBe(destination); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From c8b7f5e7804e4bbcb7c4d2c712eb075633cab64a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:17:25 +0900 Subject: [PATCH 2/2] test(codex): cover dangling no-replace target --- tests/rename-no-replace.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/rename-no-replace.test.ts b/tests/rename-no-replace.test.ts index 6a448c733..a85bbfa82 100644 --- a/tests/rename-no-replace.test.ts +++ b/tests/rename-no-replace.test.ts @@ -43,6 +43,28 @@ describe("atomic no-replace rename", () => { } }); + test.skipIf(process.platform === "win32")("refuses a dangling destination symlink", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-rename-no-replace-dangling-")); + try { + const source = join(dir, "source"); + const destination = join(dir, "destination"); + const missingTarget = join(dir, "missing"); + writeFileSync(source, "source", "utf8"); + symlinkSync(missingTarget, destination, "file"); + + let thrown: unknown; + try { renameNoReplace(source, destination); } catch (error) { thrown = error; } + + expect((thrown as NodeJS.ErrnoException | undefined)?.code).toBe("EEXIST"); + expect(readFileSync(source, "utf8")).toBe("source"); + expect(lstatSync(destination).isSymbolicLink()).toBe(true); + expect(readlinkSync(destination)).toBe(missingTarget); + expect(existsSync(destination)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test.skipIf(process.platform === "win32")("moves the symlink entry rather than its target", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-rename-no-replace-link-")); try {