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

162 lines
6.9 KiB
JavaScript

'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { stableStringify, validateRelativePath } = require('./context-profile-support');
const MAX_BYTES = 16 * 1024 * 1024;
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode;
function pathSegments(absolute, pathApi = path) {
const root = pathApi.parse(absolute).root;
return { root, parts: absolute.slice(root.length).split(pathApi.sep).filter(Boolean) };
}
function inspect(absolute, allowMissing = false) {
const { root, parts } = pathSegments(absolute);
let current = root;
const chain = [];
for (const [index, part] of parts.entries()) {
current = path.join(current, part);
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
if (!stat && allowMissing && index === parts.length - 1) return { chain, stat: null };
if (!stat) throw new Error(`Managed parent directory is missing: ${current}`);
if (stat.isSymbolicLink()) throw new Error(`Symbolic link in managed path: ${current}`);
if (index < parts.length - 1 && !stat.isDirectory()) throw new Error('Managed parent is not a directory');
chain.push({ path: current, stat });
}
return { chain, stat: chain.at(-1)?.stat || fs.lstatSync(current) };
}
function recheck(chain) {
for (const item of chain) {
const now = fs.lstatSync(item.path);
if (now.isSymbolicLink() || !same(item.stat, now)) throw new Error('Managed path identity changed');
}
}
function read(file) {
const before = inspect(file);
if (!before.stat.isFile() || before.stat.nlink !== 1 || before.stat.size > MAX_BYTES) {
throw new Error('Managed file integrity requires a bounded regular file with one link');
}
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
try {
const opened = fs.fstatSync(fd);
recheck(before.chain);
if (!same(before.stat, opened) || opened.nlink !== 1 || opened.size !== before.stat.size
|| opened.mtimeMs !== before.stat.mtimeMs || opened.ctimeMs !== before.stat.ctimeMs) throw new Error('Managed file identity changed');
const result = Buffer.alloc(opened.size + 1);
let count = 0;
while (count < result.length) {
const n = fs.readSync(fd, result, count, result.length - count, null);
if (!n) break;
count += n;
}
const after = fs.fstatSync(fd);
recheck(before.chain);
if (count !== opened.size || opened.mtimeMs !== after.mtimeMs || opened.ctimeMs !== after.ctimeMs) throw new Error('Managed file changed during read');
return result.subarray(0, count);
} finally { fs.closeSync(fd); }
}
function syncDirectory(directory) {
if (process.platform === 'win32') return;
const fd = fs.openSync(directory, fs.constants.O_RDONLY);
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
}
function writeExclusive(file, bytes) {
const before = inspect(file, true);
if (before.stat) throw new Error(`Managed file already exists: ${file}`);
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
try { recheck(before.chain); fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); }
finally { fs.closeSync(fd); }
recheck(before.chain);
syncDirectory(path.dirname(file));
}
function jsonBytes(value) { return Buffer.from(`${stableStringify(value)}\n`); }
function readJson(file) { return JSON.parse(read(file).toString('utf8')); }
function atomicJson(file, value) {
const before = inspect(file, true);
const previous = before.stat ? read(file) : null;
const temporary = path.join(path.dirname(file), `.atomic-${crypto.randomUUID()}`);
writeExclusive(temporary, jsonBytes(value));
try {
recheck(before.chain);
if (previous && !previous.equals(read(file))) throw new Error('Managed file changed before replacement');
if (!before.stat && fs.lstatSync(file, { throwIfNoEntry: false })) throw new Error('Managed destination appeared during write');
fs.renameSync(temporary, file);
syncDirectory(path.dirname(file));
} finally {
if (fs.lstatSync(temporary, { throwIfNoEntry: false })) fs.unlinkSync(temporary);
}
}
function mkdir(directory) {
const before = inspect(directory, true);
if (before.stat) {
if (!before.stat.isDirectory()) throw new Error('Managed path is not a directory');
return;
}
fs.mkdirSync(directory, { mode: 0o700 });
recheck(before.chain);
syncDirectory(path.dirname(directory));
}
function ensureParents(root, relative) {
validateRelativePath(relative);
const parts = relative.split('/');
for (let index = 1; index < parts.length; index++) mkdir(path.join(root, ...parts.slice(0, index)));
}
function inventory(root) {
const files = []; const directories = []; let total = 0; let entries = 0;
function visit(relative, depth) {
if (depth > 40) throw new Error('Managed tree depth limit exceeded');
const directory = path.join(root, relative);
const before = inspect(directory);
if (!before.stat.isDirectory()) throw new Error('Managed generation is not a directory');
const handle = fs.opendirSync(directory);
try {
for (let item = handle.readSync(); item !== null; item = handle.readSync()) {
if (++entries > 12000) throw new Error('Managed tree entry limit exceeded');
const name = relative ? `${relative}/${item.name}` : item.name;
validateRelativePath(name);
const stat = inspect(path.join(root, name)).stat;
if (stat.isDirectory()) { directories.push(name); visit(name, depth + 1); }
else {
const bytes = read(path.join(root, name));
total += bytes.length;
if (total > MAX_BYTES) throw new Error('Managed tree byte limit exceeded');
files.push({ path: name, digest: hash(bytes), bytes: bytes.length });
}
}
recheck(before.chain);
} finally { handle.closeSync(); }
}
visit('', 0);
return { files, directories };
}
// Remove only a previously verified private staging tree, never a user root.
function removeTree(root, expected) {
const observed = inventory(root);
if (stableStringify(observed) !== stableStringify(expected)) throw new Error('Managed staging tree changed before cleanup');
for (const file of observed.files) {
const absolute = path.join(root, file.path);
if (hash(read(absolute)) !== file.digest) throw new Error('Managed staging file changed before cleanup');
fs.unlinkSync(absolute);
}
for (const directory of [...observed.directories].sort((a, b) => b.length - a.length)) fs.rmdirSync(path.join(root, directory));
fs.rmdirSync(root);
syncDirectory(path.dirname(root));
}
module.exports = { atomicJson, ensureParents, hash, inspect, inventory, jsonBytes, mkdir,
pathSegments, read, readJson, recheck, removeTree, syncDirectory, writeExclusive };