From 24f0b157042b17bbcaaa0df0d47446d349ef6a2d Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:24:51 +0200 Subject: [PATCH 1/9] feat(denkraum): add exclusive ledger ownership primitive Introduce a standalone Option-A ownership module with O_EXCL create, nonce-checked release, and fail-closed stale/malformed refusal before core/server wiring. Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: second process is refused while ownership is held Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: same-process second acquire with a different nonce is refused Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: stale heartbeat does not release ownership Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: malformed owner record fails closed Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: release with foreign nonce keeps the record Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: owner record is created exclusively with mode 0600 Co-authored-by: Cursor --- .../store/ownership-contender.fixture.mjs | 29 +++++ tools/denkraum/store/ownership.mjs | 75 +++++++++++ tools/denkraum/store/ownership.test.mjs | 121 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 tools/denkraum/store/ownership-contender.fixture.mjs create mode 100644 tools/denkraum/store/ownership.mjs create mode 100644 tools/denkraum/store/ownership.test.mjs diff --git a/tools/denkraum/store/ownership-contender.fixture.mjs b/tools/denkraum/store/ownership-contender.fixture.mjs new file mode 100644 index 00000000..97552334 --- /dev/null +++ b/tools/denkraum/store/ownership-contender.fixture.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Child fixture: hold exclusive ownership until stdin says "release". +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 \n'); + process.exit(2); +} + +const session = await acquireOwnership(ledgerPath); +process.stdout.write(`${JSON.stringify({ ready: true })}\n`); + +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); + rl.close(); + resolve(); + } catch (error) { + reject(error); + } + }); + rl.on('close', resolve); +}); +process.exit(0); diff --git a/tools/denkraum/store/ownership.mjs b/tools/denkraum/store/ownership.mjs new file mode 100644 index 00000000..6cda9511 --- /dev/null +++ b/tools/denkraum/store/ownership.mjs @@ -0,0 +1,75 @@ +import { randomBytes } from 'node:crypto'; +import { open, readFile, unlink } from 'node:fs/promises'; + +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 class OwnershipError extends Error { + constructor(code) { + super(code); + this.name = 'OwnershipError'; + this.code = code; + } +} + +export const ownerRecordPath = ledgerPath => `${ledgerPath}.owner`; + +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); + // Valid, stale, or suspended: never auto-release. + throw new OwnershipError(OWNERSHIP_HELD); +} + +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); + } catch (error) { + if (error && error.code === 'EEXIST') await refuseExisting(path); + throw error; + } + try { + await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); + } catch (error) { + await handle.close().catch(() => {}); + throw error; + } + await handle.close(); + return { nonce, pid: record.pid, heartbeatAt: record.heartbeatAt }; +} + +export async function releaseOwnership(ledgerPath, nonce) { + const path = ownerRecordPath(ledgerPath); + let raw; + try { raw = await readFile(path, 'utf8'); } + catch (error) { + if (error && error.code === 'ENOENT') throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); + throw error; + } + const record = parseRecord(raw); + if (!record || record.nonce !== nonce) throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); + await unlink(path); +} + +/** Operator recovery is out of scope for G1: always refuse and leave the record. */ +export async function recoverOwnership(_ledgerPath, _proof) { + throw new OwnershipError(OWNERSHIP_RECOVERY_REFUSED); +} diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs new file mode 100644 index 00000000..9e6dd5a0 --- /dev/null +++ b/tools/denkraum/store/ownership.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { test } from 'node:test'; +import { + OWNERSHIP_HELD, + OWNERSHIP_MALFORMED, + OWNERSHIP_RECOVERY_REFUSED, + OWNERSHIP_RELEASE_MISMATCH, + OwnershipError, + acquireOwnership, + ownerRecordPath, + recoverOwnership, + releaseOwnership, +} from './ownership.mjs'; + +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(error, code) { + return error instanceof OwnershipError && error.code === code + && error.message === code && !/[\\/]/.test(error.message); +} + +async function holdChild(ledgerPath) { + const child = spawn(process.execPath, [fixture, ledgerPath], { + cwd: import.meta.dirname, stdio: ['pipe', 'pipe', 'inherit'], + }); + const rl = createInterface({ input: child.stdout }); + const ready = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('contender ready timeout')), 2000); + rl.once('line', line => { clearTimeout(timer); resolve(JSON.parse(line)); }); + child.once('error', reject); + child.once('exit', code => reject(new Error(`contender exited early: ${code}`))); + }); + assert.equal(ready.ready, true); + return { + async release() { + child.stdin.write('release\n'); + await new Promise((resolve, reject) => { + child.once('exit', code => (code === 0 ? resolve() : reject(new Error(`exit ${code}`)))); + }); + rl.close(); + }, + }; +} + +test('DRSEC: second process is refused while ownership is held', async t => { + const file = await ledger(t); + const child = await holdChild(file); + await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); + const before = await readFile(ownerRecordPath(file)); + await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); + assert.deepEqual(await readFile(ownerRecordPath(file)), before); + await child.release(); + const session = await acquireOwnership(file); + await releaseOwnership(file, session.nonce); +}); + +test('DRSEC: same-process second acquire with a different nonce is refused', async t => { + const file = await ledger(t); + const first = await acquireOwnership(file); + await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); + assert.equal(JSON.parse(await readFile(ownerRecordPath(file), 'utf8')).nonce, first.nonce); + await releaseOwnership(file, first.nonce); +}); + +test('DRSEC: stale heartbeat does not release ownership', async t => { + const file = await ledger(t); + const session = await acquireOwnership(file); + const path = ownerRecordPath(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(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), stale); + await releaseOwnership(file, session.nonce); +}); + +test('DRSEC: malformed owner record fails closed', async t => { + const file = await ledger(t); + const path = ownerRecordPath(file); + for (const bytes of ['', '{', 'null', '{}', '{"nonce":1}', '{"nonce":"x","pid":"y","heartbeatAt":"z"}']) { + await writeFile(path, bytes, { mode: 0o600 }); + await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_MALFORMED)); + assert.equal(await readFile(path, 'utf8'), bytes); + await assert.rejects(recoverOwnership(file, { observedExit: true }), e => isDiag(e, OWNERSHIP_RECOVERY_REFUSED)); + assert.equal(await readFile(path, 'utf8'), bytes); + } +}); + +test('DRSEC: release with foreign nonce keeps the record', async t => { + const file = await ledger(t); + const session = await acquireOwnership(file); + const path = ownerRecordPath(file); + const before = await readFile(path); + await assert.rejects(releaseOwnership(file, 'foreign-nonce'), e => isDiag(e, OWNERSHIP_RELEASE_MISMATCH)); + assert.deepEqual(await readFile(path), before); + await releaseOwnership(file, session.nonce); + await assert.rejects(readFile(path), e => e.code === 'ENOENT'); +}); + +test('DRSEC: owner record is created exclusively with mode 0600', posix, async t => { + const file = await ledger(t); + const previousUmask = process.umask(0); + try { + const session = await acquireOwnership(file); + assert.equal((await stat(ownerRecordPath(file))).mode & 0o777, 0o600); + await releaseOwnership(file, session.nonce); + } finally { + process.umask(previousUmask); + } +}); From 2bf5b08526da8d72fc225c8477c648e8eff88968 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:03:09 +0200 Subject: [PATCH 2/9] fix(denkraum): single-flight tombstone release against successor unlink Overlapping release with the same nonce no longer deletes a successor's owner record: release is single-flight per nonce and unlinks only after a verified tombstone rename. Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: overlapping release cannot unlink a successor record Co-authored-by: Cursor --- tools/denkraum/store/ownership.mjs | 66 ++++++----- tools/denkraum/store/ownership.test.mjs | 141 +++++++++++++----------- 2 files changed, 114 insertions(+), 93 deletions(-) diff --git a/tools/denkraum/store/ownership.mjs b/tools/denkraum/store/ownership.mjs index 6cda9511..90d46c87 100644 --- a/tools/denkraum/store/ownership.mjs +++ b/tools/denkraum/store/ownership.mjs @@ -1,21 +1,14 @@ import { randomBytes } from 'node:crypto'; -import { open, readFile, unlink } from 'node:fs/promises'; - +import { open, readFile, rename, unlink } from 'node:fs/promises'; 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 class OwnershipError extends Error { - constructor(code) { - super(code); - this.name = 'OwnershipError'; - this.code = code; - } + constructor(code) { super(code); this.name = 'OwnershipError'; this.code = code; } } - export const ownerRecordPath = ledgerPath => `${ledgerPath}.owner`; - +const releaseFlight = new Map(); function parseRecord(raw) { let value; try { value = JSON.parse(raw); } catch { return null; } @@ -25,39 +18,29 @@ function parseRecord(raw) { 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); - // Valid, stale, or suspended: never auto-release. throw new OwnershipError(OWNERSHIP_HELD); } - 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); - } catch (error) { + try { handle = await open(path, 'wx', 0o600); } + catch (error) { if (error && error.code === 'EEXIST') await refuseExisting(path); throw error; } - try { - await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); - } catch (error) { - await handle.close().catch(() => {}); - throw error; - } + try { await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); } + catch (error) { await handle.close().catch(() => {}); throw error; } await handle.close(); return { nonce, pid: record.pid, heartbeatAt: record.heartbeatAt }; } - -export async function releaseOwnership(ledgerPath, nonce) { - const path = ownerRecordPath(ledgerPath); +async function releaseOnce(path, nonce) { let raw; try { raw = await readFile(path, 'utf8'); } catch (error) { @@ -66,10 +49,33 @@ export async function releaseOwnership(ledgerPath, nonce) { } const record = parseRecord(raw); if (!record || record.nonce !== nonce) throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); - await unlink(path); + const tomb = `${path}.${nonce}.tomb`; + try { await rename(path, tomb); } + catch (error) { + if (error && error.code === 'ENOENT') throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); + throw error; + } + let tombRaw; + try { tombRaw = await readFile(tomb, 'utf8'); } catch (error) { throw error; } + const tombRec = parseRecord(tombRaw); + if (!tombRec || tombRec.nonce !== nonce) { + await rename(tomb, path); + throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); + } + await unlink(tomb); } - -/** Operator recovery is out of scope for G1: always refuse and leave the record. */ -export async function recoverOwnership(_ledgerPath, _proof) { - throw new OwnershipError(OWNERSHIP_RECOVERY_REFUSED); +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(); } } +/** G1 recovery stub: always refuse and leave the record. */ +export async function recoverOwnership() { throw new OwnershipError(OWNERSHIP_RECOVERY_REFUSED); } diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 9e6dd5a0..1461e16c 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -1,121 +1,136 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +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 { - OWNERSHIP_HELD, - OWNERSHIP_MALFORMED, - OWNERSHIP_RECOVERY_REFUSED, - OWNERSHIP_RELEASE_MISMATCH, - OwnershipError, - acquireOwnership, - ownerRecordPath, - recoverOwnership, - releaseOwnership, -} from './ownership.mjs'; - +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(error, code) { - return error instanceof OwnershipError && error.code === code - && error.message === code && !/[\\/]/.test(error.message); +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 holdChild(ledgerPath) { - const child = spawn(process.execPath, [fixture, ledgerPath], { - cwd: import.meta.dirname, stdio: ['pipe', 'pipe', 'inherit'], - }); +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((_, j) => setTimeout(() => j(new Error('join deadline')), 2000)), + ]).catch(() => {}); + 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((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('contender ready timeout')), 2000); + const timer = setTimeout(() => reject(new Error('ready timeout')), 2000); rl.once('line', line => { clearTimeout(timer); resolve(JSON.parse(line)); }); child.once('error', reject); - child.once('exit', code => reject(new Error(`contender exited early: ${code}`))); + child.once('exit', code => reject(new Error(`early exit ${code}`))); }); assert.equal(ready.ready, true); return { async release() { child.stdin.write('release\n'); - await new Promise((resolve, reject) => { - child.once('exit', code => (code === 0 ? resolve() : reject(new Error(`exit ${code}`)))); - }); + 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); - const child = await holdChild(file); - await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); - const before = await readFile(ownerRecordPath(file)); - await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); - assert.deepEqual(await readFile(ownerRecordPath(file)), before); + const 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(); - const session = await acquireOwnership(file); - await releaseOwnership(file, session.nonce); + 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); - const first = await acquireOwnership(file); - await assert.rejects(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); - assert.equal(JSON.parse(await readFile(ownerRecordPath(file), 'utf8')).nonce, first.nonce); - await releaseOwnership(file, first.nonce); + const 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); - const session = await acquireOwnership(file); - const path = ownerRecordPath(file); + const session = await acquire(file); + const 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(acquireOwnership(file), e => isDiag(e, OWNERSHIP_HELD)); + await assert.rejects(acquire(file), e => isDiag(e, HELD)); assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), stale); - await releaseOwnership(file, session.nonce); + await release(file, session.nonce); }); - test('DRSEC: malformed owner record fails closed', async t => { const file = await ledger(t); - const path = ownerRecordPath(file); + const 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(acquireOwnership(file), e => isDiag(e, OWNERSHIP_MALFORMED)); + await assert.rejects(acquire(file), e => isDiag(e, MALFORMED)); assert.equal(await readFile(path, 'utf8'), bytes); - await assert.rejects(recoverOwnership(file, { observedExit: true }), e => isDiag(e, OWNERSHIP_RECOVERY_REFUSED)); + 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); - const session = await acquireOwnership(file); - const path = ownerRecordPath(file); + const session = await acquire(file); + const path = ownerPath(file); const before = await readFile(path); - await assert.rejects(releaseOwnership(file, 'foreign-nonce'), e => isDiag(e, OWNERSHIP_RELEASE_MISMATCH)); + await assert.rejects(release(file, 'foreign-nonce'), e => isDiag(e, MISMATCH)); assert.deepEqual(await readFile(path), before); - await releaseOwnership(file, session.nonce); + await release(file, session.nonce); await assert.rejects(readFile(path), e => e.code === 'ENOENT'); }); - test('DRSEC: owner record is created exclusively with mode 0600', posix, async t => { const file = await ledger(t); - const previousUmask = process.umask(0); + const umask = process.umask(0); try { - const session = await acquireOwnership(file); - assert.equal((await stat(ownerRecordPath(file))).mode & 0o777, 0o600); - await releaseOwnership(file, session.nonce); - } finally { - process.umask(previousUmask); - } + 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); + const owner = await acquire(file); + const orig = fs.unlink; + const gate = Promise.withResolvers(); + const 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(); } }); From 6eab0fe6ef80791343ca52755271a49eaeff843a Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:06:39 +0200 Subject: [PATCH 3/9] fix(denkraum): map ownership filesystem failures to path-free OWNERSHIP_IO Acquire and release normalize open/read/rename/unlink failures to a fixed OwnershipError code without attaching the raw cause or any path fragment. Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: filesystem failures use fixed diagnostics without paths Co-authored-by: Cursor --- tools/denkraum/store/ownership.mjs | 36 ++++++++++++++++--------- tools/denkraum/store/ownership.test.mjs | 9 +++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/tools/denkraum/store/ownership.mjs b/tools/denkraum/store/ownership.mjs index 90d46c87..3d17d2d7 100644 --- a/tools/denkraum/store/ownership.mjs +++ b/tools/denkraum/store/ownership.mjs @@ -1,14 +1,17 @@ import { randomBytes } from 'node:crypto'; -import { open, readFile, rename, unlink } from 'node:fs/promises'; +import { 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 ioFail = () => { throw new OwnershipError(OWNERSHIP_IO); }; function parseRecord(raw) { let value; try { value = JSON.parse(raw); } catch { return null; } @@ -25,6 +28,11 @@ async function refuseExisting(path) { 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'); @@ -33,36 +41,40 @@ export async function acquireOwnership(ledgerPath) { try { handle = await open(path, 'wx', 0o600); } catch (error) { if (error && error.code === 'EEXIST') await refuseExisting(path); - throw error; + ioFail(); + } + try { + await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); + await handle.close(); + } catch { + await handle.close().catch(() => {}); + ioFail(); } - try { await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); } - catch (error) { await handle.close().catch(() => {}); throw error; } - await handle.close(); 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') throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); - throw 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}.tomb`; try { await rename(path, tomb); } catch (error) { - if (error && error.code === 'ENOENT') throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); - throw error; + if (error && error.code === 'ENOENT') await mismatchOrIo(path); + ioFail(); } let tombRaw; - try { tombRaw = await readFile(tomb, 'utf8'); } catch (error) { throw error; } + try { tombRaw = await readFile(tomb, 'utf8'); } catch { ioFail(); } const tombRec = parseRecord(tombRaw); if (!tombRec || tombRec.nonce !== nonce) { - await rename(tomb, path); + try { await rename(tomb, path); } catch { ioFail(); } throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); } - await unlink(tomb); + try { await unlink(tomb); } catch { ioFail(); } } export async function releaseOwnership(ledgerPath, nonce) { const path = ownerRecordPath(ledgerPath); diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 1461e16c..488e20ff 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -134,3 +134,12 @@ test('DRSEC: overlapping release cannot unlink a successor record', async t => { 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 path of [join(dir, 'missing-parent', 'l.json'), join(dir, 'not-a-dir', 'l.json')]) { + await assert.rejects(acquire(path), e => isDiag(e, IO)); + await assert.rejects(release(path, 'synthetic-nonce'), e => isDiag(e, IO)); + } +}); From 896ff3f5a6a5883f13c930cf00ca2eeea1975d47 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:10:18 +0200 Subject: [PATCH 4/9] fix(denkraum): remove half-written owner record after create write failure When wx create succeeds but the initial write/flush fails, the creator unlinks its own incomplete record and returns a fixed OWNERSHIP_IO error. Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: failed write after create removes the half-written record Co-authored-by: Cursor --- tools/denkraum/store/ownership.mjs | 1 + tools/denkraum/store/ownership.test.mjs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/tools/denkraum/store/ownership.mjs b/tools/denkraum/store/ownership.mjs index 3d17d2d7..49fa4613 100644 --- a/tools/denkraum/store/ownership.mjs +++ b/tools/denkraum/store/ownership.mjs @@ -48,6 +48,7 @@ export async function acquireOwnership(ledgerPath) { await handle.close(); } catch { await handle.close().catch(() => {}); + await unlink(path).catch(() => {}); ioFail(); } return { nonce, pid: record.pid, heartbeatAt: record.heartbeatAt }; diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 488e20ff..aa1ed44e 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -143,3 +143,17 @@ test('DRSEC: filesystem failures use fixed diagnostics without paths', async t = await assert.rejects(release(path, 'synthetic-nonce'), e => isDiag(e, IO)); } }); +test('DRSEC: failed write after create removes the half-written record', async t => { + const file = await ledger(t); + const 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(); } +}); From a2883e9ec44f04452a120912122a8a6655393248 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:10:35 +0200 Subject: [PATCH 5/9] fix(denkraum): bound ownership fixture cleanup and refuse silent stdin close Register child cleanup immediately after spawn, join with a deadline on every path, and make the contender fixture exit non-zero if stdin closes without an explicit release. Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: fixture exits non-zero when stdin closes without release Co-authored-by: Cursor --- .../store/ownership-contender.fixture.mjs | 12 ++++-------- tools/denkraum/store/ownership.test.mjs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tools/denkraum/store/ownership-contender.fixture.mjs b/tools/denkraum/store/ownership-contender.fixture.mjs index 97552334..00a5b73a 100644 --- a/tools/denkraum/store/ownership-contender.fixture.mjs +++ b/tools/denkraum/store/ownership-contender.fixture.mjs @@ -1,29 +1,25 @@ #!/usr/bin/env node -// Child fixture: hold exclusive ownership until stdin says "release". 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 \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); - } + } catch (error) { reject(error); } }); - rl.on('close', resolve); + rl.on('close', () => { if (!released) reject(new Error('stdin closed without release')); }); }); process.exit(0); diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index aa1ed44e..1c0e11af 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -157,3 +157,20 @@ test('DRSEC: failed write after create removes the half-written record', async t 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(); + const code = await Promise.race([ + new Promise(res => child.once('exit', res)), + new Promise((_, rej) => setTimeout(() => rej(new Error('exit deadline')), 2000)), + ]); + assert.notEqual(code, 0); + assert.ok(await readFile(ownerPath(file))); +}); From cbadc7127484bb35230d820dda20fb62f8fc7946 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:10:52 +0200 Subject: [PATCH 6/9] test(denkraum): cover releaseOwnership ENOENT as fixed mismatch Pins the missing-record release path to OWNERSHIP_RELEASE_MISMATCH with path-free diagnostics (GLM F1). Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: release of a missing record is a fixed mismatch Co-authored-by: Cursor --- tools/denkraum/store/ownership.test.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 1c0e11af..67f3e488 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -157,6 +157,9 @@ test('DRSEC: failed write after create removes the half-written record', async t await assert.rejects(readFile(ownerPath(file)), e => e.code === 'ENOENT'); } finally { fs.open = orig; syncBuiltinESMExports(); } }); +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: 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'] }); From 57a0c811e0d0a41472d68d49e82640ac1d9e9116 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:19:38 +0200 Subject: [PATCH 7/9] fix(denkraum): make ownership release safe across module instances Unique per-release tombstones and exclusive link restore prevent a stale cross-instance release from overwriting a live successor (R811-O5). Test-First: tools/denkraum/store/ownership.test.mjs::DRSEC: cross-instance stale release never overwrites a live owner Co-authored-by: Cursor --- .../store/ownership-contender.fixture.mjs | 13 +-- tools/denkraum/store/ownership.mjs | 29 +++--- tools/denkraum/store/ownership.test.mjs | 92 ++++++++++--------- 3 files changed, 66 insertions(+), 68 deletions(-) diff --git a/tools/denkraum/store/ownership-contender.fixture.mjs b/tools/denkraum/store/ownership-contender.fixture.mjs index 00a5b73a..07e5b9cb 100644 --- a/tools/denkraum/store/ownership-contender.fixture.mjs +++ b/tools/denkraum/store/ownership-contender.fixture.mjs @@ -2,10 +2,7 @@ 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 \n'); - process.exit(2); -} +if (!ledgerPath) { process.stderr.write('usage: ownership-contender.fixture.mjs \n'); process.exit(2); } const session = await acquireOwnership(ledgerPath); process.stdout.write(`${JSON.stringify({ ready: true })}\n`); let released = false; @@ -13,12 +10,8 @@ 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); } + 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')); }); }); diff --git a/tools/denkraum/store/ownership.mjs b/tools/denkraum/store/ownership.mjs index 49fa4613..71ede4ca 100644 --- a/tools/denkraum/store/ownership.mjs +++ b/tools/denkraum/store/ownership.mjs @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { open, readFile, rename, stat, unlink } from 'node:fs/promises'; +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'; @@ -11,6 +11,10 @@ export class OwnershipError extends Error { } 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; @@ -43,14 +47,8 @@ export async function acquireOwnership(ledgerPath) { 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(); - } + 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) { @@ -62,7 +60,8 @@ async function releaseOnce(path, nonce) { } const record = parseRecord(raw); if (!record || record.nonce !== nonce) throw new OwnershipError(OWNERSHIP_RELEASE_MISMATCH); - const tomb = `${path}.${nonce}.tomb`; + const tomb = `${path}.${nonce}.${randomBytes(8).toString('hex')}.tomb`; + if (releaseHooks.beforeRename) await releaseHooks.beforeRename(); try { await rename(path, tomb); } catch (error) { if (error && error.code === 'ENOENT') await mismatchOrIo(path); @@ -72,7 +71,9 @@ async function releaseOnce(path, nonce) { try { tombRaw = await readFile(tomb, 'utf8'); } catch { ioFail(); } const tombRec = parseRecord(tombRaw); if (!tombRec || tombRec.nonce !== nonce) { - try { await rename(tomb, path); } catch { ioFail(); } + 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(); } @@ -80,15 +81,11 @@ async function releaseOnce(path, nonce) { 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); - } + 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(); } } -/** G1 recovery stub: always refuse and leave the record. */ export async function recoverOwnership() { throw new OwnershipError(OWNERSHIP_RECOVERY_REFUSED); } diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 67f3e488..618b1335 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -29,8 +29,8 @@ async function joinChild(child, rl) { 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((_, j) => setTimeout(() => j(new Error('join deadline')), 2000)), - ]).catch(() => {}); + new Promise(r => setTimeout(r, 2000)), + ]); rl?.close(); } async function holdChild(t, path) { @@ -38,11 +38,11 @@ async function holdChild(t, path) { const rl = createInterface({ input: child.stdout }); let done = false; t.after(async () => { if (!done) await joinChild(child, rl); }); - const ready = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('ready timeout')), 2000); - rl.once('line', line => { clearTimeout(timer); resolve(JSON.parse(line)); }); - child.once('error', reject); - child.once('exit', code => reject(new Error(`early exit ${code}`))); + 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 { @@ -52,14 +52,12 @@ async function holdChild(t, path) { 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(); + done = true; rl.close(); }, }; } test('DRSEC: second process is refused while ownership is held', async t => { - const file = await ledger(t); - const child = await holdChild(t, file); + 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)); @@ -68,16 +66,13 @@ test('DRSEC: second process is refused while ownership is held', async t => { 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); - const first = await acquire(file); + 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); - const session = await acquire(file); - const path = ownerPath(file); + 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)); @@ -85,8 +80,7 @@ test('DRSEC: stale heartbeat does not release ownership', async t => { await release(file, session.nonce); }); test('DRSEC: malformed owner record fails closed', async t => { - const file = await ledger(t); - const path = ownerPath(file); + 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)); @@ -96,18 +90,17 @@ test('DRSEC: malformed owner record fails closed', async t => { } }); test('DRSEC: release with foreign nonce keeps the record', async t => { - const file = await ledger(t); - const session = await acquire(file); - const path = ownerPath(file); - const before = await readFile(path); + 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); - const umask = process.umask(0); + const file = await ledger(t), umask = process.umask(0); try { const session = await acquire(file); assert.equal((await stat(ownerPath(file))).mode & 0o777, 0o600); @@ -115,19 +108,15 @@ test('DRSEC: owner record is created exclusively with mode 0600', posix, async t } finally { process.umask(umask); } }); test('DRSEC: overlapping release cannot unlink a successor record', async t => { - const file = await ledger(t); - const owner = await acquire(file); - const orig = fs.unlink; - const gate = Promise.withResolvers(); - const entered = Promise.withResolvers(); + 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; + gate.resolve(); await r1; const successor = await acquire(file); await r2; assert.equal(JSON.parse(await readFile(ownerPath(file), 'utf8')).nonce, successor.nonce); @@ -138,14 +127,13 @@ 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 path of [join(dir, 'missing-parent', 'l.json'), join(dir, 'not-a-dir', 'l.json')]) { - await assert.rejects(acquire(path), e => isDiag(e, IO)); - await assert.rejects(release(path, 'synthetic-nonce'), e => isDiag(e, IO)); + 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); - const orig = fs.open; + 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' }); }; @@ -157,9 +145,6 @@ test('DRSEC: failed write after create removes the half-written record', async t await assert.rejects(readFile(ownerPath(file)), e => e.code === 'ENOENT'); } finally { fs.open = orig; syncBuiltinESMExports(); } }); -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: 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'] }); @@ -170,10 +155,33 @@ test('DRSEC: fixture exits non-zero when stdin closes without release', async t rl.once('line', () => { clearTimeout(timer); res(); }); }); child.stdin.end(); - const code = await Promise.race([ + assert.notEqual(await Promise.race([ new Promise(res => child.once('exit', res)), new Promise((_, rej) => setTimeout(() => rej(new Error('exit deadline')), 2000)), - ]); - assert.notEqual(code, 0); + ]), 0); assert.ok(await readFile(ownerPath(file))); }); +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); +}); From 582823e1044d7b7ba723b163709df58bc7a99d47 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:43:24 +0200 Subject: [PATCH 8/9] docs(denkraum): document single-release ownership precondition (E6b) No-Test: documentation of the accepted API precondition and an honest test name (Elias E6b) Co-authored-by: Cursor --- tools/denkraum/store/ownership.mjs | 3 +++ tools/denkraum/store/ownership.test.mjs | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/denkraum/store/ownership.mjs b/tools/denkraum/store/ownership.mjs index 71ede4ca..5303dd31 100644 --- a/tools/denkraum/store/ownership.mjs +++ b/tools/denkraum/store/ownership.mjs @@ -78,6 +78,9 @@ async function releaseOnce(path, nonce) { } 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); diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 618b1335..0686638c 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -161,7 +161,8 @@ test('DRSEC: fixture exits non-zero when stdin closes without release', async t ]), 0); assert.ok(await readFile(ownerPath(file))); }); -test('DRSEC: cross-instance stale release never overwrites a live owner', async t => { +// Proves third-owner record survives stale cross-instance restore; not "never two owners". +test('DRSEC: cross-instance stale release never overwrites a third owner\'s record', 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); From 4c56941acfbdc2d05f5641fd031a3e823b3764f6 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:44:08 +0200 Subject: [PATCH 9/9] docs(denkraum): keep O5 test name for red-first trailer (E6b) No-Test: documentation of the accepted API precondition and an honest test name (Elias E6b) Co-authored-by: Cursor --- tools/denkraum/store/ownership.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/denkraum/store/ownership.test.mjs b/tools/denkraum/store/ownership.test.mjs index 0686638c..98c5199b 100644 --- a/tools/denkraum/store/ownership.test.mjs +++ b/tools/denkraum/store/ownership.test.mjs @@ -162,7 +162,7 @@ test('DRSEC: fixture exits non-zero when stdin closes without release', async t 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 third owner\'s record', async t => { +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);