311 lines
13 KiB
JavaScript
311 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { transactionError } = require("./errors");
|
|
const { fsyncDirectory } = require("./state");
|
|
|
|
function isContained(root, candidate) {
|
|
const relative = path.relative(root, candidate);
|
|
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function assertRegularDirectory(directory, code = "AAS_TRANSACTION_DIRECTORY_UNSAFE") {
|
|
const stat = fs.lstatSync(directory);
|
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
throw transactionError(code, "filesystem", {});
|
|
}
|
|
return stat;
|
|
}
|
|
|
|
function assertOwned(stat, code = "AAS_TRANSACTION_OWNERSHIP_UNSAFE") {
|
|
if (typeof process.getuid === "function" && typeof stat.uid === "number" && stat.uid !== process.getuid()) {
|
|
throw transactionError(code, "filesystem", {});
|
|
}
|
|
}
|
|
|
|
function assertNoSymlinkChain(root, candidate) {
|
|
if (!isContained(root, candidate)) {
|
|
throw transactionError("AAS_TRANSACTION_PATH_OUTSIDE_TARGET", "filesystem", {});
|
|
}
|
|
const relative = path.relative(root, candidate);
|
|
let cursor = root;
|
|
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
cursor = path.join(cursor, part);
|
|
if (!fs.existsSync(cursor)) break;
|
|
const stat = fs.lstatSync(cursor);
|
|
if (stat.isSymbolicLink()) {
|
|
throw transactionError("AAS_TRANSACTION_SYMLINK_PATH", "filesystem", {});
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectLayout(adapter, target) {
|
|
if (!adapter || typeof adapter.resolveTransactionLayout !== "function") {
|
|
throw transactionError("AAS_TRANSACTION_ADAPTER_INVALID", "invalidInput", {});
|
|
}
|
|
const layout = adapter.resolveTransactionLayout(target);
|
|
const keys = ["root", "skillsDirectory", "stateFile", "transactionDirectory"];
|
|
if (!layout || keys.some((key) => typeof layout[key] !== "string" || !path.isAbsolute(layout[key]))) {
|
|
throw transactionError("AAS_TRANSACTION_LAYOUT_INVALID", "invalidInput", {});
|
|
}
|
|
const lexicalRoot = path.resolve(layout.root);
|
|
const root = fs.realpathSync(layout.root);
|
|
const resolved = Object.fromEntries(keys.map((key) => [
|
|
key,
|
|
key === "root" ? root : path.resolve(root, path.relative(lexicalRoot, path.resolve(layout[key]))),
|
|
]));
|
|
const rootStat = assertRegularDirectory(root);
|
|
assertOwned(rootStat);
|
|
for (const key of ["skillsDirectory", "stateFile", "transactionDirectory"]) {
|
|
if (!isContained(root, resolved[key])) {
|
|
throw transactionError("AAS_TRANSACTION_PATH_OUTSIDE_TARGET", "filesystem", { logicalId: key });
|
|
}
|
|
assertNoSymlinkChain(root, resolved[key]);
|
|
}
|
|
const requiredDirectories = [...new Set([
|
|
...directoryClosure(root, resolved.skillsDirectory),
|
|
...directoryClosure(root, resolved.transactionDirectory),
|
|
])].sort((left, right) => left.split(path.sep).length - right.split(path.sep).length || (left < right ? -1 : 1));
|
|
const missingDirectories = [];
|
|
for (const directory of requiredDirectories) {
|
|
if (!fs.existsSync(directory)) {
|
|
missingDirectories.push(directory);
|
|
continue;
|
|
}
|
|
const stat = assertRegularDirectory(directory);
|
|
assertOwned(stat);
|
|
if (stat.dev !== rootStat.dev) throw transactionError("AAS_TRANSACTION_CROSS_FILESYSTEM", "filesystem", {});
|
|
}
|
|
if (fs.existsSync(resolved.stateFile)) {
|
|
const stateStat = fs.lstatSync(resolved.stateFile);
|
|
if (stateStat.isSymbolicLink() || !stateStat.isFile() || stateStat.nlink !== 1 || stateStat.dev !== rootStat.dev) {
|
|
throw transactionError("AAS_TRANSACTION_STATE_UNSAFE", "filesystem", {});
|
|
}
|
|
assertOwned(stateStat);
|
|
}
|
|
return Object.freeze({
|
|
...resolved,
|
|
device: rootStat.dev,
|
|
layoutDirectories: Object.freeze(requiredDirectories),
|
|
missingDirectories: Object.freeze(missingDirectories),
|
|
});
|
|
}
|
|
|
|
function directoryClosure(root, leaf) {
|
|
const directories = [];
|
|
let cursor = leaf;
|
|
while (cursor !== root) {
|
|
if (!isContained(root, cursor)) throw transactionError("AAS_TRANSACTION_PATH_OUTSIDE_TARGET", "filesystem", {});
|
|
directories.push(cursor);
|
|
const parent = path.dirname(cursor);
|
|
if (parent === cursor) throw transactionError("AAS_TRANSACTION_PATH_OUTSIDE_TARGET", "filesystem", {});
|
|
cursor = parent;
|
|
}
|
|
return directories.reverse();
|
|
}
|
|
|
|
function resolveLayout(adapter, target) {
|
|
const inspected = inspectLayout(adapter, target);
|
|
if (inspected.missingDirectories.length) {
|
|
throw transactionError("AAS_TRANSACTION_LAYOUT_MISSING", "filesystem", {
|
|
logicalIds: inspected.missingDirectories.map((directory) => path.relative(inspected.root, directory).split(path.sep).join("/")),
|
|
});
|
|
}
|
|
return inspected;
|
|
}
|
|
|
|
function ownershipMarker(options) {
|
|
if (!options || typeof options.markerName !== "string" || !/^\.aas-layout-recovery-[a-f0-9]{32,64}$/.test(options.markerName)
|
|
|| typeof options.markerToken !== "string" || !/^[a-f0-9]{48}$/.test(options.markerToken)) {
|
|
throw transactionError("AAS_TRANSACTION_LAYOUT_OWNERSHIP_INVALID", "integrity", {});
|
|
}
|
|
return { markerName: options.markerName, markerToken: options.markerToken };
|
|
}
|
|
|
|
function writeMarker(directory, markerName, markerToken) {
|
|
const marker = path.join(directory, markerName);
|
|
const descriptor = fs.openSync(marker, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
try {
|
|
fs.writeFileSync(descriptor, `${markerToken}\n`);
|
|
fs.fsyncSync(descriptor);
|
|
} finally { fs.closeSync(descriptor); }
|
|
fsyncDirectory(directory);
|
|
}
|
|
|
|
function markerOwned(directory, markerName, markerToken) {
|
|
const marker = path.join(directory, markerName);
|
|
if (!fs.existsSync(marker)) return false;
|
|
const stat = fs.lstatSync(marker);
|
|
return !stat.isSymbolicLink() && stat.isFile() && stat.nlink === 1 && fs.readFileSync(marker, "utf8") === `${markerToken}\n`;
|
|
}
|
|
|
|
function materializeLayout(inspected, options) {
|
|
const { markerName, markerToken } = ownershipMarker(options);
|
|
const created = Array.isArray(options.createdDirectories) ? options.createdDirectories : [];
|
|
try {
|
|
for (const directory of inspected.missingDirectories) {
|
|
const parent = path.dirname(directory);
|
|
const parentStat = assertRegularDirectory(parent);
|
|
assertOwned(parentStat);
|
|
if (parentStat.dev !== inspected.device) throw transactionError("AAS_TRANSACTION_CROSS_FILESYSTEM", "filesystem", {});
|
|
assertNoSymlinkChain(inspected.root, directory);
|
|
const stage = path.join(parent, `.aas-layout-stage-${markerToken}-${path.basename(directory)}`);
|
|
try {
|
|
fs.mkdirSync(stage, { mode: 0o700 });
|
|
writeMarker(stage, markerName, markerToken);
|
|
if (fs.existsSync(directory)) throw transactionError("AAS_TRANSACTION_LAYOUT_CREATE_RACE", "conflict", {});
|
|
fs.renameSync(stage, directory);
|
|
// Publish ownership to the caller before the directory durability
|
|
// barrier. If that barrier fails, cleanup/recovery still knows the
|
|
// exact marker-bound directory that became visible.
|
|
created.push(directory);
|
|
if (typeof options.onBoundary === "function") {
|
|
options.onBoundary("layoutDirectoryPublished", {
|
|
logicalId: path.relative(inspected.root, directory).split(path.sep).join("/"),
|
|
});
|
|
}
|
|
fsyncDirectory(parent);
|
|
} catch (cause) {
|
|
try { fs.rmSync(stage, { recursive: true, force: true }); } catch {}
|
|
throw transactionError("AAS_TRANSACTION_LAYOUT_CREATE_FAILED", "filesystem", {}, cause);
|
|
}
|
|
const stat = assertRegularDirectory(directory);
|
|
assertOwned(stat);
|
|
if (stat.dev !== inspected.device) throw transactionError("AAS_TRANSACTION_CROSS_FILESYSTEM", "filesystem", {});
|
|
fsyncDirectory(parent);
|
|
}
|
|
return created;
|
|
} catch (error) {
|
|
cleanupMaterializedLayout(inspected, created, { markerName, markerToken });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function cleanupMaterializedLayout(inspected, directories, options) {
|
|
const { markerName, markerToken } = ownershipMarker(options);
|
|
for (const directory of [...directories].reverse()) {
|
|
if (!isContained(inspected.root, directory)) continue;
|
|
const parent = path.dirname(directory);
|
|
const stage = path.join(parent, `.aas-layout-stage-${markerToken}-${path.basename(directory)}`);
|
|
const tombstone = path.join(parent, `.aas-layout-remove-${markerToken}-${path.basename(directory)}`);
|
|
// A hard kill may land after the marker-bound staging directory is made
|
|
// durable but before its rename publishes the final layout directory.
|
|
// Remove only the exact token-owned, marker-only stage derived from the
|
|
// allowlisted layout; anything else remains for fail-closed inspection.
|
|
if (fs.existsSync(stage)) {
|
|
const stageStat = fs.lstatSync(stage);
|
|
if (!stageStat.isSymbolicLink() && stageStat.isDirectory() && stageStat.dev === inspected.device) {
|
|
try {
|
|
assertOwned(stageStat);
|
|
if (markerOwned(stage, markerName, markerToken)
|
|
&& !fs.readdirSync(stage).some((name) => name !== markerName)) {
|
|
fs.rmSync(stage, { recursive: true });
|
|
fsyncDirectory(parent);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
// A prior cleanup may have published the exact token-bound tombstone and
|
|
// then failed its parent fsync. Reconcile that state before inspecting the
|
|
// original path so cleanup is retryable at every durability boundary.
|
|
if (fs.existsSync(tombstone)) {
|
|
const tombstoneStat = fs.lstatSync(tombstone);
|
|
if (tombstoneStat.isSymbolicLink() || !tombstoneStat.isDirectory() || tombstoneStat.dev !== inspected.device) continue;
|
|
try { assertOwned(tombstoneStat); } catch { continue; }
|
|
if (!markerOwned(tombstone, markerName, markerToken)) continue;
|
|
if (fs.readdirSync(tombstone).some((name) => name !== markerName)) continue;
|
|
fs.rmSync(tombstone, { recursive: true });
|
|
fsyncDirectory(parent);
|
|
continue;
|
|
}
|
|
if (!fs.existsSync(directory)) continue;
|
|
const stat = fs.lstatSync(directory);
|
|
if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== inspected.device) continue;
|
|
try { assertOwned(stat); } catch { continue; }
|
|
if (!markerOwned(directory, markerName, markerToken)) continue;
|
|
if (fs.readdirSync(directory).some((name) => name !== markerName)) continue;
|
|
fs.renameSync(directory, tombstone);
|
|
if (typeof options.onBoundary === "function") {
|
|
options.onBoundary("layoutDirectoryTombstoned", {
|
|
logicalId: path.relative(inspected.root, directory).split(path.sep).join("/"),
|
|
});
|
|
}
|
|
fsyncDirectory(parent);
|
|
fs.rmSync(tombstone, { recursive: true });
|
|
fsyncDirectory(parent);
|
|
}
|
|
}
|
|
|
|
function remainingMaterializedLayoutPaths(inspected, directories, options) {
|
|
const { markerToken } = ownershipMarker(options);
|
|
const remaining = [];
|
|
for (const directory of directories) {
|
|
if (!isContained(inspected.root, directory)) continue;
|
|
const parent = path.dirname(directory);
|
|
const candidates = [
|
|
directory,
|
|
path.join(parent, `.aas-layout-stage-${markerToken}-${path.basename(directory)}`),
|
|
path.join(parent, `.aas-layout-remove-${markerToken}-${path.basename(directory)}`),
|
|
];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
fs.lstatSync(candidate);
|
|
remaining.push(path.relative(inspected.root, candidate).split(path.sep).join("/"));
|
|
} catch (error) {
|
|
if (!["ENOENT", "ENOTDIR"].includes(error?.code)) throw error;
|
|
}
|
|
}
|
|
}
|
|
return [...new Set(remaining)].sort();
|
|
}
|
|
|
|
function clearMaterializedMarkers(inspected, directories, options) {
|
|
const { markerName, markerToken } = ownershipMarker(options);
|
|
for (const directory of [...directories].reverse()) {
|
|
if (!fs.existsSync(directory) || !markerOwned(directory, markerName, markerToken)) continue;
|
|
fs.unlinkSync(path.join(directory, markerName));
|
|
fsyncDirectory(directory);
|
|
}
|
|
}
|
|
|
|
function resolveDestination(layout, skillId) {
|
|
const destination = path.resolve(layout.skillsDirectory, ...skillId.split("/"));
|
|
if (!isContained(layout.skillsDirectory, destination)) {
|
|
throw transactionError("AAS_TRANSACTION_PATH_OUTSIDE_TARGET", "filesystem", { logicalId: skillId });
|
|
}
|
|
assertNoSymlinkChain(layout.root, destination);
|
|
return destination;
|
|
}
|
|
|
|
function resolveSource(adapter, operation, layout, target) {
|
|
if (typeof adapter.resolveSourceTree !== "function") {
|
|
throw transactionError("AAS_TRANSACTION_ADAPTER_INVALID", "invalidInput", {});
|
|
}
|
|
const source = adapter.resolveSourceTree({ skillId: operation.skillId, operation, target });
|
|
if (typeof source !== "string" || !path.isAbsolute(source)) {
|
|
throw transactionError("AAS_TRANSACTION_SOURCE_INVALID", "invalidInput", { skillId: operation.skillId });
|
|
}
|
|
const real = fs.realpathSync(source);
|
|
assertRegularDirectory(real, "AAS_TRANSACTION_SOURCE_UNSAFE");
|
|
if (typeof adapter.validateSourceTree === "function" && adapter.validateSourceTree(real, operation) !== true) {
|
|
throw transactionError("AAS_TRANSACTION_SOURCE_REJECTED", "integrity", { skillId: operation.skillId });
|
|
}
|
|
return real;
|
|
}
|
|
|
|
module.exports = {
|
|
assertNoSymlinkChain,
|
|
assertOwned,
|
|
assertRegularDirectory,
|
|
cleanupMaterializedLayout,
|
|
remainingMaterializedLayoutPaths,
|
|
clearMaterializedMarkers,
|
|
inspectLayout,
|
|
isContained,
|
|
materializeLayout,
|
|
resolveDestination,
|
|
resolveLayout,
|
|
resolveSource,
|
|
};
|