Files
ECC/scripts/lib/context-profile-store.js
T

298 lines
17 KiB
JavaScript

'use strict';
// An explicit, private materialization store. It never registers a provider or
// changes a user's install receipts, settings, hooks, or permission grants.
// Receipt, immutable-generation, lock, and recovery concepts are adapted from
// the ECC-029 activation prototype and Jeffrey Montoya's #2788 carrier work.
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { planContextCarrier } = require('./context-carriers');
const { createSourceReader, digestObject, stableStringify, validateSchema } = require('./context-profile-support');
const io = require('./context-profile-store-fs');
const DIGEST = /^[a-f0-9]{64}$/;
const CARRIER_KEYS = ['repoRoot', 'profileId', 'selectionMode', 'target', 'include', 'exclude'];
const INPUT_KEYS = new Set([...CARRIER_KEYS, 'stateRoot', 'expectedRevision', 'expectedCarrierDigest', 'onCheckpoint']);
const equal = (a, b) => stableStringify(a) === stableStringify(b);
const exists = name => Boolean(fs.lstatSync(name, { throwIfNoEntry: false }));
function rootFor(options) {
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Store options must be an object');
for (const key of Object.keys(options)) if (!INPUT_KEYS.has(key)) throw new Error(`Unknown store option: ${key}`);
const root = options.stateRoot;
if (typeof root !== 'string' || !path.isAbsolute(root) || path.resolve(root) !== root
|| root === path.parse(root).root || root === os.homedir()) throw new Error('stateRoot must name an explicit dedicated absolute directory');
if (options.expectedRevision !== undefined && (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 0)) throw new Error('Expected revision must be a nonnegative integer');
if (options.expectedCarrierDigest !== undefined && !DIGEST.test(options.expectedCarrierDigest)) throw new Error('Invalid expected carrier digest');
if (options.onCheckpoint !== undefined && typeof options.onCheckpoint !== 'function') throw new Error('Invalid checkpoint callback');
io.inspect(root, true);
return root;
}
function ownership(root, create = false) {
const marker = { schemaVersion: 'ecc.context-store.v1', destinationDigest: digestObject({ root }) };
if (!exists(root)) {
if (!create) return false;
io.mkdir(root);
io.writeExclusive(path.join(root, 'store.json'), io.jsonBytes(marker));
}
const stat = io.inspect(root).stat;
if (!stat.isDirectory() || (process.platform !== 'win32' && ((stat.mode & 0o077) !== 0
|| (process.getuid && stat.uid !== process.getuid())))) throw new Error('Managed store must be a private owned directory');
if (!exists(path.join(root, 'store.json')) || !equal(io.readJson(path.join(root, 'store.json')), marker)) throw new Error('Directory is not an owned ECC managed store');
return true;
}
function checkCarrier(carrier, expectedDigest) {
validateSchema(carrier, 'context-carrier.schema.json');
const { carrierDigest, ...body } = carrier;
if (carrier.status !== 'planned' || !DIGEST.test(expectedDigest) || carrierDigest !== expectedDigest
|| digestObject(body) !== expectedDigest) throw new Error('Managed carrier digest integrity mismatch');
return carrier;
}
function generationPath(root, digest) {
if (!DIGEST.test(digest)) throw new Error('Invalid generation digest');
return path.join(root, 'generations', digest);
}
function verifyGeneration(directory, carrier, partial = false) {
const expected = new Map(carrier.files.map(file => [`payload/${file.destinationPath}`, file]));
const descriptor = io.jsonBytes(carrier);
expected.set('carrier.json', { digest: io.hash(descriptor), bytes: descriptor.length });
const allowedDirectories = new Set(['payload']);
for (const name of expected.keys()) {
const parts = name.split('/');
for (let i = 1; i < parts.length; i++) allowedDirectories.add(parts.slice(0, i).join('/'));
}
const observed = io.inventory(directory);
for (const file of observed.files) {
const wanted = expected.get(file.path);
if (!wanted || file.digest !== wanted.digest || file.bytes !== wanted.bytes) throw new Error(`Managed generation file changed or has unexpected digest: ${file.path}`);
}
if (observed.directories.some(name => !allowedDirectories.has(name))) throw new Error('Managed generation contains an extra directory');
if (!partial && (observed.files.length !== expected.size || observed.directories.length !== allowedDirectories.size)) throw new Error('Managed generation integrity is incomplete');
return observed;
}
function loadGeneration(root, digest) {
const directory = generationPath(root, digest);
const carrier = checkCarrier(io.readJson(path.join(directory, 'carrier.json')), digest);
verifyGeneration(directory, carrier);
return carrier;
}
function readState(root) {
if (!exists(path.join(root, 'state.json'))) return null;
const state = io.readJson(path.join(root, 'state.json'));
if (state.schemaVersion !== 'ecc.context-store-state.v1' || !Number.isSafeInteger(state.revision)
|| state.revision < 1 || !DIGEST.test(state.receiptDigest)) throw new Error('Invalid managed state');
const receipt = io.readJson(path.join(root, 'receipts', `${state.receiptDigest}.json`));
if (digestObject(receipt) !== state.receiptDigest || receipt.destinationDigest !== digestObject({ root })
|| !equal(state, stateFor(receipt))) throw new Error('Managed receipt and state integrity mismatch');
checkSelection(receipt.selection, loadGeneration(root, state.generationDigest));
return state;
}
function selectionFor(carrier, options) {
return { profileId: carrier.profileId, target: carrier.target, selectionMode: carrier.selectionMode,
include: [...(options.include || [])].sort(), exclude: [...(options.exclude || [])].sort() };
}
function checkSelection(selection, carrier) {
if (!selection || selection.profileId !== carrier.profileId || selection.target !== carrier.target
|| selection.selectionMode !== carrier.selectionMode || !Array.isArray(selection.include)
|| selection.include.some(id => !carrier.selectedIds.includes(id))
|| !equal(selection.exclude, carrier.excludedIds)) throw new Error('Managed selection does not match its carrier');
}
function stateFor(receipt) {
return { schemaVersion: 'ecc.context-store-state.v1', revision: receipt.revision,
generationDigest: receipt.generationDigest, previousGenerationDigest: receipt.previousGenerationDigest,
selection: receipt.selection,
receiptDigest: digestObject(receipt) };
}
function result(root, state, pending = false) {
const carrier = state ? loadGeneration(root, state.generationDigest) : null;
return { schemaVersion: 'ecc.context-store-status.v1', status: pending ? 'recovery-required' : state ? 'configured' : 'unconfigured',
stateRoot: root, revision: state?.revision || 0, configured: Boolean(state), active: false,
activation: 'unobserved', recoveryRequired: pending,
profileId: carrier?.profileId || null, target: carrier?.target || null, selectionMode: carrier?.selectionMode || null,
include: state?.selection.include || [], exclude: state?.selection.exclude || [],
carrierDigest: carrier?.carrierDigest || null, selectedIds: carrier?.selectedIds || [],
generationRoot: state ? path.join(generationPath(root, state.generationDigest), 'payload') : null,
receiptDigest: state?.receiptDigest || null };
}
function getStoreStatus(options) {
const root = rootFor(options);
if (!ownership(root)) return result(root, null);
return result(root, readState(root), exists(path.join(root, 'pending.json')) || exists(path.join(root, '.lock')));
}
function selectedCarrier(options) {
const carrierOptions = Object.fromEntries(CARRIER_KEYS.filter(key => Object.hasOwn(options, key)).map(key => [key, options[key]]));
const carrier = planContextCarrier(carrierOptions);
if (carrier.status !== 'planned') throw new Error('Unsupported carrier target cannot be materialized');
if (options.expectedCarrierDigest !== undefined && options.expectedCarrierDigest !== carrier.carrierDigest) throw new Error('Carrier digest changed since preview');
return { carrier, carrierOptions };
}
function revisionCheck(options, state) {
if (options.expectedRevision !== undefined && options.expectedRevision !== (state?.revision || 0)) throw new Error('Managed state revision changed since preview');
}
function previewStore(options) {
const root = rootFor(options);
const { carrier } = selectedCarrier(options);
const state = ownership(root) ? readState(root) : null;
revisionCheck(options, state);
return { ...result(root, state, exists(path.join(root, 'pending.json'))), status: 'proposed',
carrierDigest: carrier.carrierDigest, proposedProfileId: carrier.profileId,
proposedSelectedIds: carrier.selectedIds, proposedGenerationRoot: path.join(generationPath(root, carrier.carrierDigest), 'payload') };
}
function withLock(root, recover, run) {
const lockPath = path.join(root, '.lock');
if (exists(lockPath)) {
const lock = io.readJson(lockPath);
if (!recover || lock.hostname !== os.hostname() || !Number.isSafeInteger(lock.pid) || lock.pid < 1) throw new Error('Managed store lock requires recovery');
try { process.kill(lock.pid, 0); throw new Error('Managed store lock is held by a live process'); }
catch (error) { if (error.code !== 'ESRCH') throw error; }
if (!equal(io.readJson(lockPath), lock)) throw new Error('Managed store lock changed');
fs.unlinkSync(lockPath);
}
const lock = { pid: process.pid, hostname: os.hostname(), nonce: crypto.randomUUID() };
io.writeExclusive(lockPath, io.jsonBytes(lock));
try { return run(); }
finally {
if (equal(io.readJson(lockPath), lock)) { fs.unlinkSync(lockPath); io.syncDirectory(root); }
}
}
function checkpoint(options, name, detail = {}) { if (options.onCheckpoint) options.onCheckpoint(name, detail); }
function publishGeneration(root, pending, options, carrierOptions) {
const final = generationPath(root, pending.carrier.carrierDigest);
if (exists(final)) { loadGeneration(root, pending.carrier.carrierDigest); return; }
const staging = path.join(root, 'generations', `stage-${pending.transactionDigest}`);
io.mkdir(staging); io.mkdir(path.join(staging, 'payload'));
const reader = createSourceReader(options.repoRoot);
for (const file of pending.carrier.files) {
const resource = file.kind === 'copy' ? reader.read(file.sourcePath) : { content: Buffer.from(file.content, 'utf8') };
if (io.hash(resource.content) !== file.digest || resource.content.length !== file.bytes) throw new Error('Canonical source digest changed during materialization');
const relative = `payload/${file.destinationPath}`;
io.ensureParents(staging, relative);
const destination = path.join(staging, relative);
io.writeExclusive(destination, resource.content);
checkpoint(options, 'file-written', { path: destination });
}
if (!equal(planContextCarrier(carrierOptions), pending.carrier)) throw new Error('Canonical source changed during materialization');
io.writeExclusive(path.join(staging, 'carrier.json'), io.jsonBytes(pending.carrier));
verifyGeneration(staging, pending.carrier);
io.inspect(final, true);
if (exists(final)) throw new Error('Generation appeared during materialization');
fs.renameSync(staging, final); io.syncDirectory(path.dirname(final));
}
function publishReceipt(root, receipt) {
const file = path.join(root, 'receipts', `${digestObject(receipt)}.json`);
if (exists(file)) {
if (!equal(io.readJson(file), receipt)) throw new Error('Managed immutable receipt changed');
} else io.writeExclusive(file, io.jsonBytes(receipt));
}
function transaction(root, before, carrier, operation, options, carrierOptions) {
const receipt = { schemaVersion: 'ecc.context-store-receipt.v1', destinationDigest: digestObject({ root }),
operation, revision: (before?.revision || 0) + 1, generationDigest: carrier.carrierDigest,
previousGenerationDigest: before?.generationDigest || null, previousReceiptDigest: before?.receiptDigest || null,
selection: selectionFor(carrier, carrierOptions) };
const body = { schemaVersion: 'ecc.context-store-transaction.v1', before, after: stateFor(receipt), receipt, carrier };
const pending = { ...body, transactionDigest: digestObject(body) };
io.atomicJson(path.join(root, 'pending.json'), pending); checkpoint(options, 'prepared');
publishGeneration(root, pending, options, carrierOptions); checkpoint(options, 'generation-published');
publishReceipt(root, receipt); checkpoint(options, 'receipt-published');
if (!equal(readState(root), before)) throw new Error('Managed state changed during transaction');
loadGeneration(root, carrier.carrierDigest);
io.atomicJson(path.join(root, 'state.json'), pending.after); checkpoint(options, 'state-published');
fs.unlinkSync(path.join(root, 'pending.json')); io.syncDirectory(root);
return result(root, readState(root));
}
function applyStore(options) {
const root = rootFor(options);
const { carrier, carrierOptions } = selectedCarrier(options);
if (ownership(root)) { revisionCheck(options, readState(root)); }
else revisionCheck(options, null);
ownership(root, true);
return withLock(root, false, () => {
if (exists(path.join(root, 'pending.json'))) throw new Error('Managed transaction requires recovery');
const before = readState(root); revisionCheck(options, before);
if (!equal(planContextCarrier(carrierOptions), carrier)) throw new Error('Canonical source digest changed before apply');
if (before?.generationDigest === carrier.carrierDigest
&& equal(before.selection, selectionFor(carrier, carrierOptions))) return result(root, before);
io.mkdir(path.join(root, 'generations')); io.mkdir(path.join(root, 'receipts'));
return transaction(root, before, carrier, 'apply', options, carrierOptions);
});
}
function rollbackStore(options) {
const root = rootFor(options);
if (!ownership(root)) throw new Error('Managed store has no previous generation');
return withLock(root, false, () => {
if (exists(path.join(root, 'pending.json'))) throw new Error('Managed transaction requires recovery');
const before = readState(root); revisionCheck(options, before);
if (!before?.previousGenerationDigest) throw new Error('Managed store has no previous generation');
const carrier = loadGeneration(root, before.previousGenerationDigest);
const receipt = io.readJson(path.join(root, 'receipts', `${before.receiptDigest}.json`));
if (!DIGEST.test(receipt.previousReceiptDigest)) throw new Error('Previous receipt digest is invalid');
const previous = io.readJson(path.join(root, 'receipts', `${receipt.previousReceiptDigest}.json`));
if (digestObject(previous) !== receipt.previousReceiptDigest || previous.generationDigest !== carrier.carrierDigest) throw new Error('Previous receipt integrity mismatch');
return transaction(root, before, carrier, 'rollback', options, previous.selection);
});
}
function readPending(root) {
const pending = io.readJson(path.join(root, 'pending.json'));
const { transactionDigest, ...body } = pending;
if (!DIGEST.test(transactionDigest) || digestObject(body) !== transactionDigest
|| pending.schemaVersion !== 'ecc.context-store-transaction.v1'
|| pending.receipt.destinationDigest !== digestObject({ root })
|| !equal(pending.after, stateFor(pending.receipt))
|| pending.after.revision !== (pending.before?.revision || 0) + 1
|| pending.receipt.previousGenerationDigest !== (pending.before?.generationDigest || null)
|| pending.receipt.previousReceiptDigest !== (pending.before?.receiptDigest || null)) throw new Error('Pending transaction integrity mismatch');
checkCarrier(pending.carrier, pending.after.generationDigest);
checkSelection(pending.receipt.selection, pending.carrier);
return pending;
}
function recoverStore(options) {
const root = rootFor(options);
if (!ownership(root)) return result(root, null);
return withLock(root, true, () => {
const before = readState(root); revisionCheck(options, before);
if (!exists(path.join(root, 'pending.json'))) return result(root, before);
const pending = readPending(root);
if (!equal(before, pending.before) && !equal(before, pending.after)) throw new Error('State changed outside the pending transaction');
const final = generationPath(root, pending.after.generationDigest);
const staging = path.join(root, 'generations', `stage-${pending.transactionDigest}`);
if (exists(final)) {
loadGeneration(root, pending.after.generationDigest);
if (exists(staging)) throw new Error('Ambiguous pending generation requires inspection');
publishReceipt(root, pending.receipt);
io.atomicJson(path.join(root, 'state.json'), pending.after);
} else {
if (!equal(before, pending.before)) throw new Error('Committed generation is missing');
if (exists(staging)) io.removeTree(staging, verifyGeneration(staging, pending.carrier, true));
}
fs.unlinkSync(path.join(root, 'pending.json')); io.syncDirectory(root);
return result(root, readState(root));
});
}
module.exports = { applyStore, getStoreStatus, previewStore, recoverStore, rollbackStore };