refactor(persistence): migrate share-store to canonical atomic-write util (DC-102) [glm-grade=A]
Drops share-store's private _atomicWriteJSON copy (pid+Date.now() tmp names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js: fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync, cleanup-on-failure. Also fixes a latent bug in the same file: .share-secret signing-key persistence used plain fs.writeFileSync — a torn write would silently rotate the HMAC key on next boot, invalidating every outstanding share signature (links 404, no error logged). Now atomicWriteFile. Sole consumers parse JSON / trim(), so the dropped trailing newline on shares.json is unobservable. +2 regression tests pin 0600 on both files, complete content, zero tmp leftovers, and HMAC-still-verifies via getRaw. Judge: GLM-5.3 cold read, round-1 A, deleg_50d5c239 (41s, 4 calls). Verdict: urn:ump:ehblegyr5eko4hgyfylnrqk72hh3crmc43o2yt5ragfgvyc4zrdq Full suite: 121 suites / 2774 tests green.
This commit is contained in:
@@ -297,3 +297,66 @@ describe('DC-101 user-store on canonical atomic-write (real fs)', () => {
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// DC-102: share-store migrated off its private _atomicWriteJSON copy onto
|
||||
// the canonical writer. Store-level pins: shares.json AND the .share-secret
|
||||
// signing key land as complete content at mode 0600 with no temp leftovers —
|
||||
// a torn secret write would silently rotate the key and invalidate every
|
||||
// outstanding share signature on next boot.
|
||||
describe('DC-102 share-store on canonical atomic-write (real fs)', () => {
|
||||
let dir, store;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc102-share-'));
|
||||
store = require('../src/security/share-store').createShareStore({ dataDir: dir });
|
||||
});
|
||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
||||
|
||||
test('issued share + persisted signing secret land at 0600, complete, no temp leftovers', async () => {
|
||||
const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
|
||||
expect(r.ok).toBe(true);
|
||||
|
||||
const sharesFile = path.join(dir, 'shares.json');
|
||||
const secretFile = path.join(dir, '.share-secret');
|
||||
const sharesSt = fs.statSync(sharesFile);
|
||||
const secretSt = fs.statSync(secretFile);
|
||||
expect(sharesSt.mode & 0o777).toBe(0o600);
|
||||
expect(secretSt.mode & 0o777).toBe(0o600);
|
||||
|
||||
// complete JSON — a torn write would fail JSON.parse right here
|
||||
const data = JSON.parse(fs.readFileSync(sharesFile, 'utf8'));
|
||||
expect(Object.keys(data.shares)).toHaveLength(1);
|
||||
// complete secret — readable, 32+ bytes after trim, trailing newline kept
|
||||
const secret = fs.readFileSync(secretFile, 'utf8');
|
||||
expect(secret.trim().length).toBeGreaterThanOrEqual(32);
|
||||
expect(secret.endsWith('\n')).toBe(true);
|
||||
|
||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret');
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
test('back-to-back mutations (issue x2, subscribe, tailscale use, revoke) never collide on tmp names', async () => {
|
||||
const a = await store.issuePublic({ serviceId: 'svc', subscribeCap: 5 });
|
||||
const b = await store.issueTailscale({ serviceId: 'svc', email: 'dc102@x.com' });
|
||||
await store.recordPublicSubscribe(a.token, { email: 'sub@x.com' });
|
||||
await store.recordTailscaleUse(b.token, { deviceId: 'device-1' });
|
||||
await store.revoke(a.id);
|
||||
|
||||
// b remains outstanding and fully redeemable state on disk
|
||||
const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||
expect(Object.keys(data.shares)).toEqual([b.id]);
|
||||
expect(data.shares[b.id].usedAt).toBeTruthy();
|
||||
expect(data.shares[b.id].usedBy).toBe('device-1');
|
||||
|
||||
// signature verification still passes against the atomically persisted
|
||||
// secret — getRaw checks hash + HMAC only (not used-state), so a rotated
|
||||
// or torn secret would return null here.
|
||||
const raw = await store.getRaw(b.token);
|
||||
expect(raw).toBeTruthy();
|
||||
expect(raw.id).toBe(b.id);
|
||||
expect(raw.kind).toBe('tailscale');
|
||||
|
||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret');
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user