Repository navigation
DR-SEC-01g-G1: Denkraum ledger ownership primitive (option A) #811
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
24f0b15
2bf5b08
6eab0fe
896ff3f
a2883e9
cbadc71
57a0c81
582823e
4c56941
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 |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| #!/usr/bin/env node | ||
| import { createInterface } from 'node:readline'; | ||
| import { acquireOwnership, releaseOwnership } from './ownership.mjs'; | ||
| const ledgerPath = process.argv[2]; | ||
| if (!ledgerPath) { process.stderr.write('usage: ownership-contender.fixture.mjs <ledgerPath>\n'); process.exit(2); } | ||
| const session = await acquireOwnership(ledgerPath); | ||
| process.stdout.write(`${JSON.stringify({ ready: true })}\n`); | ||
| let released = false; | ||
| await new Promise((resolve, reject) => { | ||
| const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); | ||
| rl.on('line', async line => { | ||
| if (line.trim() !== 'release') return; | ||
| try { await releaseOwnership(ledgerPath, session.nonce); released = true; rl.close(); resolve(); } | ||
| catch (error) { reject(error); } | ||
| }); | ||
| rl.on('close', () => { if (!released) reject(new Error('stdin closed without release')); }); | ||
| }); | ||
| process.exit(0); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { randomBytes } from 'node:crypto'; | ||
| import { link, open, readFile, rename, stat, unlink } from 'node:fs/promises'; | ||
| import { dirname } from 'node:path'; | ||
| export const OWNERSHIP_HELD = 'OWNERSHIP_HELD'; | ||
| export const OWNERSHIP_MALFORMED = 'OWNERSHIP_MALFORMED'; | ||
| export const OWNERSHIP_RELEASE_MISMATCH = 'OWNERSHIP_RELEASE_MISMATCH'; | ||
| export const OWNERSHIP_RECOVERY_REFUSED = 'OWNERSHIP_RECOVERY_REFUSED'; | ||
| export const OWNERSHIP_IO = 'OWNERSHIP_IO'; | ||
| export class OwnershipError extends Error { | ||
| constructor(code) { super(code); this.name = 'OwnershipError'; this.code = code; } | ||
| } | ||
| export const ownerRecordPath = ledgerPath => `${ledgerPath}.owner`; | ||
| const releaseFlight = new Map(); | ||
| const releaseHooks = { beforeRename: null, beforeRestore: null }; | ||
| export function setOwnershipReleaseHooks(n = {}) { | ||
| releaseHooks.beforeRename = n.beforeRename ?? null; releaseHooks.beforeRestore = n.beforeRestore ?? null; | ||
| } | ||
| const ioFail = () => { throw new OwnershipError(OWNERSHIP_IO); }; | ||
| function parseRecord(raw) { | ||
| let value; | ||
| try { value = JSON.parse(raw); } catch { return null; } | ||
| if (!value || typeof value !== 'object' || Array.isArray(value)) return null; | ||
| if (typeof value.nonce !== 'string' || value.nonce.length < 8 || value.nonce.length > 128) return null; | ||
| if (!Number.isInteger(value.pid) || value.pid <= 0) return null; | ||
| if (typeof value.heartbeatAt !== 'string' || !Number.isFinite(Date.parse(value.heartbeatAt))) return null; | ||
| return { nonce: value.nonce, pid: value.pid, heartbeatAt: value.heartbeatAt }; | ||
| } | ||
| async function refuseExisting(path) { | ||
| let raw; | ||
| try { raw = await readFile(path, 'utf8'); } | ||
| catch { throw new OwnershipError(OWNERSHIP_HELD); } | ||
| if (!parseRecord(raw)) throw new OwnershipError(OWNERSHIP_MALFORMED); | ||
| throw new OwnershipError(OWNERSHIP_HELD); | ||
| } | ||
| async function mismatchOrIo(path) { | ||
| try { if (!(await stat(dirname(path))).isDirectory()) ioFail(); } | ||
| catch (e) { if (e instanceof OwnershipError) throw e; ioFail(); } | ||
| throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); | ||
| } | ||
| export async function acquireOwnership(ledgerPath) { | ||
| const path = ownerRecordPath(ledgerPath); | ||
| const nonce = randomBytes(16).toString('hex'); | ||
| const record = { nonce, pid: process.pid, heartbeatAt: new Date().toISOString() }; | ||
| let handle; | ||
| try { handle = await open(path, 'wx', 0o600); } | ||
|
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 the configured state file's parent directory does not yet exist, this Useful? React with 👍 / 👎. |
||
| catch (error) { | ||
| if (error && error.code === 'EEXIST') await refuseExisting(path); | ||
| ioFail(); | ||
| } | ||
| try { await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); await handle.close(); } | ||
| catch { await handle.close().catch(() => {}); await unlink(path).catch(() => {}); ioFail(); } | ||
| return { nonce, pid: record.pid, heartbeatAt: record.heartbeatAt }; | ||
| } | ||
| async function releaseOnce(path, nonce) { | ||
| let raw; | ||
| try { raw = await readFile(path, 'utf8'); } | ||
| catch (error) { | ||
| if (error && error.code === 'ENOENT') await mismatchOrIo(path); | ||
| ioFail(); | ||
| } | ||
| const record = parseRecord(raw); | ||
| if (!record || record.nonce !== nonce) throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); | ||
| const tomb = `${path}.${nonce}.${randomBytes(8).toString('hex')}.tomb`; | ||
|
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.
For a valid ledger basename near the filesystem's component limit, acquisition succeeds because the owner file adds only Useful? React with 👍 / 👎. |
||
| if (releaseHooks.beforeRename) await releaseHooks.beforeRename(); | ||
| try { await rename(path, tomb); } | ||
| catch (error) { | ||
| if (error && error.code === 'ENOENT') await mismatchOrIo(path); | ||
| ioFail(); | ||
| } | ||
| let tombRaw; | ||
| try { tombRaw = await readFile(tomb, 'utf8'); } catch { ioFail(); } | ||
| const tombRec = parseRecord(tombRaw); | ||
| if (!tombRec || tombRec.nonce !== nonce) { | ||
| if (releaseHooks.beforeRestore) await releaseHooks.beforeRestore(); | ||
| try { await link(tomb, path); await unlink(tomb); } | ||
| catch (e) { if (e?.code === 'EEXIST') await unlink(tomb).catch(() => {}); else ioFail(); } | ||
| throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); | ||
| } | ||
| try { await unlink(tomb); } catch { ioFail(); } | ||
| } | ||
| // PRECONDITION: each nonce released at most once by its owner. A second (stale) | ||
| // release from another module instance/process is a caller bug; a live successor | ||
| // may then lose its record (R811-O5/F1, E6b). Core (G2) enforces single release. | ||
| export async function releaseOwnership(ledgerPath, nonce) { | ||
| const path = ownerRecordPath(ledgerPath); | ||
| const pending = releaseFlight.get(nonce); | ||
| if (pending) { await pending.catch(() => {}); throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); } | ||
| let finish; | ||
| const gate = new Promise(r => { finish = r; }); | ||
| releaseFlight.set(nonce, gate); | ||
| try { await releaseOnce(path, nonce); } | ||
| finally { releaseFlight.delete(nonce); finish(); } | ||
| } | ||
| export async function recoverOwnership() { throw new OwnershipError(OWNERSHIP_RECOVERY_REFUSED); } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { spawn } from 'node:child_process'; | ||
| import fs, { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; | ||
| import { syncBuiltinESMExports } from 'node:module'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { createInterface } from 'node:readline'; | ||
| import { test } from 'node:test'; | ||
| import * as o from './ownership.mjs'; | ||
| const { | ||
| OWNERSHIP_HELD: HELD, OWNERSHIP_MALFORMED: MALFORMED, OWNERSHIP_RECOVERY_REFUSED: REFUSED, | ||
| OWNERSHIP_RELEASE_MISMATCH: MISMATCH, OwnershipError, acquireOwnership: acquire, | ||
| ownerRecordPath: ownerPath, recoverOwnership: recover, releaseOwnership: release, | ||
| } = o; | ||
| const IO = o.OWNERSHIP_IO ?? 'OWNERSHIP_IO'; | ||
| const posix = { skip: process.platform === 'win32' ? 'POSIX mode 0600 is not Windows ACL evidence' : false }; | ||
| const fixture = join(import.meta.dirname, 'ownership-contender.fixture.mjs'); | ||
| async function ledger(t) { | ||
| const dir = await mkdtemp(join(tmpdir(), 'denkraum-ownership-')); | ||
| t.after(() => rm(dir, { recursive: true, force: true })); | ||
| return join(dir, 'ledger.json'); | ||
| } | ||
| function isDiag(e, code) { | ||
| return e instanceof OwnershipError && e.code === code && e.message === code | ||
| && e.cause == null && !Object.values(e).some(v => typeof v === 'string' && /[\\/]/.test(v)); | ||
| } | ||
| async function joinChild(child, rl) { | ||
| try { child.stdin.end(); } catch { /* ignore */ } | ||
| if (child.exitCode == null && child.signalCode == null) try { child.kill('SIGKILL'); } catch { /* ignore */ } | ||
| await Promise.race([ | ||
| new Promise(r => (child.exitCode != null || child.signalCode != null ? r() : child.once('exit', r))), | ||
| new Promise(r => setTimeout(r, 2000)), | ||
| ]); | ||
| rl?.close(); | ||
| } | ||
| async function holdChild(t, path) { | ||
| const child = spawn(process.execPath, [fixture, path], { cwd: import.meta.dirname, stdio: ['pipe', 'pipe', 'inherit'] }); | ||
| const rl = createInterface({ input: child.stdout }); | ||
| let done = false; | ||
| t.after(async () => { if (!done) await joinChild(child, rl); }); | ||
| const ready = await new Promise((res, rej) => { | ||
| const timer = setTimeout(() => rej(new Error('ready timeout')), 2000); | ||
| rl.once('line', line => { clearTimeout(timer); res(JSON.parse(line)); }); | ||
| child.once('error', rej); | ||
| child.once('exit', code => rej(new Error(`early exit ${code}`))); | ||
| }); | ||
| assert.equal(ready.ready, true); | ||
| return { | ||
| async release() { | ||
| child.stdin.write('release\n'); | ||
| await Promise.race([ | ||
| new Promise((res, rej) => child.once('exit', c => (c === 0 ? res() : rej(new Error(`exit ${c}`))))), | ||
| new Promise((_, rej) => setTimeout(() => rej(new Error('release deadline')), 2000)), | ||
| ]); | ||
| done = true; rl.close(); | ||
| }, | ||
| }; | ||
| } | ||
| test('DRSEC: second process is refused while ownership is held', async t => { | ||
| const file = await ledger(t), child = await holdChild(t, file); | ||
| await assert.rejects(acquire(file), e => isDiag(e, HELD)); | ||
| const before = await readFile(ownerPath(file)); | ||
| await assert.rejects(acquire(file), e => isDiag(e, HELD)); | ||
| assert.deepEqual(await readFile(ownerPath(file)), before); | ||
| await child.release(); | ||
| await release(file, (await acquire(file)).nonce); | ||
| }); | ||
| test('DRSEC: same-process second acquire with a different nonce is refused', async t => { | ||
| const file = await ledger(t), first = await acquire(file); | ||
| await assert.rejects(acquire(file), e => isDiag(e, HELD)); | ||
| assert.equal(JSON.parse(await readFile(ownerPath(file), 'utf8')).nonce, first.nonce); | ||
| await release(file, first.nonce); | ||
| }); | ||
| test('DRSEC: stale heartbeat does not release ownership', async t => { | ||
| const file = await ledger(t), session = await acquire(file), path = ownerPath(file); | ||
| const stale = { nonce: session.nonce, pid: session.pid, heartbeatAt: '2000-01-01T00:00:00.000Z' }; | ||
| await writeFile(path, JSON.stringify(stale), { mode: 0o600 }); | ||
| await assert.rejects(acquire(file), e => isDiag(e, HELD)); | ||
| assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), stale); | ||
| await release(file, session.nonce); | ||
| }); | ||
| test('DRSEC: malformed owner record fails closed', async t => { | ||
| const file = await ledger(t), path = ownerPath(file); | ||
| for (const bytes of ['', '{', 'null', '{}', '{"nonce":1}', '{"nonce":"x","pid":"y","heartbeatAt":"z"}']) { | ||
| await writeFile(path, bytes, { mode: 0o600 }); | ||
| await assert.rejects(acquire(file), e => isDiag(e, MALFORMED)); | ||
| assert.equal(await readFile(path, 'utf8'), bytes); | ||
| await assert.rejects(recover(file, { observedExit: true }), e => isDiag(e, REFUSED)); | ||
| assert.equal(await readFile(path, 'utf8'), bytes); | ||
| } | ||
| }); | ||
| test('DRSEC: release with foreign nonce keeps the record', async t => { | ||
| const file = await ledger(t), session = await acquire(file), path = ownerPath(file), before = await readFile(path); | ||
| await assert.rejects(release(file, 'foreign-nonce'), e => isDiag(e, MISMATCH)); | ||
| assert.deepEqual(await readFile(path), before); | ||
| await release(file, session.nonce); | ||
| await assert.rejects(readFile(path), e => e.code === 'ENOENT'); | ||
| }); | ||
| test('DRSEC: release of a missing record is a fixed mismatch', async t => { | ||
| await assert.rejects(release(await ledger(t), 'missing-nonce-value'), e => isDiag(e, MISMATCH)); | ||
| }); | ||
| test('DRSEC: owner record is created exclusively with mode 0600', posix, async t => { | ||
| const file = await ledger(t), umask = process.umask(0); | ||
| try { | ||
| const session = await acquire(file); | ||
| assert.equal((await stat(ownerPath(file))).mode & 0o777, 0o600); | ||
| await release(file, session.nonce); | ||
| } finally { process.umask(umask); } | ||
| }); | ||
| test('DRSEC: overlapping release cannot unlink a successor record', async t => { | ||
| const file = await ledger(t), owner = await acquire(file), orig = fs.unlink; | ||
| const gate = Promise.withResolvers(), entered = Promise.withResolvers(); | ||
| fs.unlink = async p => { entered.resolve(); await gate.promise; return orig(p); }; | ||
| syncBuiltinESMExports(); | ||
| try { | ||
| const r1 = release(file, owner.nonce); | ||
| await entered.promise; | ||
| const r2 = assert.rejects(release(file, owner.nonce), e => isDiag(e, MISMATCH)); | ||
| gate.resolve(); await r1; | ||
| const successor = await acquire(file); | ||
| await r2; | ||
| assert.equal(JSON.parse(await readFile(ownerPath(file), 'utf8')).nonce, successor.nonce); | ||
| await release(file, successor.nonce); | ||
| } finally { fs.unlink = orig; syncBuiltinESMExports(); } | ||
| }); | ||
| test('DRSEC: filesystem failures use fixed diagnostics without paths', async t => { | ||
| const dir = await mkdtemp(join(tmpdir(), 'denkraum-ownership-io-')); | ||
| t.after(() => rm(dir, { recursive: true, force: true })); | ||
| await writeFile(join(dir, 'not-a-dir'), 'x'); | ||
| for (const p of [join(dir, 'missing-parent', 'l.json'), join(dir, 'not-a-dir', 'l.json')]) { | ||
| await assert.rejects(acquire(p), e => isDiag(e, IO)); | ||
| await assert.rejects(release(p, 'synthetic-nonce'), e => isDiag(e, IO)); | ||
| } | ||
| }); | ||
| test('DRSEC: failed write after create removes the half-written record', async t => { | ||
| const file = await ledger(t), orig = fs.open; | ||
| fs.open = async (...args) => { | ||
| const h = await orig(...args); | ||
| if (args[1] === 'wx') h.writeFile = async () => { throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }); }; | ||
| return h; | ||
| }; | ||
| syncBuiltinESMExports(); | ||
| try { | ||
| await assert.rejects(acquire(file), e => isDiag(e, IO)); | ||
| await assert.rejects(readFile(ownerPath(file)), e => e.code === 'ENOENT'); | ||
| } finally { fs.open = orig; syncBuiltinESMExports(); } | ||
| }); | ||
| test('DRSEC: fixture exits non-zero when stdin closes without release', async t => { | ||
| const file = await ledger(t); | ||
| const child = spawn(process.execPath, [fixture, file], { cwd: import.meta.dirname, stdio: ['pipe', 'pipe', 'inherit'] }); | ||
| const rl = createInterface({ input: child.stdout }); | ||
| t.after(() => joinChild(child, rl)); | ||
| await new Promise((res, rej) => { | ||
| const timer = setTimeout(() => rej(new Error('ready timeout')), 2000); | ||
| rl.once('line', () => { clearTimeout(timer); res(); }); | ||
| }); | ||
| child.stdin.end(); | ||
| assert.notEqual(await Promise.race([ | ||
| new Promise(res => child.once('exit', res)), | ||
| new Promise((_, rej) => setTimeout(() => rej(new Error('exit deadline')), 2000)), | ||
| ]), 0); | ||
| assert.ok(await readFile(ownerPath(file))); | ||
| }); | ||
| // Proves third-owner record survives stale cross-instance restore; not "never two owners". | ||
| test('DRSEC: cross-instance stale release never overwrites a live owner', async t => { | ||
| const file = await ledger(t); | ||
| const a = await import('./ownership.mjs?a'), b = await import('./ownership.mjs?b'); | ||
| const path = a.ownerRecordPath(file), old = await a.acquireOwnership(file); | ||
| const entered = Promise.withResolvers(), resume = Promise.withResolvers(); | ||
| const restoring = Promise.withResolvers(), restoreGate = Promise.withResolvers(); | ||
| t.after(() => { a.setOwnershipReleaseHooks(); b.setOwnershipReleaseHooks(); }); | ||
| a.setOwnershipReleaseHooks({ | ||
| beforeRename: async () => { entered.resolve(); await resume.promise; }, | ||
| beforeRestore: async () => { restoring.resolve(); await restoreGate.promise; }, | ||
| }); | ||
| const stale = assert.rejects(a.releaseOwnership(file, old.nonce), e => e?.code === a.OWNERSHIP_RELEASE_MISMATCH); | ||
| await entered.promise; | ||
| await b.releaseOwnership(file, old.nonce); | ||
| const successor = await b.acquireOwnership(file); | ||
| resume.resolve(); await restoring.promise; | ||
| const third = await b.acquireOwnership(file); | ||
| restoreGate.resolve(); await stale; | ||
| assert.equal(JSON.parse(await readFile(path, 'utf8')).nonce, third.nonce); | ||
| assert.notEqual(third.nonce, successor.nonce); | ||
| await assert.rejects(b.releaseOwnership(file, successor.nonce), e => e?.code === b.OWNERSHIP_RELEASE_MISMATCH); | ||
| assert.equal(JSON.parse(await readFile(path, 'utf8')).nonce, third.nonce); | ||
| await b.releaseOwnership(file, third.nonce); | ||
| }); |
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.
When the same existing ledger is supplied through a file symlink or hard-link alias, this derives different
.ownerpaths from the two lexical names, so bothacquireOwnershipcalls succeed and two servers are admitted. This is reachable through normal configuration becauseloadStartConfigpreserves the lexicalstatePathafter only using the physical path for validation; derive ownership from a stable physical identity or reject such aliases before G2 relies on this primitive.Useful? React with 👍 / 👎.