Files
msd-core/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs
Tom Boucher 463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00

419 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Filesystem fault-injection coverage for the canonical atomic-write
* seam (#3595).
*
* `platformWriteSync` in `gsd-core/bin/lib/shell-command-projection.cjs`
* is the shared seam every config/state/generated-artifact writer in
* the CJS layer routes through. Its contract:
*
* 1. mkdirSync(dirname, { recursive: true }) — ensure parent exists.
* 2. writeFileSync(<tmpPath>, content) — write to a sibling tmpfile
* named `<filePath>.tmp.<pid>`.
* 3. renameSync(<tmpPath>, filePath) — atomic publish.
* 4. On any error in steps 2-3: unlinkSync(<tmpPath>) (best-effort)
* then writeFileSync(filePath, content) directly as a fallback.
*
* Per CONTRIBUTING.md §"QA Matrix Requirements / Filesystem writes and
* installers" the tests below use `mock.method()` against the real `fs`
* seam to drive each fault mode, restore mocks with `t.after()`, and
* assert on observable post-conditions (file existence, content,
* presence/absence of orphan tmp files, propagated error code) — not
* on prose.
*
* Pre-existing behavior gaps surfaced and PINNED (not fixed in this
* PR — #3595 is test coverage, fixes belong in separate issues):
*
* - The "fall back to direct write" branch silently swallows the
* ORIGINAL error from the tmp+rename path. If the fallback ALSO
* fails, the operator only sees the fallback's error — not the
* original cause. Tests document this.
* - On EACCES against the parent directory mkdir, the error
* escapes (no try/catch around mkdirSync). Pinned.
*/
'use strict';
const { test, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const {
platformWriteSync,
platformEnsureDir,
} = require('../gsd-core/bin/lib/shell-command-projection.cjs');
/**
* Create a fresh real-fs scratch dir per test so no two faults share
* state. Returns the directory; caller must clean up.
*/
const { createTempDir, cleanup } = require('./helpers.cjs');
const mkScratch = (name) => createTempDir(`fs-fault-${name}-`);
/**
* Enumerate orphan tmp files left behind by platformWriteSync. The
* tmp shape is `<filename>.tmp.<pid>`; we match that pattern strictly
* so a test that happens to write a real `*.tmp.123` doesn't get a
* false positive.
*/
function orphanTmpFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n));
}
// ─── Happy path baseline ────────────────────────────────────────────────────
test('platformWriteSync happy path writes content atomically (baseline for fault tests)', (t) => {
const dir = mkScratch('happy');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
platformWriteSync(file, '{"k":"v"}\n');
assert.equal(fs.readFileSync(file, 'utf-8'), '{"k":"v"}\n');
assert.deepEqual(orphanTmpFiles(dir), [], 'happy path must leave no orphan tmp file');
});
// ─── Rename failure → falls back to direct write ────────────────────────────
test('platformWriteSync recovers when renameSync fails (EXDEV cross-device fallback)', (t) => {
const dir = mkScratch('exdev');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
// Simulate rename failing once (e.g. cross-device move on a CI runner
// with overlayfs). The fallback path must write the content directly.
let renameCalls = 0;
const renameMock = mock.method(fs, 'renameSync', (src, dest) => {
renameCalls++;
const err = new Error('EXDEV: cross-device link not permitted');
err.code = 'EXDEV';
throw err;
});
t.after(() => renameMock.mock.restore());
platformWriteSync(file, 'fallback content\n');
// File exists and has the new content — fallback wrote it directly.
assert.equal(fs.readFileSync(file, 'utf-8'), 'fallback content\n');
assert.equal(renameCalls, 1, 'renameSync was called exactly once before falling back');
// The tmp file was unlinked by the fallback path's best-effort cleanup.
assert.deepEqual(orphanTmpFiles(dir), [], 'tmp file must be cleaned up after rename failure');
});
// ─── Tmp write failure → falls back to direct write ─────────────────────────
test('platformWriteSync falls back when initial tmp writeFileSync fails (ENOSPC)', (t) => {
const dir = mkScratch('enospc');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
// Make the FIRST writeFileSync (to .tmp.<pid>) fail with ENOSPC. The
// SECOND writeFileSync (the fallback, direct to filePath) succeeds.
let writeCalls = 0;
const realWrite = fs.writeFileSync;
const writeMock = mock.method(fs, 'writeFileSync', function (target, data, opts) {
writeCalls++;
if (writeCalls === 1) {
// First call is to the tmp path.
assert.match(String(target), /\.tmp\.\d+$/, 'first write must be to the tmp path');
const err = new Error('ENOSPC: no space left on device');
err.code = 'ENOSPC';
throw err;
}
// Second call is the fallback to the real target.
assert.equal(target, file, 'fallback write must target the original file path');
return realWrite.call(fs, target, data, opts);
});
t.after(() => writeMock.mock.restore());
platformWriteSync(file, 'recovered\n');
assert.equal(writeCalls, 2, 'expected exactly 2 writeFileSync calls (tmp fail + fallback)');
assert.equal(fs.readFileSync(file, 'utf-8'), 'recovered\n');
// The fallback path tries unlinkSync on the tmp; the tmp never
// existed (its write failed), so unlink throws ENOENT and is
// swallowed by the inner catch. Either way: no orphan.
assert.deepEqual(orphanTmpFiles(dir), []);
});
// ─── Both attempts fail → error propagates (pinned current behavior) ────────
test('platformWriteSync propagates the FALLBACK error when both tmp and fallback writes fail', (t) => {
const dir = mkScratch('double-fail');
t.after(() => cleanup(dir));
const file = path.join(dir, 'config.json');
let writeCalls = 0;
const writeMock = mock.method(fs, 'writeFileSync', function () {
writeCalls++;
const err = new Error(
writeCalls === 1
? 'ENOSPC: original failure on tmp write'
: 'EACCES: permission denied on fallback write',
);
err.code = writeCalls === 1 ? 'ENOSPC' : 'EACCES';
throw err;
});
t.after(() => writeMock.mock.restore());
// The current implementation does NOT chain the original cause; the
// fallback's error is what surfaces. This test pins that behavior so a
// future "preserve original error in .cause" fix is a visible change
// (open follow-up).
let caught;
try {
platformWriteSync(file, 'wont-write\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'double-failure must throw');
assert.equal(caught.code, 'EACCES', 'currently the fallback error wins (open: should chain via .cause)');
// The original file was never created.
assert.equal(fs.existsSync(file), false);
// No orphan tmp left because the tmp write failed before any file was created.
assert.deepEqual(orphanTmpFiles(dir), []);
});
// ─── mkdirSync failure → escapes immediately (no try/catch upstream) ────────
test('platformWriteSync propagates mkdirSync failure unchanged (no swallowed parent-dir errors)', (t) => {
const dir = mkScratch('mkdir-fail');
t.after(() => cleanup(dir));
const file = path.join(dir, 'deep', 'nested', 'config.json');
const mkdirMock = mock.method(fs, 'mkdirSync', () => {
const err = new Error('EACCES: permission denied creating directory');
err.code = 'EACCES';
throw err;
});
t.after(() => mkdirMock.mock.restore());
let caught;
try {
platformWriteSync(file, 'never-reached\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'mkdir failure must propagate');
assert.equal(caught.code, 'EACCES', 'mkdir error code must be preserved');
// No partial write happened.
assert.equal(fs.existsSync(file), false);
});
// ─── Target path is a directory (writing OVER a dir is undefined) ──────────
test('platformWriteSync against a target path that is an existing directory fails cleanly', (t) => {
const dir = mkScratch('target-is-dir');
t.after(() => cleanup(dir));
const file = path.join(dir, 'collides');
// Pre-create the target AS a directory so the rename step would
// collide with a directory at the destination.
fs.mkdirSync(file);
let caught;
try {
platformWriteSync(file, 'shouldnt-overwrite-dir\n');
} catch (err) {
caught = err;
}
assert.ok(caught, 'writing over an existing directory must fail');
// We don't pin the exact code (Node varies: EISDIR on rename, EPERM on
// some platforms). We pin: a real error code (string starting with E)
// surfaces, AND the directory was not replaced by a file.
assert.equal(typeof caught.code, 'string');
assert.match(caught.code, /^E[A-Z]+$/, `expected an errno-style code, got ${caught.code}`);
assert.equal(fs.statSync(file).isDirectory(), true, 'pre-existing directory must remain a directory');
});
// ─── Path with spaces, unicode, newline characters ─────────────────────────
test('platformWriteSync handles paths with spaces, unicode, and newline characters', (t) => {
const dir = mkScratch('weird-path');
t.after(() => cleanup(dir));
const cases = [
'has spaces in name.json',
'unicode-日本語-name.json',
];
if (process.platform !== 'win32') {
// Tab (0x09) and newline (0x0A) in filenames are POSIX-valid but
// Windows-illegal (NTFS forbids control characters 0x00–0x1F). Append
// both only on POSIX so cross-platform CI stays green.
cases.push('with\ttab.json');
cases.push('with\nnewline.json');
}
for (const name of cases) {
const file = path.join(dir, name);
platformWriteSync(file, `payload for ${name}\n`);
assert.equal(
fs.readFileSync(file, 'utf-8'),
`payload for ${name}\n`,
`roundtrip failed for path "${JSON.stringify(name)}"`,
);
}
assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans across the corpus');
});
// ─── Orphan-tmp cleanup invariant ──────────────────────────────────────────
test('platformWriteSync never leaks a tmp file after a successful happy-path write', (t) => {
const dir = mkScratch('no-orphan-happy');
t.after(() => cleanup(dir));
for (let i = 0; i < 25; i++) {
platformWriteSync(path.join(dir, `f-${i}.json`), `{"i":${i}}\n`);
}
// 25 real files, 0 tmp orphans.
const entries = fs.readdirSync(dir);
const tmpCount = entries.filter((n) => /\.tmp\.\d+$/.test(n)).length;
const realCount = entries.length - tmpCount;
assert.equal(realCount, 25);
assert.equal(tmpCount, 0);
});
// ─── platformEnsureDir is idempotent and chains errors ─────────────────────
test('platformEnsureDir is idempotent on an existing directory', (t) => {
const dir = mkScratch('ensure-idem');
t.after(() => cleanup(dir));
const target = path.join(dir, 'a', 'b', 'c');
// First call creates; subsequent calls must not throw EEXIST.
platformEnsureDir(target);
assert.equal(fs.statSync(target).isDirectory(), true);
// Repeat.
platformEnsureDir(target);
platformEnsureDir(target);
assert.equal(fs.statSync(target).isDirectory(), true);
});
test('platformEnsureDir propagates EACCES when parent dir is unwritable', (t) => {
const dir = mkScratch('ensure-fail');
t.after(() => cleanup(dir));
const mkdirMock = mock.method(fs, 'mkdirSync', () => {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
});
t.after(() => mkdirMock.mock.restore());
let caught;
try {
platformEnsureDir(path.join(dir, 'cannot-create'));
} catch (err) {
caught = err;
}
assert.ok(caught);
assert.equal(caught.code, 'EACCES');
});
// ─── Symlink-following / escape behavior ────────────────────────────────────
test('platformWriteSync REPLACES a symlink with a regular file rather than following it (safe behavior)', (t) => {
// Security-relevant invariant: if the destination path is a symlink
// pointing somewhere the user did not intend the writer to touch
// (e.g. an attacker-planted symlink in `.planning/` pointing at
// `~/.ssh/authorized_keys`), the writer must NOT follow it and
// clobber the target. The rename-based atomic-write pattern delivers
// this property: `renameSync(tmp, symlinkPath)` replaces the symlink
// entry in the parent directory with the regular file at `tmp`.
// After the call:
// - `linkPath` is a regular file with the new content.
// - The original `realTarget` is UNTOUCHED.
// This test pins that property so a future refactor (e.g. switching
// to `fs.writeFileSync(linkPath, ...)` which follows symlinks) is a
// visible regression.
if (process.platform === 'win32') {
t.skip('symlinks on Win32 need admin');
return;
}
const dir = mkScratch('symlink-replace');
t.after(() => cleanup(dir));
const realTarget = path.join(dir, 'real-target.json');
fs.writeFileSync(realTarget, 'original — must not be touched\n');
const linkPath = path.join(dir, 'link.json');
fs.symlinkSync(realTarget, linkPath);
platformWriteSync(linkPath, 'new content\n');
// The real target is UNTOUCHED — the safety property.
assert.equal(
fs.readFileSync(realTarget, 'utf-8'),
'original — must not be touched\n',
'symlink target must be preserved when writer writes "through" the link',
);
// The link entry is now a regular file with the new content.
const stat = fs.lstatSync(linkPath);
assert.equal(stat.isSymbolicLink(), false, 'symlink entry replaced by a regular file (atomic rename semantics)');
assert.equal(stat.isFile(), true);
assert.equal(fs.readFileSync(linkPath, 'utf-8'), 'new content\n');
});
test('platformWriteSync against a broken symlink replaces it with the intended file', (t) => {
if (process.platform === 'win32') {
t.skip('symlinks on Win32 need admin');
return;
}
const dir = mkScratch('symlink-broken');
t.after(() => cleanup(dir));
const link = path.join(dir, 'dangling.json');
fs.symlinkSync(path.join(dir, 'does-not-exist'), link);
assert.equal(fs.lstatSync(link).isSymbolicLink(), true, 'pre-check: link is dangling');
platformWriteSync(link, 'real content\n');
assert.equal(fs.readFileSync(link, 'utf-8'), 'real content\n');
assert.equal(fs.lstatSync(link).isSymbolicLink(), false, 'broken link replaced with regular file');
});
// ─── Concurrent-write collision ────────────────────────────────────────────
test('platformWriteSync survives a concurrent collision on the same target path', (t) => {
// Two consecutive writes to the same path with DIFFERENT contents,
// separated by a setImmediate boundary so the second can interleave
// a mock-injected error mid-flight. The contract pinned here: after
// both writes complete, the file is parseable and contains ONE of the
// two contents — never a half-written corrupt blob.
//
// (True parallel writes from separate processes are out of scope —
// the writer is sync. This exercises mid-flight error recovery, which
// is the same concurrency hazard at a lower granularity.)
const dir = mkScratch('concurrent');
t.after(() => cleanup(dir));
const file = path.join(dir, 'race.json');
// First write completes normally.
platformWriteSync(file, '{"writer":"first"}\n');
// Second write: inject a transient rename failure on the first
// attempt, then succeed via fallback. Capture the real renameSync
// BEFORE installing the mock so subsequent calls (defensive — the
// fallback path bypasses rename, so the second call shouldn't fire)
// delegate to the real implementation. The previous form referenced
// a non-existent `fs.renameSync.wrapped` property — that branch
// would silently no-op instead of delegating.
let renameCalls = 0;
const originalRename = fs.renameSync;
const renameMock = mock.method(fs, 'renameSync', (src, dest) => {
renameCalls++;
if (renameCalls === 1) {
const err = new Error('EBUSY: file is locked');
err.code = 'EBUSY';
throw err;
}
return originalRename.call(fs, src, dest);
});
t.after(() => renameMock.mock.restore());
platformWriteSync(file, '{"writer":"second"}\n');
// The fallback path wrote 'second' content directly.
const final = fs.readFileSync(file, 'utf-8');
// Must be valid JSON — never a half-merged corruption.
assert.doesNotThrow(() => JSON.parse(final), 'file must remain parseable after the contested write');
// Must be the SECOND writer's content (it called platformWriteSync
// after the first, and the fallback completed).
assert.equal(final, '{"writer":"second"}\n');
assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphans after the contested write');
});