Skip to content
Merged
18 changes: 18 additions & 0 deletions tools/denkraum/store/ownership-contender.fixture.mjs
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);
94 changes: 94 additions & 0 deletions tools/denkraum/store/ownership.mjs
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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent aliases from creating independent owner files

When the same existing ledger is supplied through a file symlink or hard-link alias, this derives different .owner paths from the two lexical names, so both acquireOwnership calls succeed and two servers are admitted. This is reachable through normal configuration because loadStartConfig preserves the lexical statePath after 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 👍 / 👎.

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); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create the ledger directory before acquiring ownership

When the configured state file's parent directory does not yet exist, this open returns ENOENT and acquisition reports OWNERSHIP_IO. That state path is accepted by loadStartConfig, and DeskStore.change currently supports it by creating the parent recursively before the first write, so acquiring ownership before that write will make a fresh state location fail to start; create the parent during acquisition or require an explicit setup step before this call.

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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep tombstone names within filesystem component limits

For a valid ledger basename near the filesystem's component limit, acquisition succeeds because the owner file adds only .owner, but release appends another 55 characters for this tombstone and fails with OWNERSHIP_IO/ENAMETOOLONG, leaving the owner record permanently held. On a filesystem with a 255-byte component limit this already occurs for a 200-character basename, while the existing store can still write that ledger; use a bounded tombstone name in the same directory rather than extending the full owner filename.

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); }
188 changes: 188 additions & 0 deletions tools/denkraum/store/ownership.test.mjs
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);
});
Loading