99 lines
3.5 KiB
JavaScript
99 lines
3.5 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { fsyncDirectorySync } = require("../durability");
|
|
const { canonicalJson, sha256 } = require("../canonical-json");
|
|
const { transactionError } = require("./errors");
|
|
const { validateInstance } = require("../schema-validator");
|
|
|
|
function compareStrings(left, right) {
|
|
return left < right ? -1 : (left > right ? 1 : 0);
|
|
}
|
|
|
|
function publicEntries(entries) {
|
|
return entries.map(({ skillId, treeDigest, catalogIntegrity }) => ({ skillId, treeDigest, catalogIntegrity }))
|
|
.sort((left, right) => compareStrings(left.skillId, right.skillId));
|
|
}
|
|
|
|
function digestManagedEntries(entries) {
|
|
return sha256(canonicalJson({ schemaVersion: 1, entries: publicEntries(entries) }));
|
|
}
|
|
|
|
function buildManagedState({ target, catalog, entries, completedPlanDigests }) {
|
|
const normalizedEntries = entries.map((entry) => ({ ...entry }))
|
|
.sort((left, right) => compareStrings(left.skillId, right.skillId));
|
|
const state = {
|
|
schemaVersion: 1,
|
|
target: { ...target },
|
|
catalog: { ...catalog },
|
|
entries: normalizedEntries,
|
|
completedPlanDigests: [...new Set(completedPlanDigests)].sort(compareStrings).slice(-256),
|
|
stateDigest: digestManagedEntries(normalizedEntries),
|
|
};
|
|
return state;
|
|
}
|
|
|
|
function readManagedState(stateFile) {
|
|
if (!fs.existsSync(stateFile)) return null;
|
|
const stat = fs.lstatSync(stateFile);
|
|
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024) {
|
|
throw transactionError("AAS_TRANSACTION_STATE_UNSAFE", "filesystem", {});
|
|
}
|
|
let state;
|
|
try {
|
|
state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
} catch (cause) {
|
|
throw transactionError("AAS_TRANSACTION_STATE_CORRUPT", "integrity", {}, cause);
|
|
}
|
|
if (!state || state.schemaVersion !== 1 || !Array.isArray(state.entries) || !Array.isArray(state.completedPlanDigests)) {
|
|
throw transactionError("AAS_TRANSACTION_STATE_CORRUPT", "integrity", {});
|
|
}
|
|
validateInstance("managed-state.schema.json", state, "AAS_TRANSACTION_STATE_SCHEMA_INVALID");
|
|
const ids = new Set();
|
|
for (const entry of state.entries) {
|
|
if (!entry || typeof entry.skillId !== "string" || ids.has(entry.skillId)
|
|
|| typeof entry.treeDigest !== "string" || typeof entry.catalogIntegrity !== "string"
|
|
|| typeof entry.installedByPlanDigest !== "string") {
|
|
throw transactionError("AAS_TRANSACTION_STATE_CORRUPT", "integrity", {});
|
|
}
|
|
ids.add(entry.skillId);
|
|
}
|
|
if (state.stateDigest !== digestManagedEntries(state.entries)) {
|
|
throw transactionError("AAS_TRANSACTION_STATE_DIGEST_MISMATCH", "integrity", {});
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function fsyncDirectory(directory) {
|
|
fsyncDirectorySync(directory);
|
|
}
|
|
|
|
function writeFileDurable(filePath, bytes, mode = 0o600) {
|
|
const descriptor = fs.openSync(filePath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, mode);
|
|
try {
|
|
fs.writeFileSync(descriptor, bytes);
|
|
fs.fsyncSync(descriptor);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function atomicWriteManagedState(stateFile, state, transactionRoot) {
|
|
const temporary = path.join(transactionRoot, "managed-state.next.json");
|
|
if (fs.existsSync(temporary)) fs.rmSync(temporary, { force: true });
|
|
writeFileDurable(temporary, `${canonicalJson(state)}\n`);
|
|
fs.renameSync(temporary, stateFile);
|
|
fsyncDirectory(path.dirname(stateFile));
|
|
}
|
|
|
|
module.exports = {
|
|
atomicWriteManagedState,
|
|
buildManagedState,
|
|
digestManagedEntries,
|
|
fsyncDirectory,
|
|
publicEntries,
|
|
readManagedState,
|
|
writeFileDurable,
|
|
};
|