From dcfb71f7c69f9019c06aaf32f669da2b3fe5cc03 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 20:06:20 +0100 Subject: [PATCH 1/4] Identity v2: one source of truth; a 0.13 agent is moved, never copied An agent exists exactly when its identity is committed (by-id//identity.json, in SYM_IDENTITY_DIR when set). The name index and the 0.13 compat links are caches of which agent a name is: written after the commit, re-derived from the committed identities' names when missing or unreadable, never read as "no such agent". Names are compared case-insensitively (NFC, lower case): the entry is keyed by the folded name and holds the exact one. A 0.13 directory is moved in by ONE rename (node dir first, identity last); the compat link is made before the commit and renamed onto the 0.13 path right after it. A 0.13 directory behind a link, on a mount point, on another volume, or holding a relative link that leads out of it is linked to from by-id, never copied; across volumes only the identity files are copied (staged, synced, read back, committed by one rename) after a free-space check. A husk is set aside whole under a short per-nodeId lock and reported; a mint holds the name's short mint lock from staging to claim. Keys never left loose: dirs 0700 and keys 0600 narrowed on every load, asides narrowed at once and on load, dead staging, temp links, temp entries and temp lock files cleared on every load, a stray key copy in the node dir removed. Export: intent, tombstone (beside the key and in the node dir), the bundle's hash, the bundle, complete, the scrub of every copy of the key. Resume needs the same out (while a complete bundle may exist) and the same recipient; abort only while no complete bundle was produced, also on the CLI (sym node abort-export). Import: the identity first, each install staged and marked with its bundle; an interrupted import is finished by running it again. lib/node.js (identity and lock only): once the identity lock is held, at construction and at a restart after a stop, an identity exported or being exported meanwhile is refused and the lock released. --- bin/sym.js | 15 +- lib/config.js | 1869 +++++++++++++++++++++++++++------------------ lib/node.js | 23 +- lib/relocation.js | 310 +++++--- 4 files changed, 1362 insertions(+), 855 deletions(-) diff --git a/bin/sym.js b/bin/sym.js index 03bff582..6a761403 100755 --- a/bin/sym.js +++ b/bin/sym.js @@ -34,6 +34,7 @@ const { logSafe } = require('../lib/wire-identity'); // peer-chosen text on the * sym keys resolve # Operator: bind nodeId to key (pinned), clearing its conflicts * sym keys reset-floor # Operator: let a Legacy Import route be used again * sym node export --out (--to-host | --passphrase-env VAR --allow-copies) + * sym node abort-export ( | --node-id ) abandon an export that produced no complete bundle * sym node import --expect-node [--expect-key | --expect-fingerprint sha256:…] [--passphrase-env VAR] * sym node host-key # This host's X25519 key for receiving node bundles * sym logs # Tail daemon logs @@ -176,7 +177,17 @@ function cmdNode() { // Sealed to the target host by default; a passphrase bundle opens on any host with the passphrase, // so it is a copy, and needs --allow-copies (final re-review, Finding 7b). const r = relocation.exportNode({ name: args[2], out: flagValue('--out'), passphrase: passphrase(), toHostKey: flagValue('--to-host') || undefined, copyable: args.includes('--allow-copies') }); - console.log(`exported ${r.name} (${r.nodeId}, ${r.fingerprint}) to ${r.out}: ${r.files} file(s). It is tombstoned here and will not start on this host again.${flagValue('--to-host') ? '' : ' The bundle is a passphrase bundle: any host with the passphrase can import it.'}`); + console.log(r.resumed + ? `finished the export of ${r.name} (${r.nodeId}) to ${r.out}: its bundle was complete; the key is removed from this host.` + : `exported ${r.name} (${r.nodeId}, ${r.fingerprint}) to ${r.out}: ${r.files} file(s). It is tombstoned here and will not start on this host again.${flagValue('--to-host') ? '' : ' The bundle is a passphrase bundle: any host with the passphrase can import it.'}`); + return; + } + if (sub === 'abort-export') { + // Abandon an export that began and produced no complete bundle: the tombstone goes and the node starts here again. + const r = relocation.abortExport({ name: args[2] && !args[2].startsWith('--') ? args[2] : undefined, nodeId: flagValue('--node-id') || undefined }); + console.log(r.aborted + ? `the export of ${r.nodeId}${r.bundle ? ` to ${r.bundle}` : ''} is abandoned: no complete bundle was produced, and the node starts on this host again${r.partialRemoved ? ` (${r.partialRemoved} half-written bundle file(s) removed)` : ''}.` + : `no export of ${r.nodeId} is under way; nothing was changed.`); return; } if (sub === 'import') { @@ -189,7 +200,7 @@ function cmdNode() { console.error(err.message); process.exit(1); } - console.error('usage: sym node (export --out (--to-host | --passphrase-env VAR --allow-copies) | import --expect-node (--expect-key | --expect-fingerprint sha256:…) [--passphrase-env VAR] | host-key)'); + console.error('usage: sym node (export --out (--to-host | --passphrase-env VAR --allow-copies) | abort-export ( | --node-id ) | import --expect-node (--expect-key | --expect-fingerprint sha256:…) [--passphrase-env VAR] | host-key)'); process.exit(1); } diff --git a/lib/config.js b/lib/config.js index f65c57c2..6b6fe8f5 100644 --- a/lib/config.js +++ b/lib/config.js @@ -4,7 +4,7 @@ * Configuration helpers for SYM mesh nodes. * * Manages identity persistence, node directories, and logging. - * Node data lives under ~/.sym/nodes//. + * Node data lives under ~/.sym/nodes/by-id// (0.14; 0.13 kept it at ~/.sym/nodes//). * * See MMP v0.2.0 Section 3 (Identity), Section 18 (Configuration). * @@ -141,9 +141,9 @@ function normalizeMdnsHostname(h) { } /** - * Get the data directory for a named node. A name is an index (design D9): when the name index holds - * it, this is the node's by-id directory (`nodes/by-id/`); otherwise the 0.13 path - * `nodes/`, which `loadIdentity` copies to the by-id layout when it first loads it. + * Get the data directory for a named node. A name is an index (design D9): when the name is an agent on + * this host, this is the node's by-id directory (`nodes/by-id/`); otherwise the 0.13 path + * `nodes/`, which `loadIdentity` moves to the by-id layout when it first loads it. * @param {string} name — node name * @returns {string} path to the node's directory */ @@ -262,10 +262,11 @@ class IdentityAbsentError extends Error { * source refuses to start it, so no second live copy can exist. */ class IdentityTombstonedError extends Error { - constructor(nodeId, tomb) { + constructor(nodeId, tomb, intent) { super(`[SYM] REFUSING TO START ${nodeId}: this identity was exported to another host` + `${tomb && tomb.movedAt ? ` on ${new Date(tomb.movedAt).toISOString()}` : ''} and is tombstoned here. ` + - `It runs where it was imported; two live copies would be two agents claiming one key.`); + `It runs where it was imported; two live copies would be two agents claiming one key.` + + (intent ? ' The export did not finish removing the key from this host: run the same `sym node export` again to finish it.' : '')); this.name = 'IdentityTombstonedError'; this.code = 'EIDENTITYTOMBSTONED'; this.tombstone = tomb || null; @@ -273,16 +274,22 @@ class IdentityTombstonedError extends Error { } /** - * Raised for an identity whose export began and did not finish (relocation.js wrote EXPORTING.json beside - * the key, and no tombstone yet): a bundle may exist, so the source does not start it until the export is - * finished (run it again) or abandoned (`abortExport`, which removes the bundle it wrote). + * Raised for an identity whose export began and did not finish (relocation.js wrote EXPORTING.json beside the key): + * the source stopped using the key when the export began, so it is not started until the export is finished (run the + * same export again: same out, same recipient) or, while no complete bundle was produced, abandoned (abortExport). */ class IdentityExportPendingError extends Error { - constructor(nodeId, intent) { + constructor(nodeId, intent, tomb) { + const i = intent || {}; + const complete = i.complete === true; super(`[SYM] REFUSING TO START ${nodeId}: an export of this identity` + - `${intent && intent.out ? ` to ${intent.out}` : ''}${intent && intent.startedAt ? `, begun ${new Date(intent.startedAt).toISOString()},` : ''} did not finish. ` + - 'A bundle of it may exist; started here as well, there could be two live copies. Finish the export (run `sym node export` ' + - 'again) or abandon it (relocation.abortExport({ nodeId }), which removes the bundle it wrote; delete any copy made of that file).'); + `${i.out ? ` to ${i.out}` : ''}${i.startedAt ? `, begun ${new Date(i.startedAt).toISOString()},` : ''} did not finish. ` + + (complete + ? 'Its bundle is complete: the agent now lives in that bundle. Import it where it goes, and run the same `sym node export` here ' + + 'again to finish removing the key from this host.' + : `This host stopped using the key when the export began${tomb ? '' : ' (an export of an earlier release: a bundle may exist)'}, so it does not start it here meanwhile. ` + + 'Finish the export (run the same `sym node export` again: the same --out and the same recipient), or abandon it ' + + '(`sym node abort-export `, refused once a complete bundle was produced), and the agent starts here again.')); this.name = 'IdentityExportPendingError'; this.code = 'EIDENTITYEXPORTING'; this.intent = intent || null; @@ -304,18 +311,12 @@ function identityDirById(nodeId) { const isNodeIdText = (id) => typeof id === 'string' && /^[0-9A-Za-z][0-9A-Za-z_.-]{0,127}$/.test(id) && id !== '.' && id !== '..'; const TOMBSTONE = 'TOMBSTONE.json'; -/** Beside the key while an export is under way (relocation.js): the bundle may exist, the tombstone does not yet. */ +/** Beside the key while an export is under way (relocation.js): its out, its recipient, and the bundle's hash once known. */ const EXPORTING = 'EXPORTING.json'; -/** - * The commit record of a directory this release installed: written into the staging directory before the - * one rename that puts it in place, so a directory that carries it is complete. A by-id directory without - * it was made by something else (a lock, an earlier release's interrupted move) and is never taken for - * "the identity is in place" by a migration. - */ +/** The record an import writes into the node directory it installs (relocation.js): which bundle it came from. */ const COMMIT_MARK = '.sym-commit'; -/** Where a migrated 0.13 directory is set aside, under the root it was in. Nothing deletes it automatically. */ +/** Where a 0.13 directory that is a second copy of an installed agent is set aside, under the root it was in. Never deleted by sym. */ const LEGACY_ASIDE = '.legacy-0.13'; -const nameIndexPath = (name) => path.join(NODES_DIR, 'by-name', `${encodeURIComponent(name)}.json`); const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch { return null; } }; const statOrNull = (p) => { try { return fs.statSync(p); } catch { return null; } }; const randomTag = () => crypto.randomBytes(4).toString('hex'); @@ -323,6 +324,7 @@ const randomTag = () => crypto.randomBytes(4).toString('hex'); const RENAME_TAKEN = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EISDIR', 'ENOTDIR']); /** A short synchronous pause, for the few places that let another start finish a step of a few system calls. */ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +const linkType = () => (process.platform === 'win32' ? 'junction' : 'dir'); // ── Durable files ──────────────────────────────────────────────────────── @@ -332,12 +334,6 @@ function fsyncDir(dir) { try { fd = fs.openSync(dir, 'r'); fs.fsyncSync(fd); } catch { /* not supported here */ } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* */ } } } -/** fsync a file by path. Best effort: a file this process may not open for writing is still read back by the verify. */ -function fsyncFile(file) { - let fd; - try { fd = fs.openSync(file, 'r+'); fs.fsyncSync(fd); } catch { /* not ours to open for writing */ } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* */ } } -} - /** Write `text` to a new file `file` (it must not exist), synced. A failure removes what it made (it made it: O_EXCL). */ function writeNewFileSynced(file, text, mode) { const fd = fs.openSync(file, 'wx', mode); @@ -387,87 +383,84 @@ function createFileExclusive(file, text, mode = 0o644) { } } -// ── The name index (the record of which node a name is) ────────────────── - -/** - * The nodeId the name index holds for `name`; null ONLY when there is no entry (ENOENT). An entry that - * exists and cannot be read, or does not hold a nodeId, is the record of an agent that cannot be read: it - * halts (IdentityHaltError), and is never taken for "no such agent" (a start that took it so made a new - * identity while the original was on disk). - * @param {string} name - * @returns {string|null} - */ -function nodeIdForName(name) { - const file = nameIndexPath(name); - let raw; +/** Write a file the way a key file must be written: a private temp file, synced, renamed over the target. */ +function writeFileAtomic0600(file, text) { + const tmp = `${file}.${process.pid}.tmp`; + let fd; try { - raw = fs.readFileSync(file, 'utf8'); + fd = fs.openSync(tmp, 'w', 0o600); + fs.writeSync(fd, text); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(tmp, file); + fsyncDir(path.dirname(file)); } catch (e) { - if (e.code === 'ENOENT') return null; - throw unreadableIndex(name, file, `it could not be read (${e.code || e.message})`); - } - let r = null; - try { r = JSON.parse(raw); } catch { /* below */ } - if (!r || typeof r !== 'object' || !isNodeIdText(r.nodeId) || (r.name !== undefined && r.name !== name)) { - throw unreadableIndex(name, file, raw.length ? 'it does not hold {"name", "nodeId"} for this name' : 'it is empty'); - } - return r.nodeId; -} - -function unreadableIndex(name, file, why) { - const onDisk = identitiesOnDisk().filter((i) => i.name === name).map((i) => `${i.nodeId} (${i.file})`); - const link = compatLinkNodeId(path.join(NODES_DIR, name)); - return new IdentityHaltError(name, file, - `the name index entry for "${name}" exists, but ${why}. It is the record of which node this name is, so it is not read as "no such agent". ` + - (onDisk.length || link - ? `The identity it named is still on this host: ${[...onDisk, link && `nodes/${name} leads to by-id/${link}`].filter(Boolean).join('; ')}. ` - : '') + - 'Nothing was changed.', - `Restore the entry: ${file} holds {"name": ${JSON.stringify(name)}, "nodeId": ""}. Do not delete it to "start over": ` + - 'the key it leads to is this agent.'); -} - -/** Wait (briefly) for the entry another process is creating in place, where hard links are not supported. */ -function nodeIdForNameSettled(name) { - for (let i = 0; ; i++) { - try { return nodeIdForName(name); } catch (e) { - const st = statOrNull(nameIndexPath(name)); - if (i >= 40 || !st || st.size !== 0 || Date.now() - st.mtimeMs > 2000) throw e; - pause(5); - } + if (fd !== undefined) try { fs.closeSync(fd); } catch { /* */ } + try { fs.unlinkSync(tmp); } catch { /* */ } + throw e; } } +/** A JSON file's state: absent (ENOENT), unreadable (exists, cannot be read or parsed), or ok with its value. */ +function readJsonState(file) { + let raw; + try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { + if (e.code === 'ENOENT' || e.code === 'ENOTDIR') return { state: 'absent', file }; + return { state: 'unreadable', file, why: `it could not be read (${e.code || e.message})`, io: true }; + } + try { + const j = JSON.parse(raw); + if (j && typeof j === 'object') return { state: 'ok', file, j }; + } catch { /* below */ } + return { state: 'unreadable', file, why: raw.length ? 'it is not valid JSON' : 'it is empty' }; +} + +// ── Names ──────────────────────────────────────────────────────────────── + /** - * Claim `name` for `nodeId` by an exclusive create of its index entry. Of several first starts of one name, - * exactly one makes the entry; the rest find it made. Returns the nodeId the entry holds afterwards: `nodeId` - * when this call claimed the name (or it already named it), the winner's otherwise. + * The one spelling of a name that every comparison uses: Unicode NFC, lower case. A name is compared this way on + * every platform, so two agents whose names differ only in case (or in how an accent is encoded) are one name: a + * case-insensitive file system (macOS, Windows) cannot hold their two 0.13 directories, and a host that could + * (Linux) must not run two agents that one move to a Mac would merge. */ -function claimName(name, nodeId) { - ensureDir(path.join(NODES_DIR, 'by-name')); - for (let i = 0; i < 5; i++) { - if (createFileExclusive(nameIndexPath(name), JSON.stringify({ name, nodeId, at: Date.now() }))) return nodeId; - const held = nodeIdForNameSettled(name); - if (held) return held; +function foldName(name) { return String(name).normalize('NFC').toLowerCase(); } + +/** The name index entry of `name`: `nodes/by-name/.json`, holding { name: , nodeId }. */ +const nameIndexPath = (name) => path.join(NODES_DIR, 'by-name', `${encodeURIComponent(foldName(name))}.json`); +/** Where 0.14.0 - 0.14.2 kept it (the exact name): read when the folded entry is missing, written beside it for a rollback. */ +const exactIndexPath = (name) => path.join(NODES_DIR, 'by-name', `${encodeURIComponent(name)}.json`); +const sameFile = (a, b) => { const x = statOrNull(a); const y = statOrNull(b); return Boolean(x && y && x.dev === y.dev && x.ino === y.ino); }; + +/** One index entry file, read for the folded name `f`: missing, unreadable (with why), or ok with its nodeId. */ +function readEntryFile(file, f) { + const r = readJsonState(file); + if (r.state === 'absent') return { state: 'missing', file }; + if (r.state === 'unreadable') return { state: 'unreadable', file, why: r.why, io: Boolean(r.io) }; + if (!isNodeIdText(r.j.nodeId)) return { state: 'unreadable', file, why: 'it does not hold {"name", "nodeId"} for this name' }; + if (r.j.name !== undefined && (typeof r.j.name !== 'string' || foldName(r.j.name) !== f)) { + return { state: 'unreadable', file, why: `it holds the entry of another name (${JSON.stringify(String(r.j.name).slice(0, 64))})` }; } - throw new Error(`[SYM] the name index entry for "${name}" keeps appearing and disappearing; nothing was changed`); + return { state: 'ok', file, nodeId: r.j.nodeId, name: typeof r.j.name === 'string' ? r.j.name : null }; } /** - * The halt for a start pinned to `pin` under a name whose index entry names another identity (`held`). The entry - * is left as it is: a name is one agent's on this host. + * The index entry for `name`, a cache of which committed identity the name is: the folded entry, or (when that is + * missing) the entry 0.14.0 - 0.14.2 wrote under the exact name. Two readable entries for one folded name that name + * different nodes (two agents a case-sensitive 0.14.2 host made, "Kappa" and "kappa") are a conflict. */ -function nameHeldByAnother(name, pin, held) { - const there = identityInPlace(held); - return new IdentityHaltError(name, nameIndexPath(name), - `this start is pinned to ${pin}, and the name "${name}" is another agent's on this host: its index entry names ${held}` + - `${there ? '' : ' (whose identity is not installed here)'}. A pinned start does not take a name from another identity: the next start of ` + - `"${name}" by name would run as ${pin}, and ${held} would be left without its name. The index entry was left as it is.`, - `Start ${pin} under its own name, or pin the start to ${held} if that is the agent meant. To give "${name}" to ${pin}, ` + - `rename the other agent first (renameIdentity).`); +function entryFor(name) { + const f = foldName(name); + const a = readEntryFile(nameIndexPath(name), f); + if (exactIndexPath(name) === nameIndexPath(name)) return a; + const b = readEntryFile(exactIndexPath(name), f); + if (a.state === 'ok' && b.state === 'ok' && a.nodeId !== b.nodeId && !sameFile(a.file, b.file)) return { state: 'conflict', entries: [a, b] }; + if (a.state === 'ok') return a; + if (b.state === 'ok') return { ...b, exactKey: true }; + return a.state === 'unreadable' ? a : b.state === 'unreadable' ? b : a; } -/** Every nodeId some index entry names, with the names. Entries that cannot be read are left out. */ +/** Every readable index entry: nodeId -> [{ name, file }]. */ function indexEntries() { const out = new Map(); const dir = path.join(NODES_DIR, 'by-name'); @@ -475,48 +468,107 @@ function indexEntries() { try { files = fs.readdirSync(dir); } catch { return out; } for (const f of files) { if (f.startsWith('.') || !f.endsWith('.json')) continue; - try { - const r = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); - if (r && isNodeIdText(r.nodeId)) { - if (!out.has(r.nodeId)) out.set(r.nodeId, []); - out.get(r.nodeId).push(typeof r.name === 'string' ? r.name : decodeURIComponent(f.slice(0, -5))); - } - } catch { /* unreadable: its own name halts when started */ } + const r = readJsonState(path.join(dir, f)); + if (r.state !== 'ok' || !isNodeIdText(r.j.nodeId)) continue; + let name = typeof r.j.name === 'string' ? r.j.name : null; + if (name === null) { try { name = decodeURIComponent(f.slice(0, -5)); } catch { continue; } } + if (!out.has(r.j.nodeId)) out.set(r.j.nodeId, []); + out.get(r.j.nodeId).push({ name, file: path.join(dir, f) }); } return out; } -/** The by-id roots: nodes/by-id, and SYM_IDENTITY_DIR/by-id when set. */ -function byIdRoots() { - const roots = [path.join(NODES_DIR, 'by-id')]; - if (process.env.SYM_IDENTITY_DIR) roots.push(path.join(process.env.SYM_IDENTITY_DIR, 'by-id')); - return roots; +/** Write the 0.14.2 exact-name entry beside the folded one when they are different files (a rollback reads it). */ +function writeExactKey(name, nodeId) { + const exact = exactIndexPath(name); + if (exact === nameIndexPath(name) || sameFile(exact, nameIndexPath(name))) return; + const there = readEntryFile(exact, foldName(name)); + if (there.state === 'ok') return; // the same node, or another one's (a conflict a start reports): never re-pointed here + try { writeFileDurable(exact, JSON.stringify({ name, nodeId, at: Date.now() }), 0o644); } catch { /* a cache */ } } -/** Every identity.json filed by nodeId on this host: { nodeId, name, file, scrubbed }. Dot-entries (staging, husks) are not identities. */ -function identitiesOnDisk() { - const out = new Map(); - for (const root of byIdRoots()) { - let dirs = []; - try { dirs = fs.readdirSync(root); } catch { continue; } - for (const d of dirs) { - if (d.startsWith('.') || !isNodeIdText(d) || out.has(d)) continue; - const file = path.join(root, d, 'identity.json'); - try { - const j = JSON.parse(fs.readFileSync(file, 'utf8')); - if (j && j.nodeId === d) out.set(d, { nodeId: d, name: j.name, file, scrubbed: !j.privateKey }); - } catch { /* none, or not readable: not an identity this scan can name */ } +/** + * Claim `name` for `nodeId` by an exclusive create of its index entry. Returns the nodeId the entry holds + * afterwards: `nodeId` when this call claimed it (or it already named it), another when another identity holds it, + * null when an entry is there that cannot be read (it is not replaced here). + */ +function claimName(name, nodeId, exactName = name) { + const f = foldName(name); + fs.mkdirSync(path.join(NODES_DIR, 'by-name'), { recursive: true }); + for (let i = 0; i < 8; i++) { + if (createFileExclusive(nameIndexPath(name), JSON.stringify({ name: exactName, nodeId, at: Date.now() }))) { + writeExactKey(exactName, nodeId); + return nodeId; + } + const e = readEntryFile(nameIndexPath(name), f); + if (e.state === 'ok') { if (e.nodeId === nodeId) writeExactKey(exactName, nodeId); return e.nodeId; } + if (e.state === 'unreadable') { + // Created in place where hard links are not supported, and not written yet: give it a moment. + const st = statOrNull(e.file); + if (st && st.size === 0 && Date.now() - st.mtimeMs < 2000) { pause(5); continue; } + return null; } } - return [...out.values()]; + return null; } -/** True when the identity of `nodeId` is where a load reads it. */ -function identityInPlace(nodeId) { - return fs.existsSync(path.join(identityDirById(nodeId), 'identity.json')); +/** Re-derive an entry whose content is not an entry (empty, not JSON, another name's): replaced in one step. */ +function repairEntry(name, nodeId, exactName) { + try { writeFileDurable(nameIndexPath(name), JSON.stringify({ name: exactName, nodeId, at: Date.now() }), 0o644); } catch { return; } + writeExactKey(exactName, nodeId); } -// ── The 0.13-compatible links (nodes/ -> by-id/, for one release) ── +// ── Committed identities: the one source of truth ──────────────────────── + +const identityFileById = (nodeId) => path.join(identityDirById(nodeId), 'identity.json'); + +/** + * The committed identity of `nodeId`: the identity.json where a load reads it (identityDirById). An agent exists + * exactly when this file exists. `absent`, `unreadable` (it exists and cannot be read, or names another nodeId: a + * load halts), or `ok` with its name. + */ +function readCommitted(nodeId) { + const r = readJsonState(identityFileById(nodeId)); + if (r.state !== 'ok') return r; + if (r.j.nodeId !== nodeId) return { state: 'unreadable', file: r.file, why: `it names ${String(r.j.nodeId).slice(0, 40)}, not ${nodeId}` }; + return { state: 'ok', file: r.file, identity: r.j, name: typeof r.j.name === 'string' ? r.j.name : null, scrubbed: !r.j.privateKey }; +} + +/** True when an identity is committed for `nodeId` (its file exists, readable or not). */ +function isCommitted(nodeId) { + try { fs.statSync(identityFileById(nodeId)); return true; } catch (e) { return !(e.code === 'ENOENT' || e.code === 'ENOTDIR'); } +} + +/** Where committed identities are filed: SYM_IDENTITY_DIR/by-id when set, else nodes/by-id. */ +const committedRoot = () => (process.env.SYM_IDENTITY_DIR ? path.join(process.env.SYM_IDENTITY_DIR, 'by-id') : path.join(NODES_DIR, 'by-id')); + +/** Every committed identity on this host: [{ nodeId, state, name, scrubbed, file }]. Dot-entries are never identities. */ +function scanCommitted() { + const out = []; + let names = []; + try { names = fs.readdirSync(committedRoot()); } catch { return out; } + for (const d of names) { + if (d.startsWith('.') || !isNodeIdText(d)) continue; + const r = readCommitted(d); + if (r.state !== 'absent') out.push({ nodeId: d, ...r }); + } + return out; +} + +/** The live committed identities named `f` (folded) other than `except`: readable, holding their key, not moved away. */ +function namedHere(scan, f, except = null) { + return scan.filter((i) => i.state === 'ok' && !i.scrubbed && i.name !== null && foldName(i.name) === f && i.nodeId !== except && !readTombstone(i.nodeId)); +} + +/** The halt for two committed identities that are one name. Nothing is changed; the operator renames one. */ +function twoAgentsHalt(name, ids, why) { + return new IdentityHaltError(name, nameIndexPath(name), + `${ids.length} agents on this host are named "${name}" (names are compared without regard to case): ${ids.map((id) => `${id} (${identityFileById(id)})`).join(', ')}${why ? `; ${why}` : ''}. ` + + 'A name is one agent, so neither was started and nothing was changed.', + `Start the one you mean by its nodeId (loadIdentity({ nodeId })) and give the other another name (renameIdentity, or start it pinned under a new name). Do not remove either identity.json: each is an agent's key.`); +} + +// ── The 0.13-compatible links (nodes/ -> by-id/): derived, like the index ── /** * True when the link at `link` leads to `target` (relative to the link's directory). A Windows junction @@ -546,32 +598,45 @@ function removeLink(p) { try { fs.unlinkSync(p); } catch (e) { if (e.code === 'ENOENT') return; fs.rmdirSync(p); } } +/** A link to `by-id/` made under a name of its own beside `at`, ready to be renamed onto it; null when none can be made. */ +function makeTempLink(at, nodeId) { + const tmp = path.join(path.dirname(at), `.${path.basename(at)}.link-${process.pid}-${randomTag()}`); + try { fs.symlinkSync(path.join('by-id', nodeId), tmp, linkType()); return tmp; } catch { return null; } +} + +/** + * Put the temp link `tmp` at `at` when nothing but a link is there. A real directory is never replaced, except an empty + * one (or one holding only a stale lock), which is nothing. + */ +function putTempLink(tmp, at) { + if (!tmp) return false; + const st = lstatOrNull(at); + let done = false; + if (!st || st.isSymbolicLink() || (st.isDirectory() && removeEmptyDir(at))) { + try { fs.renameSync(tmp, at); done = true; } catch { /* below */ } + // The rename failed (not a crash: an error): a link made in place, where nothing is. + if (!done && !lstatOrNull(at)) { try { fs.symlinkSync(fs.readlinkSync(tmp), at, linkType()); done = true; } catch { /* */ } } + } + if (lstatOrNull(tmp)) { try { removeLink(tmp); } catch { /* */ } } + if (done) fsyncDir(path.dirname(at)); + return done; +} + /** - * Make `link` lead to `by-id/`. A real directory is not replaced (a 0.13 one is set aside by the - * migration first), except an empty one (or one holding only a stale lock), which is nothing. The link is - * made under a name of its own and renamed onto the path, so a stale link is replaced in one step. Best - * effort: the index is authoritative. + * Make `link` lead to `by-id/`. A real directory is not replaced, except an empty one (or one holding only + * a stale lock), which is nothing. Best effort: the link is a cache of the name, like the index. */ function linkCompat(link, nodeId) { const target = path.join('by-id', nodeId); const st = lstatOrNull(link); if (st && st.isSymbolicLink() && linkLeadsTo(link, target)) return true; if (st && !st.isSymbolicLink() && !(st.isDirectory() && removeEmptyDir(link))) return false; - const tmp = path.join(path.dirname(link), `.${path.basename(link)}.link-${process.pid}-${randomTag()}`); + const tmp = makeTempLink(link, nodeId); + if (putTempLink(tmp, link)) return true; try { - fs.symlinkSync(target, tmp, process.platform === 'win32' ? 'junction' : 'dir'); - } catch { return false; } - try { - fs.renameSync(tmp, link); - return true; - } catch { - try { removeLink(tmp); } catch { /* */ } - try { - if (lstatOrNull(link) && lstatOrNull(link).isSymbolicLink()) removeLink(link); - fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); - return true; - } catch { return false; } - } + if (!lstatOrNull(link)) { fs.symlinkSync(target, link, linkType()); return true; } + } catch { /* */ } + return false; } /** The compat links of `name`: nodes/, and SYM_IDENTITY_DIR/ when the identity is there. */ @@ -595,7 +660,9 @@ function removeEmptyDir(dir) { try { fs.rmdirSync(dir); return true; } catch (e) { return e.code === 'ENOENT'; } } -/** The tombstone a moved identity left, or null: beside the key, or (written by 0.14.0 - 0.14.2) in the node dir. */ +// ── Tombstones and export intents ──────────────────────────────────────── + +/** The tombstone a moved identity left, or null: beside the key, or (a copy for 0.14.2, and 0.14.0 - 0.14.2's own) in the node dir. */ function readTombstone(nodeId) { for (const d of new Set([identityDirById(nodeId), nodeDirById(nodeId)])) { try { return JSON.parse(fs.readFileSync(path.join(d, TOMBSTONE), 'utf8')); } catch { /* none here */ } @@ -611,13 +678,15 @@ function readExportIntent(nodeId) { return null; } +// ── Key files: modes ───────────────────────────────────────────────────── + /** The files in a node's directory that hold a PRIVATE KEY: its signing identity and the 0.13 E2E keypair. */ const PRIVATE_KEY_FILES = ['identity.json', 'e2e-keypair.json']; /** - * Narrow a node's private key files to their owner (0600), and with `dirs` the directories that hold - * them (0700). A key file a 0.13 node wrote at the umask default (0644 — world-readable) stays - * world-readable in the 0.13 directory the migration sets aside; this is what ends that. + * Narrow a node's private key files to their owner (0600), and with `narrowDirs` the directories that hold + * them (0700). A key file a 0.13 node wrote at the umask default (0644 — world-readable) stays world-readable + * until this runs; it runs on every load, on everything a migration installs or sets aside, and on staging. * * Best effort on purpose: a file this process does not own, or a filesystem without modes (Windows), * must not stop a node from starting. Nothing here changes what a file holds. @@ -631,25 +700,6 @@ function securePrivateFiles(dirs, { narrowDirs = false } = {}) { } } -/** Write a file the way a key file must be written: a private temp file, synced, renamed over the target. */ -function writeFileAtomic0600(file, text) { - const tmp = `${file}.${process.pid}.tmp`; - let fd; - try { - fd = fs.openSync(tmp, 'w', 0o600); - fs.writeSync(fd, text); - fs.fsyncSync(fd); - fs.closeSync(fd); - fd = undefined; - fs.renameSync(tmp, file); - fsyncDir(path.dirname(file)); - } catch (e) { - if (fd !== undefined) try { fs.closeSync(fd); } catch { /* */ } - try { fs.unlinkSync(tmp); } catch { /* */ } - throw e; - } -} - // ── Reading an identity ────────────────────────────────────────────────── /** @@ -725,7 +775,7 @@ function readIdentityFile(idPath, name) { // Atomic and private: a truncate-in-place that is interrupted leaves a key file with no key in it, and a // mode passed to writeFileSync is ignored for a file that already exists. if (mutated) writeFileAtomic0600(idPath, JSON.stringify(identity, null, 2)); - securePrivateFiles([path.dirname(idPath)]); + securePrivateFiles([path.dirname(idPath)], { narrowDirs: true }); return identity; } @@ -735,199 +785,80 @@ function sameIdentity(source, other) { (!source.privateKey || source.privateKey === other.privateKey) && (!source.publicKey || source.publicKey === other.publicKey); } +const sameIdentityBothWays = (a, b) => sameIdentity(a, b) && sameIdentity(b, a); -// ── Staged copies, committed by one rename ─────────────────────────────── +// ── Runtime leftovers: staging, temp links, temp entries, lock artifacts ── -/** Top-level entries a copy of a node directory leaves out: runtime state, and what the copy writes itself. */ -const COPY_SKIP_TOP = new Set(['lock.pid', 'interior.sock', TOMBSTONE, EXPORTING, COMMIT_MARK]); -const skipTop = (extra = []) => { - const names = new Set([...COPY_SKIP_TOP, ...extra]); - return (rel) => !rel.includes('/') && (names.has(rel) || rel.endsWith('.tmp') || rel.startsWith('lock.pid.') || /^\.[^/]*\.link-\d+-[0-9a-f]+$/.test(rel)); -}; +/** A lock, a claim to remove one, or the temp file a lock is written under: runtime state, never a node's data. */ +const isLockArtifact = (n) => n === 'lock.pid' || n.startsWith('lock.pid.') || (n.startsWith('.lock.pid') && n.endsWith('.tmp')); -/** Two regular files hold the same bytes (read in pieces: a store file can be large). */ -function sameBytes(a, b) { - const sa = statOrNull(a); const sb = statOrNull(b); - if (!sa || !sb || !sa.isFile() || !sb.isFile() || sa.size !== sb.size) return false; - let fa; let fb; - try { - fa = fs.openSync(a, 'r'); fb = fs.openSync(b, 'r'); - const ba = Buffer.alloc(1 << 20); const bb = Buffer.alloc(1 << 20); - for (let pos = 0; pos < sa.size;) { - const na = fs.readSync(fa, ba, 0, ba.length, pos); const nb = fs.readSync(fb, bb, 0, bb.length, pos); - if (na !== nb || na === 0 || ba.compare(bb, 0, nb, 0, na) !== 0) return false; - pos += na; - } - return true; - } catch { return false; } finally { - if (fa !== undefined) fs.closeSync(fa); - if (fb !== undefined) fs.closeSync(fb); - } +/** Staging directories (this release's `.stage-`, an import's `.import-`, 0.14.3 candidates' `.partial-`): `..--`. */ +const STAGING_RE = /^\.([0-9A-Za-z][0-9A-Za-z_.-]*?)\.(stage|import|partial)-(\d+)-([0-9a-f]+)$/; +/** A process that made a leftover is gone, or (a pid reused) the leftover is older than any step that makes one takes. */ +const LEFTOVER_MAX_AGE_MS = 10 * 60 * 1000; +function leftoverIsDead(pid, p) { + if (pid === process.pid) return false; + if (!pidIsAlive(pid)) return true; + const st = lstatOrNull(p); + return Boolean(st && Date.now() - st.mtimeMs > LEFTOVER_MAX_AGE_MS); } /** - * Copy the children of directory `src` into directory `dst` (which exists): directories are made 0700, - * files keep their bytes and mode (a key file 0600) and are synced. Links are FOLLOWED, so what a 0.13 node - * kept behind a link (its directory on another volume, an identity.json linked to a key file) is copied, not - * a link that would lead somewhere else from the new place; a link that leads nowhere is copied as the link - * it is. What is neither a file nor a directory (a socket) is not data and is left out, and so is what - * `skip(rel)` names. A directory reached twice through links is copied as a link, not followed forever. + * Clear what processes that are gone left behind (design: on every load): staging directories (one may hold a + * private key: it was never anyone's, and it is never read), temp links, temp index entries and temp key files. + * Nothing that is set aside is touched: an aside is kept. */ -function copyChildren(src, dst, skip, rel = '', within = new Set()) { - within.add(fs.realpathSync(src)); - for (const e of fs.readdirSync(src).sort()) { - const r = rel ? `${rel}/${e}` : e; - if (skip(r)) continue; - const from = path.join(src, e); - const to = path.join(dst, e); - const st = statOrNull(from); - if (!st) { // a link that leads nowhere - const l = lstatOrNull(from); - if (l && l.isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(from), to); - continue; - } - if (st.isDirectory()) { - if (within.has(fs.realpathSync(from))) { fs.symlinkSync(fs.readlinkSync(from), to); continue; } - fs.mkdirSync(to, { mode: 0o700 }); - fs.chmodSync(to, 0o700); - copyChildren(from, to, skip, r, new Set(within)); - fsyncDir(to); - } else if (st.isFile()) { - // From the file a link leads to: a clone (macOS) of a link would copy the link. - fs.copyFileSync(fs.realpathSync(from), to, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE); - fsyncFile(to); - fs.chmodSync(to, PRIVATE_KEY_FILES.includes(e) ? 0o600 : st.mode & 0o777); +function clearLeftovers() { + const idRoot = process.env.SYM_IDENTITY_DIR; + for (const dir of new Set([path.join(NODES_DIR, 'by-id'), idRoot && path.join(idRoot, 'by-id')].filter(Boolean))) { + let names = []; + try { names = fs.readdirSync(dir); } catch { continue; } + for (const n of names) { + const m = STAGING_RE.exec(n); + if (!m || !leftoverIsDead(Number(m[3]), path.join(dir, n))) continue; + try { fs.rmSync(path.join(dir, n), { recursive: true, force: true }); } catch { /* the next load */ } } } -} - -/** The first path (relative) under directory `src` whose copy under `dst` is missing or differs, or null. */ -function firstDifference(src, dst, skip, rel = '', within = new Set()) { - within.add(fs.realpathSync(src)); - for (const e of fs.readdirSync(src).sort()) { - const r = rel ? `${rel}/${e}` : e; - if (skip(r)) continue; - const from = path.join(src, e); - const to = path.join(dst, e); - const st = statOrNull(from); - if (!st) { - const l = lstatOrNull(from); const d = lstatOrNull(to); - if (l && l.isSymbolicLink() && !(d && d.isSymbolicLink() && fs.readlinkSync(to) === fs.readlinkSync(from))) return r; - continue; - } - if (st.isDirectory()) { - if (within.has(fs.realpathSync(from))) continue; - const d = lstatOrNull(to); - if (!d || !d.isDirectory()) return r; - const inner = firstDifference(from, to, skip, r, new Set(within)); - if (inner) return inner; - } else if (st.isFile() && !sameBytes(from, to)) { - return r; + for (const dir of new Set([NODES_DIR, idRoot].filter(Boolean))) { + let names = []; + try { names = fs.readdirSync(dir); } catch { continue; } + for (const n of names) { + const m = /^\..+\.link-(\d+)-[0-9a-f]+$/.exec(n); + if (m && leftoverIsDead(Number(m[1]), path.join(dir, n))) { try { removeLink(path.join(dir, n)); } catch { /* */ } } } } - return null; -} - -/** Remove leftover staging directories (`..partial--`) of processes that are gone. */ -function clearPartials(parent) { + // What is set aside and may hold a key (a 0.13 directory, a husk, an import's archive) is narrowed, also when the start + // that set it aside was killed before it did. + for (const root of new Set([NODES_DIR, idRoot].filter(Boolean))) { + let asides = []; + try { asides = fs.readdirSync(path.join(root, LEGACY_ASIDE)); } catch { /* none */ } + securePrivateFiles(asides.map((n) => path.join(root, LEGACY_ASIDE, n)).filter((d) => { const st = lstatOrNull(d); return st && st.isDirectory(); }), { narrowDirs: true }); + } + for (const dir of new Set([path.join(NODES_DIR, 'by-id'), idRoot && path.join(idRoot, 'by-id')].filter(Boolean))) { + let names = []; + try { names = fs.readdirSync(dir).filter((n) => /^\.[^/]+\.(husk|moved)(-|$)/.test(n)); } catch { continue; } + securePrivateFiles(names.map((n) => path.join(dir, n)).filter((d) => { const st = lstatOrNull(d); return st && st.isDirectory(); }), { narrowDirs: true }); + } + const byName = path.join(NODES_DIR, 'by-name'); let names = []; - try { names = fs.readdirSync(parent); } catch { return; } + try { names = fs.readdirSync(byName); } catch { names = []; } for (const n of names) { - const m = /^\.[^\\/]+\.(?:partial|import)-(\d+)-[0-9a-f]+$/.exec(n); - if (!m || Number(m[1]) === process.pid || pidIsAlive(Number(m[1]))) continue; - try { fs.rmSync(path.join(parent, n), { recursive: true, force: true }); } catch { /* the next attempt */ } + const m = /^\..+\.(\d+)-[0-9a-f]+\.tmp$/.exec(n); + if (m && leftoverIsDead(Number(m[1]), path.join(byName, n))) { try { fs.unlinkSync(path.join(byName, n)); } catch { /* */ } } } } -/** - * A new staging directory for `nodeId` beside where it will be installed (`parent`), so the commit is a rename - * on one volume: `..--`, 0700. Leftovers of processes that are gone are cleared first. - */ -function makeStaging(parent, nodeId, kind = 'partial') { - ensureDir(parent); - clearPartials(parent); +/** A new staging directory for `nodeId` in `parent` (the directory it commits into, so the commit is one rename): 0700. */ +function makeStaging(parent, nodeId, kind = 'stage') { + fs.mkdirSync(parent, { recursive: true }); const staging = path.join(parent, `.${nodeId}.${kind}-${process.pid}-${randomTag()}`); fs.mkdirSync(staging, { mode: 0o700 }); fs.chmodSync(staging, 0o700); return staging; } -/** Write the commit record into a staging directory (last), and sync the directory. */ -function markStaged(staging, nodeId, from) { - writeNewFileSynced(path.join(staging, COMMIT_MARK), JSON.stringify({ nodeId, from, at: Date.now() }) + '\n', 0o600); - fsyncDir(staging); -} - -/** Why a staged commit was refused: a different identity, or files that differ, already under the nodeId. */ -class CommitConflict extends Error {} - -/** - * What is at `target`, the by-id directory a commit for `nodeId` installs: - * - 'absent'; - * - 'committed': it carries the commit record (and, when it holds the identity, `source`'s identity); - * - 'husk': a directory without the commit record that does not contradict `source` (empty, a stale lock, - * the files of an earlier release's interrupted move or of a node that ran there without its identity); - * - 'conflict': it holds a different identity, or is not a directory. - * `holdsIdentity`: the identity lives in this directory (no SYM_IDENTITY_DIR, or the identity directory). - */ -function examineTarget(target, nodeId, source, holdsIdentity) { - const st = lstatOrNull(target); - if (!st) return { state: 'absent' }; - if (st.isSymbolicLink() || !st.isDirectory()) return { state: 'conflict', reason: `${target} is not a directory` }; - const marked = fs.existsSync(path.join(target, COMMIT_MARK)); - if (holdsIdentity) { - const f = path.join(target, 'identity.json'); - if (fs.existsSync(f)) { - let other = null; - try { other = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { /* below */ } - if (!other) return { state: 'conflict', reason: `${f} is already there and cannot be read` }; - if (!sameIdentity(source, other)) return { state: 'conflict', reason: `a different identity.json is already filed under ${nodeId} (${f})` }; - } else if (marked) { - return { state: 'conflict', reason: `${target} carries a commit record and no identity.json` }; - } - } - if (marked) { - let mark = null; - try { mark = JSON.parse(fs.readFileSync(path.join(target, COMMIT_MARK), 'utf8')); } catch { /* below */ } - if (!mark || mark.nodeId !== nodeId) return { state: 'conflict', reason: `${target} carries the commit record of another node` }; - return { state: 'committed' }; - } - return { state: 'husk' }; -} - -/** - * Copy what `from` holds into `staging` where it is not there yet; a file on both sides that differs is a - * conflict (CommitConflict), and nothing of `from` is changed. How a husk's files (a node that ran in the - * by-id directory without its identity) come into the staged directory before the husk is set aside. - */ -function mergeIntoStaging(from, staging, skip, rel = '') { - for (const e of fs.readdirSync(path.join(from, rel)).sort()) { - const r = rel ? `${rel}/${e}` : e; - if (skip(r)) continue; - const src = path.join(from, r); - const dst = path.join(staging, r); - const st = statOrNull(src); - if (!st) continue; - const d = lstatOrNull(dst); - if (st.isDirectory()) { - if (d && !d.isDirectory()) throw new CommitConflict(`${src} is a directory where the 0.13 copy has a file`); - if (!d) { fs.mkdirSync(dst, { mode: 0o700 }); fs.chmodSync(dst, 0o700); } - mergeIntoStaging(from, staging, skip, r); - } else if (st.isFile()) { - if (d) { if (!sameBytes(src, dst)) throw new CommitConflict(`${src} differs from the copy of the 0.13 directory`); continue; } - fs.copyFileSync(fs.realpathSync(src), dst, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE); - fsyncFile(dst); - fs.chmodSync(dst, PRIVATE_KEY_FILES.includes(e) ? 0o600 : st.mode & 0o777); - if (!sameBytes(src, dst)) throw new Error(`the copy of ${src} does not match it`); - } - } -} - -/** A lock, a claim to remove one, or the temp file a lock is written under: runtime state, never a node's data. */ -const isLockArtifact = (n) => n === 'lock.pid' || n.startsWith('lock.pid.') || (n.startsWith('.lock.pid') && n.endsWith('.tmp')); - /** - * Remove, from a husk that holds nothing else, the lock artifacts of processes that are gone: a claim to remove a + * Remove, from a directory that holds nothing else, the lock artifacts of processes that are gone: a claim to remove a * lock that is itself gone, or a temp lock file. (lock.pid itself is removed only by removeStaleLock.) */ function clearDeadLockArtifacts(dir) { @@ -939,7 +870,10 @@ function clearDeadLockArtifacts(dir) { for (const n of names) { if (n.startsWith('lock.pid.')) { const l = readLockFile(path.join(dir, n)); - if (l && (l.pid === process.pid || (!lockIsLive(l) && Date.now() - l.mtimeMs > LOCK_CORRUPT_GRACE_MS))) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* */ } } + // A claim whose holder is gone (killed while it removed the lock, which is gone too) goes at once; one that cannot be + // read yet is given the grace a lock being written gets. + const gone = l && (l.pid === process.pid || (l.pid !== null ? !lockIsLive(l) : Date.now() - l.mtimeMs > LOCK_CORRUPT_GRACE_MS)); + if (gone) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* */ } } } else if (n.startsWith('.lock.pid') && n.endsWith('.tmp')) { const m = /\.(\d+)-[0-9a-f]+\.tmp$/.exec(n); if (m && gone(Number(m[1]))) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* */ } } @@ -947,120 +881,42 @@ function clearDeadLockArtifacts(dir) { } } -/** - * Install a complete staging directory as `target` by ONE rename: that rename is the commit. Returns - * 'committed' when this staging went in, 'exists' when `target` already holds a committed directory (another - * start committed first, or an earlier one did): the caller checks it is the same identity and discards its - * staging. Nothing at `target` is ever overwritten by it: - * - a husk (no commit record) holding nothing, or only a lock whose holder is gone, is removed with rmdir, - * which cannot remove a directory with anything in it, and the rename is tried again; - * - a husk with files: they are copied into the staging (a file that differs is a conflict), and the husk is - * renamed aside to `..husk` beside it, kept. That name is taken once and kept, so a later rename - * can never move a directory installed since; and the staging takes in the files of a husk set aside by - * another start before each attempt; - * - a husk whose lock a live process holds: EIDENTITYLOCK, nothing changed. - */ -function commitStaged(staging, target, { nodeId, examine, skip }) { - const asideHusk = path.join(path.dirname(target), `.${nodeId}.husk`); - for (let attempt = 0; attempt < 12; attempt++) { - if (fs.existsSync(asideHusk)) mergeIntoStaging(asideHusk, staging, skip); - try { - fs.renameSync(staging, target); - fsyncDir(path.dirname(target)); - return 'committed'; - } catch (e) { - if (!RENAME_TAKEN.has(e.code)) throw e; - } - const t = examine(target); - if (t.state === 'absent') continue; - if (t.state === 'committed') return 'exists'; - if (t.state === 'conflict') throw new CommitConflict(t.reason); - const lockPath = path.join(target, 'lock.pid'); - const lock = readLockFile(lockPath); - if (lock) { - if (lockHeldInProcess(target) || (lock.pid !== process.pid && lockIsHeldByLiveProcess(lock))) { - throw lockError(`[SYM] A live process (PID ${lock.pid ?? 'unknown'}) holds ${lockPath}, but ${nodeId}'s identity is not installed there: ` + - 'a node of an earlier release is running in that directory without its identity. Stop it, then start this agent again; nothing was changed.', lock); - } - removeStaleLock(lockPath, lock); - } - let rest = []; - try { rest = fs.readdirSync(target).filter((n) => !isLockArtifact(n)); } catch { continue; } - if (!rest.length) { - clearDeadLockArtifacts(target); - try { fs.rmdirSync(target); continue; } catch (e) { - if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code)) throw e; - if (e.code === 'ENOENT') continue; - // Another start's claim or temp lock file is in it for a moment; if one stays, the husk is set aside with it. - if (attempt < 4) { pause(2); continue; } - } - } - // Looked at again right before it is set aside: since the look above, the husk may have been emptied and - // replaced by another start's commit (found by the multi-process fuzz: a committed directory was set aside). - // A husk with files cannot be replaced by a commit (a rename onto a directory that is not empty fails), so - // from here only another claim can move it, and then the rename below fails. - const again = examine(target); - if (again.state === 'absent') continue; - if (again.state === 'committed') return 'exists'; - if (again.state === 'conflict') throw new CommitConflict(again.reason); - mergeIntoStaging(target, staging, skip); - try { fs.renameSync(target, asideHusk); } catch (e) { if (e.code !== 'ENOENT' && !RENAME_TAKEN.has(e.code)) throw e; } - } - throw new Error(`[SYM] could not install ${target}: what was there kept changing; nothing was overwritten. Start again.`); +/** The live holder of the lock in `dir` (a process of this or an earlier release running there), or null. */ +function liveLockIn(dir) { + const lock = readLockFile(path.join(dir, 'lock.pid')); + if (!lock) return null; + if (lockHeldInProcess(dir)) return lock; + if (lock.pid !== process.pid && lockIsHeldByLiveProcess(lock)) return lock; + return null; } /** - * Stage a copy of the 0.13 directory `src` (null: none) for `nodeId` and commit it as `target`. `identityText`, - * when given, is written as identity.json after everything else, then the commit record; the copy is verified - * against `src` byte for byte before the commit. Returns 'committed' | 'exists'; a conflict throws CommitConflict. + * Hold the short lock file `lockPath` while `fn` runs: a few system calls (a mint, setting a husk aside), never a + * copy. A live holder is waited for (it is done within milliseconds), up to `waitMs`; a holder that is gone is + * removed the atomic way (removeStaleLock). */ -function commitCopy(src, target, { nodeId, identityText, source, holdsIdentity, from }) { - const skip = skipTop(['identity.json']); - const examine = (t) => examineTarget(t, nodeId, source, holdsIdentity); - const pre = examine(target); - if (pre.state === 'committed') return 'exists'; - if (pre.state === 'conflict') throw new CommitConflict(pre.reason); - const staging = makeStaging(path.dirname(target), nodeId); - try { - if (src) copyChildren(src, staging, skip); - if (identityText) writeNewFileSynced(path.join(staging, 'identity.json'), identityText, 0o600); - markStaged(staging, nodeId, from); - if (src) { - const d = firstDifference(src, staging, skip); - if (d) throw new Error(`[SYM] the copy of ${path.join(src, d)} does not match its source (did it change while it was copied?); nothing was installed, the source is untouched. Start again.`); - } - if (identityText && !sameIdentity(source, JSON.parse(fs.readFileSync(path.join(staging, 'identity.json'), 'utf8')))) { - throw new Error('[SYM] the staged identity.json does not read back as the identity it was written from; nothing was installed'); +function withShortLock(lockPath, what, fn, waitMs = 10000) { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + const t0 = Date.now(); + for (;;) { + const got = acquireLockAt(lockPath, { short: what }); + if (got.ok) break; + // This process's own: lock work is synchronous, so it is a leftover of a step that threw. + if (got.lock && got.lock.pid === process.pid && !got.contended) { removeStaleLock(lockPath, got.lock); continue; } + if (Date.now() - t0 > waitMs) { + throw lockError(`[SYM] Another start (PID ${got.lock && got.lock.pid !== null ? got.lock.pid : 'unknown'}) has been ${what} for over ${Math.round(waitMs / 1000)} s ` + + `(${lockPath}). This start is refused; nothing was changed. Start again once that process has finished or exited.`, got.lock); } - return commitStaged(staging, target, { nodeId, skip, examine }); - } finally { - fs.rmSync(staging, { recursive: true, force: true }); - } -} - -/** - * Install a new directory for `nodeId` at `target` (which must not exist), built by `build(staging)` in a staging - * directory and committed by one rename: for a new identity, and for an import (relocation.js, whose - * `beforeCommit` sets the tombstoned copy of a node coming back aside once the new one is complete). - */ -function installNewDir(target, nodeId, build, kind = 'partial', { beforeCommit } = {}) { - const staging = makeStaging(path.dirname(target), nodeId, kind); - try { - build(staging); - markStaged(staging, nodeId, kind); - if (beforeCommit) beforeCommit(); - fs.renameSync(staging, target); - fsyncDir(path.dirname(target)); - } finally { - fs.rmSync(staging, { recursive: true, force: true }); + pause(5); } + try { return fn(); } finally { _releaseLockIfOurs(lockPath); } } -// ── 0.13 directories ───────────────────────────────────────────────────── +// ── 0.13 directories, and moving them in ───────────────────────────────── /** * The 0.13 directory at `p`, or null: a real directory, or a link to a directory that is NOT a compat link (a - * 0.13 node kept its directory on another volume behind one; it is followed and copied). + * 0.13 node kept its directory on another volume behind one). */ function legacyAt(p) { const st = lstatOrNull(p); @@ -1089,138 +945,363 @@ function legacyIdentityUnder(name) { /** Whether `dir` holds anything but a lock. */ function hasContent(dir) { - try { return fs.readdirSync(dir).some((n) => n !== 'lock.pid'); } catch { return false; } + try { return fs.readdirSync(dir).some((n) => !isLockArtifact(n)); } catch { return false; } } -/** - * Hold the 0.13 directory's own lock (`nodes//lock.pid`, the file a 0.13 process of the agent takes) - * while it is copied and set aside, so no 0.13 process starts in it meanwhile. The lock says it is a - * migration's: another 0.14 start that finds it goes on beside it (the staged commit makes that safe) instead - * of waiting. A live holder that is not a migration is a process running in that directory: `liveHolder`. - */ -function takeLegacyLock(dir) { - const lockPath = path.join(dir, 'lock.pid'); - const token = randomTag() + randomTag(); - let got; - try { - got = acquireLockAt(lockPath, { migrating: true, token }); - } catch { - // The directory does not take a lock (read-only, not ours): copy it all the same, unless a process holds it. - const l = readLockFile(lockPath); - got = l && lockIsLive(l) ? { ok: false, lock: l } : { ok: true, none: true }; - if (got.ok) return { release() {} }; +/** The device a new entry in `dir` lands on: `dir`'s, or its nearest existing ancestor's. */ +function deviceOf(dir) { + for (let d = path.resolve(dir); ; d = path.dirname(d)) { + const st = statOrNull(d); + if (st) return st.dev; + if (path.dirname(d) === d) return null; } - if (!got.ok) { - const l = got.lock; - if (l && (l.migrating || l.pid === process.pid)) return { release() {} }; - return { liveHolder: (l && l.pid) || 'unknown', release() {} }; - } - return { - /** Remove the lock wherever the directory went (set aside), if it is still this one. */ - release(also = []) { - for (const p of [lockPath, ...also.map((d) => path.join(d, 'lock.pid'))]) { - const l = readLockFile(p); - if (l && l.pid === process.pid && l.token === token) { try { fs.unlinkSync(p); } catch { /* */ } } - } - }, - }; } +/** True when the real directory `p` is a mount point: on another device than its parent. */ +function isMountPoint(p) { + const a = statOrNull(p); const b = statOrNull(path.dirname(p)); + return Boolean(a && b && a.dev !== b.dev); +} + +/** Two paths are one directory (links followed). */ +const sameDir = (a, b) => { const x = statOrNull(a); const y = statOrNull(b); return Boolean(x && y && x.isDirectory() && x.dev === y.dev && x.ino === y.ino); }; + /** - * Rename the 0.13 directory (or link) at `at` aside, to `/.legacy-0.13/`: kept, never deleted. - * Returns where it went, or null when it is no longer there (another start set it aside). + * Set `p` (a directory, or a link) aside to `/.legacy-0.13/`, in the root it is in (one rename, on its + * volume), and narrow what holds a key at once. Kept, never deleted. A mount point cannot be renamed: it is narrowed + * where it is. Returns where it is now. */ -function setAside(at, root, name) { - const st = lstatOrNull(at); - if (!st || compatLinkNodeId(at)) return null; +function setLegacyAside(p, root, name, moved) { + const st = lstatOrNull(p); + if (!st) return null; + if (!st.isSymbolicLink() && isMountPoint(p)) { securePrivateFiles([p], { narrowDirs: true }); return p; } const dir = path.join(root, LEGACY_ASIDE); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); for (const dest of [path.join(dir, name), path.join(dir, `${name}.${Date.now()}-${randomTag()}`)]) { try { - fs.renameSync(at, dest); + fs.renameSync(p, dest); + if (moved) moved(); // the link in its place first (a 0.13 build reads the path), then the modes, then the syncs + if (!st.isSymbolicLink()) securePrivateFiles([dest], { narrowDirs: true }); fsyncDir(dir); fsyncDir(root); return dest; } catch (e) { if (e.code === 'ENOENT') return null; if (!RENAME_TAKEN.has(e.code)) throw e; - const again = lstatOrNull(at); - if (!again || compatLinkNodeId(at)) return null; // set aside meanwhile; what is there now is the link } } return null; } /** - * After the commit: the 0.13 directories of `name` go aside and compat links take their place. The link is - * made first, under a name of its own, and renamed onto the old path right after the directory left it, so a - * 0.13 rollback finds the old path empty for one rename only. + * What is at `target` (a by-id directory a migration places into), for the identity `o.source`: + * empty | locks (only lock artifacts) | same (the same identity is there) | other (another identity, or a file that + * cannot be read where an identity is) | husk (anything else: a directory an earlier release made without the + * identity, a link that leads nowhere). */ -function setAsideAndLink(name, nodeId, srcs) { - const plans = [[NODES_DIR, srcs.nodes]]; - if (srcs.idRoot) plans.push([srcs.idRoot, srcs.ids]); - const aside = []; - for (const [root, src] of plans) { - const at = path.join(root, name); - if (!src) { if (root === NODES_DIR || fs.existsSync(path.join(root, 'by-id', nodeId))) linkCompat(at, nodeId); continue; } - const tmp = path.join(root, `.${name}.link-${process.pid}-${randomTag()}`); +function judgeTarget(target, o) { + const l = lstatOrNull(target); + if (!l) return { verdict: 'absent' }; + const isLink = l.isSymbolicLink(); + const st = isLink ? statOrNull(target) : l; + if (!st || !st.isDirectory()) return { verdict: 'husk' }; + const r = readJsonState(path.join(target, 'identity.json')); + if (r.state === 'unreadable') { + if (o.holdsIdentity) return { verdict: 'other', reason: `${path.join(target, 'identity.json')} is there and ${r.why}`, halt: true }; + } else if (r.state === 'ok') { + if (!sameIdentityBothWays(o.source, r.j)) return { verdict: 'other', reason: `a different identity.json is already filed under ${o.nodeId} (${path.join(target, 'identity.json')})` }; + if (o.holdsIdentity) return { verdict: 'same' }; + } + if (isLink) return { verdict: 'husk' }; + let names = []; + try { names = fs.readdirSync(target); } catch { return { verdict: 'husk' }; } + if (!names.length) return { verdict: 'empty' }; + if (names.every(isLockArtifact)) return { verdict: 'locks' }; + return { verdict: 'husk' }; +} + +/** + * Set the husk at `target` aside (`by-id/..husk-