Files
msd-core/tests/atomic-write-rename-failure.test.cjs
Tom Boucher e9868a92ba fix(#1875): route installer settings/defaults writes through atomic, lock-guarded primitives (#3966)
* fix(#1875): route writeSettings through atomicWriteFileSync

writeSettings is the sole writer of settings.json/settings.local.json for
six runtimes and wrote them with a naked fs.writeFileSync. Hosts discard the
entire settings file on any parse failure, so a crash mid-write cost the user
every hook, permission, env var, and statusline they had — not just GSD's.

Route it through the atomicWriteFileSync (temp+rename) already used elsewhere
in the installer and already bound in this file.

withWriteFailure in the migration integration harness matched only the final
destination path, so an atomic write bypassed the injection entirely and turned
a rollback assertion into a vacuous pass. It now also matches the .tmp- sibling.

Refs open-gsd/gsd-core#1874 (F5)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1875): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1876): honor the readSettings null contract in the #338 local-merge leg

readSettings returns null only for an unparseable file — its documented
"preserve existing, don't touch" signal. The #338 migration coerced that null
to {} and wrote the result back, so a settings.local.json with one stray comma
lost all its non-GSD content on the next install.

The guard stands the whole migration down rather than just the local write:
skipping the merge while still stripping the shared file would destroy the GSD
entries outright instead of relocating them.

Aborting here reaches a pre-existing latent crash that the clobber had been
masking. Both are fixed, with their own regression test:

- the unparseable-settings guard returned bare `undefined` while all five
  sibling early exits return the full result shape, so installAllRuntimes'
  statusline lookup (results.find(r => r.runtime)) threw;
- handleStatusline dereferences result.settings, which is null on every early
  exit, so the call site now falls through to the banner branch.

Both crashes reproduce on unmodified next with a malformed settings.local.json
and no migration involved.

Refs open-gsd/gsd-core#1874 (F6)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1876): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1877): lock and atomically write the machine-global ~/.gsd/defaults.json

Every non-Claude install read-modify-writes ~/.gsd/defaults.json with no lock
and two separate naked whole-file writes. The file is read by every runtime and
project on the machine, so concurrent installs lost each other's key, and a
crash in either write window truncated it — silently, because the read path
swallows parse errors and treats a corrupt file as absent.

Take the existing acquireInstallMigrationLock around the read-modify-write and
apply both mutations in one atomicWriteFileSync. An install that changes nothing
no longer rewrites the file at all.

Existing semantics are unchanged: the explicit resolve_model_ids:true opt-in
(#1569) and an existing "omit" are preserved, non-canonical values still default
to "omit" (#1156), a pre-existing runtime string is preserved (#2395), the
malformed-non-object recovery (#1657) stands, and both console lines still print
when both keys change.

The #2834 structural test sliced a fixed 1200-character window from the function
source; the added lock comment pushed an asserted token past it. The window now
tracks the function body, so a comment or guard cannot red it spuriously.

Refs open-gsd/gsd-core#1874 (F18)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1877): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1874): preserve target mode and create temp files exclusively in atomicWriteFileSync

* fix(#1874): write the migration-lock payload through the exclusive descriptor

* chore(#1874): bold-led changeset fragments + fragment for the hardening pass

* chore(#1874): rename the shadowed lock-release catch binding

* test(#1874): fix source-grep/try-finally violations, add missing fault-injection cases

- drop the /TypeError/.test(stderr) source-grep assertion (exitCode already
  proves the installer didn't crash)
- convert inline try/finally fs-mock restoration to t.after() across the F5/F18
  test suites, per this repo's no-try/finally-in-test-body rule
- add a rename-failure fault-injection case for atomicWriteFileSync
- add a read-only-.gsd-directory fault-injection case for the F18 lock+write path
- extract MAX_TEMP_FILE_ATTEMPTS constant, dedupe the partial-write-then-throw
  mock into a shared tests/helpers.cjs helper

Found during this session's own Standards-axis code-review pass on resurrected
PR #3385.

* chore(#1874): reset changeset fragments to pr:0 placeholder

The resurrected fragments carried the closed PR's number (3385). This is a
new PR, so reset to the pr:0 placeholder and backfill the real number once
gh pr create returns it, per CONTRIBUTING.md's PR Number Handling.

* chore(#1874): backfill changeset PR number (#3966)

---------

Co-authored-by: Richard Spiers <1355479+richardspiers@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@local>
2026-08-27 23:27:43 -04:00

85 lines
3.3 KiB
JavaScript

/**
* #1874 F5 hardening — atomicWriteFileSync (src/runtime-hooks-surface.cts)
* rename-failure fault injection.
*
* The temp-file + rename primitive documents a specific contract for a
* failure mid-swap (see the catch block wrapping shellCmdProjection.
* retryRenameSync in atomicWriteFileSync): the temp file is force-removed
* and the original error is rethrown, un-transformed. This exercises that
* contract directly rather than through an install() integration path, and
* asserts the pre-existing target is left byte-identical.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { atomicWriteFileSync } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
describe('#1874 F5: atomicWriteFileSync rename-failure fault injection', () => {
test('a renameSync failure mid-swap removes the temp file, leaves an existing target untouched, and rethrows', (t) => {
const dir = createTempDir('gsd-1874-f5-rename-fail-');
t.after(() => cleanup(dir));
const target = path.join(dir, 'hooks.json');
const prior = '{"version":1,"hooks":{}}\n';
fs.writeFileSync(target, prior);
const origRenameSync = fs.renameSync;
fs.renameSync = (src, dst) => {
if (dst === target) {
// A non-retryable code (see RENAME_RETRY_ERRNOS in
// shell-command-projection.cts) so the failure is immediate, not
// masked behind retryRenameSync's bounded Windows-lock retry loop.
throw Object.assign(new Error('simulated rename failure mid-swap'), { code: 'ENOSPC' });
}
return origRenameSync(src, dst);
};
t.after(() => { fs.renameSync = origRenameSync; });
assert.throws(
() => atomicWriteFileSync(target, '{"version":1,"hooks":{"new":true}}\n', 'utf8'),
(e) => e.code === 'ENOSPC' && /simulated rename failure mid-swap/.test(e.message),
'the rename error must propagate un-transformed'
);
assert.strictEqual(
fs.readFileSync(target, 'utf8'),
prior,
'the pre-existing target must be left byte-identical when rename fails'
);
const residue = fs.readdirSync(dir).filter((n) => n !== 'hooks.json');
assert.deepStrictEqual(residue, [], 'no atomic temp file may survive a failed rename');
});
test('a renameSync failure when no target pre-exists leaves no target and no temp residue', (t) => {
const dir = createTempDir('gsd-1874-f5-rename-fail-new-');
t.after(() => cleanup(dir));
const target = path.join(dir, 'hooks.json');
const origRenameSync = fs.renameSync;
fs.renameSync = (src, dst) => {
if (dst === target) {
throw Object.assign(new Error('simulated rename failure, no prior target'), { code: 'ENOSPC' });
}
return origRenameSync(src, dst);
};
t.after(() => { fs.renameSync = origRenameSync; });
assert.throws(
() => atomicWriteFileSync(target, '{"version":1,"hooks":{}}\n', 'utf8'),
(e) => e.code === 'ENOSPC',
'the rename error must propagate un-transformed'
);
assert.strictEqual(fs.existsSync(target), false, 'no target file may be created when rename fails');
assert.deepStrictEqual(fs.readdirSync(dir), [], 'no atomic temp file may survive a failed rename');
});
});