220 lines
8.0 KiB
JavaScript
220 lines
8.0 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const crypto = require("node:crypto");
|
|
const { canonicalJson, canonicalize, sha256 } = require("../canonical-json");
|
|
const { fsyncDirectory, writeFileDurable } = require("./state");
|
|
const { transactionError } = require("./errors");
|
|
const { validateInstance } = require("../schema-validator");
|
|
|
|
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
|
|
|
function recoveryIdFor(planDigest, identityDigest) {
|
|
return `recovery-${sha256(`${planDigest}:${identityDigest}`).slice(7, 55)}`;
|
|
}
|
|
|
|
function journalPath(layoutRoot, recoveryId) {
|
|
if (!/^recovery-[a-f0-9]{32,64}$/.test(recoveryId)) {
|
|
throw transactionError("AAS_TRANSACTION_JOURNAL_ID_INVALID", "invalidInput", {});
|
|
}
|
|
return path.join(layoutRoot, `.aas-transaction-${recoveryId}.wal`);
|
|
}
|
|
|
|
function digestRecords(records) {
|
|
return sha256(canonicalJson(records));
|
|
}
|
|
|
|
function appendRecord(context, event, details = {}) {
|
|
if (context.tornTailDigest) {
|
|
throw transactionError("AAS_TRANSACTION_JOURNAL_TORN_TAIL", "recovery", {});
|
|
}
|
|
const previous = context.records.at(-1) || null;
|
|
const body = canonicalize({
|
|
schemaVersion: 1,
|
|
recoveryId: context.recoveryId,
|
|
sequence: context.records.length,
|
|
event,
|
|
planDigest: context.planDigest,
|
|
targetIdentityDigest: context.targetIdentityDigest,
|
|
details,
|
|
previousRecordDigest: previous ? previous.recordDigest : null,
|
|
});
|
|
const record = { ...body, recordDigest: sha256(canonicalJson(body)) };
|
|
const bytes = `${canonicalJson(record)}\n`;
|
|
const descriptor = fs.openSync(context.path, fs.constants.O_APPEND | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
try {
|
|
fs.writeFileSync(descriptor, bytes);
|
|
fs.fsyncSync(descriptor);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
context.records.push(record);
|
|
context.validBytes += Buffer.byteLength(bytes);
|
|
context.validBytesDigest = sha256(fs.readFileSync(context.path).subarray(0, context.validBytes));
|
|
context.digest = digestRecords(context.records);
|
|
return record;
|
|
}
|
|
|
|
function createJournal(layoutRoot, recoveryId, planDigest, targetIdentityDigest) {
|
|
const target = journalPath(layoutRoot, recoveryId);
|
|
const temporary = `${target}.pending-${crypto.randomBytes(16).toString("hex")}`;
|
|
if (fs.existsSync(target)) throw transactionError("AAS_TRANSACTION_RECOVERY_REQUIRED", "recovery", { recoveryId });
|
|
const context = {
|
|
recoveryId,
|
|
planDigest,
|
|
targetIdentityDigest,
|
|
path: temporary,
|
|
records: [],
|
|
validBytes: 0,
|
|
validBytesDigest: sha256(Buffer.alloc(0)),
|
|
tornTailDigest: null,
|
|
};
|
|
try {
|
|
writeFileDurable(temporary, "", 0o600);
|
|
appendRecord(context, "started", {});
|
|
fs.renameSync(temporary, target);
|
|
fsyncDirectory(layoutRoot);
|
|
context.path = target;
|
|
return context;
|
|
} catch (cause) {
|
|
try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch {}
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
function readJournalFile(target) {
|
|
const stat = fs.lstatSync(target);
|
|
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_JOURNAL_BYTES) {
|
|
throw transactionError("AAS_TRANSACTION_JOURNAL_UNSAFE", "filesystem", {});
|
|
}
|
|
const bytes = fs.readFileSync(target);
|
|
const finalNewline = bytes.length > 0 && bytes.at(-1) === 0x0a;
|
|
const lastNewline = bytes.lastIndexOf(0x0a);
|
|
const validBytes = finalNewline ? bytes.length : (lastNewline < 0 ? 0 : lastNewline + 1);
|
|
const validText = bytes.subarray(0, validBytes).toString("utf8");
|
|
let records;
|
|
try {
|
|
records = validText.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
} catch (cause) {
|
|
throw transactionError("AAS_TRANSACTION_JOURNAL_CORRUPT", "integrity", {}, cause);
|
|
}
|
|
if (!records.length) throw transactionError("AAS_TRANSACTION_JOURNAL_CORRUPT", "integrity", {});
|
|
for (let index = 0; index < records.length; index += 1) {
|
|
const record = records[index];
|
|
validateInstance("journal.schema.json", record, "AAS_TRANSACTION_JOURNAL_SCHEMA_INVALID");
|
|
const { recordDigest, ...body } = record || {};
|
|
if (record.sequence !== index || record.previousRecordDigest !== (index ? records[index - 1].recordDigest : null)
|
|
|| recordDigest !== sha256(canonicalJson(body)) || record.recoveryId !== records[0].recoveryId
|
|
|| record.planDigest !== records[0].planDigest || record.targetIdentityDigest !== records[0].targetIdentityDigest) {
|
|
throw transactionError("AAS_TRANSACTION_JOURNAL_CORRUPT", "integrity", {});
|
|
}
|
|
}
|
|
const tornTail = bytes.subarray(validBytes);
|
|
return {
|
|
recoveryId: records[0].recoveryId,
|
|
planDigest: records[0].planDigest,
|
|
targetIdentityDigest: records[0].targetIdentityDigest,
|
|
path: target,
|
|
records,
|
|
digest: digestRecords(records),
|
|
validBytes,
|
|
validBytesDigest: sha256(bytes.subarray(0, validBytes)),
|
|
tornTailDigest: tornTail.length ? sha256(tornTail) : null,
|
|
};
|
|
}
|
|
|
|
function readJournal(layoutRoot, recoveryId) {
|
|
return readJournalFile(journalPath(layoutRoot, recoveryId));
|
|
}
|
|
|
|
function journalCheckpoint(journal) {
|
|
return canonicalize({
|
|
recordCount: journal.records.length,
|
|
recordsDigest: journal.digest,
|
|
validBytes: journal.validBytes,
|
|
validBytesDigest: journal.validBytesDigest,
|
|
tornTailDigest: journal.tornTailDigest,
|
|
});
|
|
}
|
|
|
|
function verifyCheckpoint(journal, checkpoint) {
|
|
if (!checkpoint || !Number.isSafeInteger(checkpoint.recordCount) || checkpoint.recordCount < 1
|
|
|| !Number.isSafeInteger(checkpoint.validBytes) || checkpoint.validBytes < 1
|
|
|| journal.records.length < checkpoint.recordCount || journal.validBytes < checkpoint.validBytes) {
|
|
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
|
|
}
|
|
const prefix = journal.records.slice(0, checkpoint.recordCount);
|
|
const raw = fs.readFileSync(journal.path).subarray(0, checkpoint.validBytes);
|
|
if (digestRecords(prefix) !== checkpoint.recordsDigest || sha256(raw) !== checkpoint.validBytesDigest) {
|
|
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
|
|
}
|
|
if (journal.records.length === checkpoint.recordCount && journal.tornTailDigest !== (checkpoint.tornTailDigest || null)) {
|
|
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function repairTornTail(journal, checkpoint) {
|
|
verifyCheckpoint(journal, checkpoint);
|
|
if (!journal.tornTailDigest) return journal;
|
|
if (journal.records.length !== checkpoint.recordCount) {
|
|
throw transactionError("AAS_RECOVERY_JOURNAL_DRIFT", "drift", {});
|
|
}
|
|
const descriptor = fs.openSync(journal.path, fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
try {
|
|
fs.ftruncateSync(descriptor, checkpoint.validBytes);
|
|
fs.fsyncSync(descriptor);
|
|
} finally { fs.closeSync(descriptor); }
|
|
fsyncDirectory(path.dirname(journal.path));
|
|
return readJournalFile(journal.path);
|
|
}
|
|
|
|
function truncateTornTail(journal) {
|
|
if (!journal.tornTailDigest) return journal;
|
|
const descriptor = fs.openSync(journal.path, fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
try {
|
|
fs.ftruncateSync(descriptor, journal.validBytes);
|
|
fs.fsyncSync(descriptor);
|
|
} finally { fs.closeSync(descriptor); }
|
|
fsyncDirectory(path.dirname(journal.path));
|
|
return readJournalFile(journal.path);
|
|
}
|
|
|
|
function removeJournal(journal) {
|
|
const observed = readJournalFile(journal.path);
|
|
if (observed.recoveryId !== journal.recoveryId || observed.planDigest !== journal.planDigest) {
|
|
throw transactionError("AAS_TRANSACTION_JOURNAL_DRIFT", "drift", {});
|
|
}
|
|
fs.unlinkSync(journal.path);
|
|
fsyncDirectory(path.dirname(journal.path));
|
|
}
|
|
|
|
function listJournalIds(layoutRoot) {
|
|
return fs.readdirSync(layoutRoot)
|
|
.map((name) => /^\.aas-transaction-(recovery-[a-f0-9]{32,64})\.wal$/.exec(name))
|
|
.filter(Boolean)
|
|
.map((match) => match[1])
|
|
.sort();
|
|
}
|
|
|
|
function journalEvents(journal) {
|
|
return new Set(journal.records.map((record) => record.event));
|
|
}
|
|
|
|
module.exports = {
|
|
appendRecord,
|
|
createJournal,
|
|
journalCheckpoint,
|
|
journalEvents,
|
|
journalPath,
|
|
listJournalIds,
|
|
readJournal,
|
|
readJournalFile,
|
|
recoveryIdFor,
|
|
removeJournal,
|
|
repairTornTail,
|
|
truncateTornTail,
|
|
verifyCheckpoint,
|
|
};
|