mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 20:45:11 +02:00
162 lines
6.9 KiB
JavaScript
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 };
|