Drops user-store's private _atomicWriteJSON copy (pid+Date.now() tmp names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js (DC-099 canonical: fsync'd same-dir exclusive-create 0600 tmp -> rename -> parent-dir fsync, cleanup-on-failure). All 3 persisted files routed: users.json, authorized-users.json, .bootstrapped sentinel. Sole consumers are JSON.parse readers — dropped trailing newline unobservable (judge verified repo-wide). +2 store-level regression tests pin 0600 / complete JSON / no temp leftovers across all three files, incl. the bootstrap path writing three files back-to-back in one login. Judge: GLM-5.3 cold read, round-1 A, deleg_f632f05c. Verdict: urn:ump:5fivvveqhcbkl6os4dhidchkvjjzbvi7rgj6znaovp6bfnvmcmsq Full suite: 121 suites / 2772 tests green.
300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
/**
|
|
* DC-099: canonical atomic file writer (src/utils/atomic-write.js).
|
|
*
|
|
* The notification config's two write paths (load-time canonicalization
|
|
* write-back and the UI saveConfig) used plain fs.writeFileSync — a crash or
|
|
* power loss mid-write could leave a truncated/empty notifications.json. The
|
|
* same risk exists in every store that grew its own private
|
|
* _atomicWriteJSON copy (invite-store, user-store, share-store, …).
|
|
*
|
|
* These tests pin the shared writer's contract:
|
|
* - durability: fsync before rename, exclusive create, 0600 default
|
|
* - atomicity: destination only ever replaced via rename
|
|
* - failure: destination untouched, temp cleaned up, error propagated
|
|
* - JSON helper: single serialization shape (2-space, no trailing newline —
|
|
* notification-manager._persistCanonicalForm depends on byte-for-byte
|
|
* idempotence)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { atomicWriteFile, atomicWriteJSON, tmpPathFor } = require('../src/utils/atomic-write');
|
|
|
|
// Real-FS tests: the actual syscalls, in a private temp dir.
|
|
describe('DC-099 atomic-write (real fs)', () => {
|
|
let dir;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc099-atomic-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('writes contents and returns the final path', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
const ret = atomicWriteFile(target, '{"a":1}');
|
|
expect(ret).toBe(target);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('{"a":1}');
|
|
});
|
|
|
|
test('replaces an existing file completely (no torn writes possible)', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
atomicWriteFile(target, 'x'.repeat(1000));
|
|
atomicWriteFile(target, 'y'.repeat(10));
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('y'.repeat(10));
|
|
});
|
|
|
|
test('creates the file 0600 by default', () => {
|
|
const target = path.join(dir, 'secret.json');
|
|
atomicWriteJSON(target, { ok: true });
|
|
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
test('honors an explicit mode override', () => {
|
|
const target = path.join(dir, 'public.json');
|
|
atomicWriteFile(target, '{}', { mode: 0o644 });
|
|
expect(fs.statSync(target).mode & 0o777).toBe(0o644);
|
|
});
|
|
|
|
test('leaves no temp files behind after success', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
atomicWriteFile(target, 'abc');
|
|
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
});
|
|
|
|
test('two rapid writes both land (unique tmp names per write)', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
atomicWriteFile(target, 'first');
|
|
atomicWriteFile(target, 'second');
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('second');
|
|
});
|
|
|
|
test('atomicWriteJSON serializes 2-space, no trailing newline', () => {
|
|
const target = path.join(dir, 'conf.json');
|
|
atomicWriteJSON(target, { a: { b: 1 } });
|
|
const raw = fs.readFileSync(target, 'utf8');
|
|
expect(raw).toBe('{\n "a": {\n "b": 1\n }\n}');
|
|
});
|
|
|
|
test('write failure leaves the destination untouched and cleans the temp file', () => {
|
|
const target = path.join(dir, 'state.json');
|
|
fs.writeFileSync(target, 'ORIGINAL');
|
|
const origWrite = fs.writeSync;
|
|
fs.writeSync = () => {
|
|
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
|
|
};
|
|
try {
|
|
expect(() => atomicWriteFile(target, 'NEW-CONTENT')).toThrow(/ENOSPC/);
|
|
} finally {
|
|
fs.writeSync = origWrite;
|
|
}
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('ORIGINAL');
|
|
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
});
|
|
|
|
test('tmpPathFor: unique per call, hidden dotfile in the same directory', () => {
|
|
const a = tmpPathFor('/data/x.json');
|
|
const b = tmpPathFor('/data/x.json');
|
|
expect(a).not.toBe(b);
|
|
expect(path.dirname(a)).toBe('/data');
|
|
expect(path.basename(a)).toMatch(/^\.x\.json\.tmp-/);
|
|
});
|
|
});
|
|
|
|
// Mocked-FS tests: pin the syscall DISCIPLINE itself (order + flags), which
|
|
// the real-fs tests can't observe directly.
|
|
describe('DC-099 atomic-write syscall discipline (mocked fs)', () => {
|
|
const calls = [];
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
const rec = (name, impl) =>
|
|
jest.spyOn(fs, name).mockImplementation((...args) => {
|
|
calls.push(name);
|
|
return impl(...args);
|
|
});
|
|
rec('openSync', () => 3);
|
|
rec('writeSync', () => 8);
|
|
rec('fsyncSync', () => {});
|
|
rec('closeSync', () => {});
|
|
rec('renameSync', () => {});
|
|
rec('unlinkSync', () => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
test('order: open → write → fsync → close → rename, then dir fsync (open → fsync → close)', () => {
|
|
atomicWriteFile('/data/x.json', '{"a":1}');
|
|
expect(calls).toEqual([
|
|
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync',
|
|
'openSync', 'fsyncSync', 'closeSync',
|
|
]);
|
|
});
|
|
|
|
test('dir fsync opens the PARENT directory (second openSync), not another tmp file', () => {
|
|
atomicWriteFile('/data/x.json', '{}');
|
|
const dirOpen = fs.openSync.mock.calls[1];
|
|
expect(dirOpen[0]).toBe('/data');
|
|
expect(dirOpen[1]).toBe('r');
|
|
});
|
|
|
|
test('dir fsync failure is swallowed (write still succeeds)', () => {
|
|
let n = 0;
|
|
fs.fsyncSync.mockImplementation(() => {
|
|
n += 1;
|
|
if (n === 2) throw new Error('EINVAL: invalid argument'); // 2nd fsync = dir
|
|
});
|
|
expect(() => atomicWriteFile('/data/x.json', '{}')).not.toThrow();
|
|
expect(fs.renameSync).toHaveBeenCalled();
|
|
});
|
|
|
|
test('open uses exclusive-create with the 0600 default on the tmp path', () => {
|
|
atomicWriteFile('/data/x.json', '{}');
|
|
const [tmpPath, flags, modeArg] = fs.openSync.mock.calls[0];
|
|
expect(tmpPath).toMatch(/^\/data\/\.x\.json\.tmp-/);
|
|
expect(flags).toBe('wx');
|
|
expect(modeArg).toBe(0o600);
|
|
});
|
|
|
|
test('write passes the payload with utf8 encoding', () => {
|
|
atomicWriteFile('/data/x.json', '{"a":1}');
|
|
expect(fs.writeSync.mock.calls[0]).toEqual([3, '{"a":1}', null, 'utf8']);
|
|
});
|
|
|
|
test('rename swaps a same-dir temp onto the target', () => {
|
|
atomicWriteFile('/data/x.json', '{}');
|
|
const [tmp, dest] = fs.renameSync.mock.calls[0];
|
|
expect(tmp).toMatch(/\/data\/\.x\.json\.tmp-/);
|
|
expect(dest).toBe('/data/x.json');
|
|
});
|
|
|
|
test('rename failure unlinks the temp and propagates the error', () => {
|
|
fs.renameSync.mockImplementation(() => {
|
|
calls.push('renameSync');
|
|
throw new Error('EXDEV: cross-device link not permitted');
|
|
});
|
|
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EXDEV/);
|
|
expect(calls).toEqual([
|
|
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync', 'unlinkSync',
|
|
]);
|
|
});
|
|
|
|
test('open failure propagates without write/rename (nothing was created)', () => {
|
|
fs.openSync.mockImplementation(() => {
|
|
calls.push('openSync');
|
|
throw new Error('EACCES: permission denied');
|
|
});
|
|
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EACCES/);
|
|
// best-effort unlink of the never-created temp, then stop
|
|
expect(calls).toEqual(['openSync', 'unlinkSync']);
|
|
});
|
|
});
|
|
|
|
// DC-100: invite-store migrated off its private _atomicWriteJSON copy onto
|
|
// the canonical writer. Store-level pins: writes are durable-canonical
|
|
// (0600, complete JSON, no temp leftovers) even under back-to-back mutations
|
|
// — the access pattern that could collide tmp names in the naive copy.
|
|
describe('DC-100 invite-store on canonical atomic-write (real fs)', () => {
|
|
let dir, store;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc100-invite-'));
|
|
store = require('../src/security/invite-store').createInviteStore({ dataDir: dir });
|
|
});
|
|
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
|
|
test('issued invite lands as complete JSON at mode 0600 with no temp leftovers', async () => {
|
|
const r = await store.issue({ email: 'dc100@x.com', ttlMs: 60_000 });
|
|
expect(r.ok).toBe(true);
|
|
const file = path.join(dir, 'invites.json');
|
|
const st = fs.statSync(file);
|
|
expect(st.mode & 0o777).toBe(0o600);
|
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
expect(Object.keys(data.invites)).toHaveLength(1);
|
|
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
|
|
test('back-to-back mutations (issue, revoke, issue) never collide on tmp names', async () => {
|
|
const a = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
|
|
const b = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
|
|
await store.revoke(a.id);
|
|
const c = await store.issue({ email: 'c@x.com', ttlMs: 60_000 });
|
|
expect(b.ok).toBe(true);
|
|
expect(c.ok).toBe(true);
|
|
const data = JSON.parse(fs.readFileSync(path.join(dir, 'invites.json'), 'utf8'));
|
|
expect(Object.keys(data.invites).sort()).toEqual([b.id, c.id].sort());
|
|
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// DC-101: user-store migrated off its private _atomicWriteJSON copy onto
|
|
// the canonical writer. Store-level pins across ALL THREE persisted files
|
|
// (users.json, authorized-users.json, .bootstrapped sentinel): 0600 mode,
|
|
// complete JSON, no temp leftovers — including the bootstrap path that
|
|
// writes two JSON files plus the sentinel back-to-back in one login.
|
|
describe('DC-101 user-store on canonical atomic-write (real fs)', () => {
|
|
let dir, store;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc101-user-'));
|
|
store = require('../src/security/user-store').createUserStore({ dataDir: dir });
|
|
});
|
|
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
|
|
test('bootstrap login persists users.json + allowlist + sentinel at 0600, complete JSON, no leftovers', async () => {
|
|
const r = await store.login({ email: 'dc101@x.com', ip: '10.0.0.1' });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.isBootstrap).toBe(true);
|
|
|
|
const usersSt = fs.statSync(path.join(dir, 'users.json'));
|
|
const allowSt = fs.statSync(path.join(dir, 'authorized-users.json'));
|
|
const sentSt = fs.statSync(path.join(dir, '.bootstrapped'));
|
|
expect(usersSt.mode & 0o777).toBe(0o600);
|
|
expect(allowSt.mode & 0o777).toBe(0o600);
|
|
expect(sentSt.mode & 0o777).toBe(0o600);
|
|
|
|
const users = JSON.parse(fs.readFileSync(path.join(dir, 'users.json'), 'utf8'));
|
|
expect(Object.keys(users.users)).toHaveLength(1);
|
|
expect(users.users[users.order[0]].role).toBe('admin');
|
|
const allowlist = JSON.parse(fs.readFileSync(path.join(dir, 'authorized-users.json'), 'utf8'));
|
|
expect(allowlist.emails).toEqual(['dc101@x.com']);
|
|
const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8'));
|
|
expect(sentinel.adminEmail).toBe('dc101@x.com');
|
|
|
|
const leftovers = fs.readdirSync(dir).filter(
|
|
(f) => f !== 'users.json' && f !== 'authorized-users.json' && f !== '.bootstrapped'
|
|
);
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
|
|
test('back-to-back mutations (login, allowlist add/remove, role set) never collide on tmp names', async () => {
|
|
const a = await store.login({ email: 'admin@x.com' });
|
|
expect(a.isBootstrap).toBe(true);
|
|
await store.addToAllowlist('b@x.com');
|
|
const b = await store.login({ email: 'b@x.com' });
|
|
expect(b.ok).toBe(true);
|
|
expect(b.role).toBe('operator');
|
|
await store.setRole(b.user.id, 'viewer');
|
|
await store.removeFromAllowlist('b@x.com');
|
|
|
|
const users = JSON.parse(fs.readFileSync(path.join(dir, 'users.json'), 'utf8'));
|
|
expect(users.users[b.user.id].role).toBe('viewer');
|
|
const allowlist = JSON.parse(fs.readFileSync(path.join(dir, 'authorized-users.json'), 'utf8'));
|
|
expect(allowlist.emails).toEqual(['admin@x.com']);
|
|
|
|
const leftovers = fs.readdirSync(dir).filter(
|
|
(f) => f !== 'users.json' && f !== 'authorized-users.json' && f !== '.bootstrapped'
|
|
);
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
});
|