-
Notifications
You must be signed in to change notification settings - Fork 685
fix: preserve symlinked destinations in atomic config writes #869
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,8 +1,8 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; | ||
| import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join, resolve } from "node:path"; | ||
| import { dirname, join, resolve } from "node:path"; | ||
| import { Database } from "bun:sqlite"; | ||
| import * as z from "zod/v4"; | ||
| import { | ||
|
|
@@ -104,6 +104,42 @@ function isMissingPathError(error: unknown): boolean { | |
| return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a write target through any symlink before the temp+rename dance. | ||
| * | ||
| * rename(2) replaces a directory ENTRY. When the entry is itself a symlink | ||
| * (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`, | ||
| * say), renaming a sibling temp file over it destroys the link and leaves a plain | ||
| * file behind — the repo silently stops receiving writes. Resolving first puts both | ||
| * the temp file and the rename target inside the link's real directory, so the entry | ||
| * being replaced is the real file and the symlink survives. | ||
| * | ||
| * Same-filesystem atomicity is preserved because the temp file stays beside its | ||
| * resolved target. An unresolvable path (not yet created) falls back to the literal | ||
| * path, which is the correct target for a first write. | ||
| */ | ||
| export function resolveWriteTarget(path: string): string { | ||
| try { | ||
| return realpathSync(path); | ||
| } catch { | ||
| return path; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Re-apply the real-home guard to a RESOLVED write target. | ||
| * | ||
| * Callers such as saveConfig check only their logical config dir, which passes when | ||
| * OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture | ||
| * would land on the protected home the caller's own check just cleared, so the guard | ||
| * has to run again on wherever the write actually terminates. Inert in production, | ||
| * where the guard is disarmed. | ||
| */ | ||
| function assertResolvedTargetAllowed(path: string, target: string): void { | ||
| if (target === path) return; | ||
| assertNotRealHomeUnderTest(dirname(target)); | ||
| } | ||
|
|
||
| export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = { | ||
| write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), | ||
| harden: target => { | ||
|
|
@@ -115,13 +151,15 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO | |
| unlink: unlinkSync, | ||
| }): void { | ||
| recordOwnedConfigPath(resolveConfigDir(), path); | ||
| const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`; | ||
| const target = resolveWriteTarget(path); | ||
| assertResolvedTargetAllowed(path, target); | ||
| const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`; | ||
| let hardened = false; | ||
| try { | ||
| io.write(tmp, content); | ||
| io.harden(tmp); | ||
| hardened = true; | ||
| io.rename(tmp, path); | ||
| io.rename(tmp, target); | ||
| forgetHardenedSecretPath(tmp); | ||
| } catch (cause) { | ||
| let scrubbed = false; | ||
|
|
@@ -201,13 +239,15 @@ export async function atomicWriteFileAsync( | |
| truncate: target => truncateSync(target, 0), | ||
| unlink: unlinkSync, | ||
| }; | ||
| const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`; | ||
| const target = resolveWriteTarget(path); | ||
| assertResolvedTargetAllowed(path, target); | ||
| const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`; | ||
|
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 👍 / 👎. |
||
| let hardened = false; | ||
| try { | ||
| await effective.write(tmp, content); | ||
| await effective.harden(tmp); | ||
| hardened = true; | ||
| await effective.rename(tmp, path); | ||
| await effective.rename(tmp, target); | ||
| forgetHardenedSecretPath(tmp); | ||
| } catch (cause) { | ||
| let scrubbed = false; | ||
|
|
||
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.
In an armed test process, callers such as
saveConfigvalidate onlygetConfigDir()before reaching this helper; after this change a tempOPENCODEX_HOMEcontainingconfig.json -> <real home>/.opencodex/config.jsonnow resolves through the symlink and writes the protected real file, whereas the old rename would have replaced only the symlink in the temp directory. Please run the real-home guard against the resolved target or otherwise refuse symlink targets that escape the allowed test home before writing.Useful? React with 👍 / 👎.