137 lines
4.3 KiB
JavaScript
137 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
import { relative, resolve, sep } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import {
|
|
canonicalJsonDigest,
|
|
canonicalValueBytes,
|
|
} from "../../src/security/canonical-digest.ts";
|
|
|
|
const validClass =
|
|
/^(?:git-object|git-ref|lfs|source|build|precompressed|release-archive)$/u;
|
|
const byteCompare = (left, right) =>
|
|
Buffer.from(left).compare(Buffer.from(right));
|
|
|
|
function normalizedRelativePath(root, path) {
|
|
const value = relative(root, path).split(sep).join("/");
|
|
if (
|
|
value === "" ||
|
|
value.startsWith("../") ||
|
|
value.startsWith("/") ||
|
|
value.includes("/../") ||
|
|
value !== value.normalize("NFC")
|
|
) {
|
|
throw new Error(
|
|
"Closure member path is outside the root or non-canonical.",
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function walk(root, current, members, memberClass) {
|
|
const entries = await readdir(current, { withFileTypes: true });
|
|
entries.sort((a, b) => byteCompare(a.name, b.name));
|
|
for (const entry of entries) {
|
|
if (
|
|
entry.name !== entry.name.normalize("NFC") ||
|
|
entry.name === "." ||
|
|
entry.name === ".."
|
|
)
|
|
throw new Error("Closure path is non-canonical.");
|
|
const path = resolve(current, entry.name);
|
|
const info = await lstat(path);
|
|
if (info.isSymbolicLink())
|
|
throw new Error("Closure cannot contain a symlink.");
|
|
if (info.isDirectory()) {
|
|
await walk(root, path, members, memberClass);
|
|
continue;
|
|
}
|
|
if (!info.isFile())
|
|
throw new Error(
|
|
"Closure cannot contain device, socket, FIFO, or other special entries.",
|
|
);
|
|
if (info.nlink !== 1)
|
|
throw new Error("Closure cannot contain hard-link aliases.");
|
|
const bytes = await readFile(path);
|
|
members.push({
|
|
class: memberClass,
|
|
path: normalizedRelativePath(root, path),
|
|
bytes: bytes.byteLength,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function enumerateClosure(inputs) {
|
|
if (!Array.isArray(inputs) || inputs.length === 0)
|
|
throw new Error("At least one explicit closure root is required.");
|
|
const members = [];
|
|
const roots = new Set();
|
|
for (const input of inputs) {
|
|
if (
|
|
input === null ||
|
|
typeof input !== "object" ||
|
|
!validClass.test(input.class) ||
|
|
typeof input.root !== "string"
|
|
) {
|
|
throw new Error("Closure root descriptor is invalid.");
|
|
}
|
|
const root = await realpath(input.root);
|
|
if (roots.has(`${input.class}\0${root}`))
|
|
throw new Error("Duplicate closure root.");
|
|
roots.add(`${input.class}\0${root}`);
|
|
const info = await lstat(root);
|
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
throw new Error("Closure root must be a real directory.");
|
|
await walk(root, root, members, input.class);
|
|
}
|
|
members.sort((a, b) =>
|
|
byteCompare(`${a.class}\0${a.path}`, `${b.class}\0${b.path}`),
|
|
);
|
|
const identities = new Set();
|
|
const folded = new Set();
|
|
for (const member of members) {
|
|
const identity = `${member.class}\0${member.path}`;
|
|
const fold = identity.toLocaleLowerCase("en-US");
|
|
if (identities.has(identity) || folded.has(fold))
|
|
throw new Error("Closure contains a duplicate or case-fold collision.");
|
|
identities.add(identity);
|
|
folded.add(fold);
|
|
}
|
|
const value = { schema: "portfolio-publication-closure/v1", members };
|
|
return {
|
|
...value,
|
|
digest: canonicalJsonDigest("portfolio-publication-closure/v1", value),
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const descriptors = process.argv.slice(2).map((argument) => {
|
|
const separator = argument.indexOf("=");
|
|
if (separator < 1)
|
|
throw new Error("Usage: enumerate-closure.mjs <class>=<directory> [...]");
|
|
return {
|
|
class: argument.slice(0, separator),
|
|
root: argument.slice(separator + 1),
|
|
};
|
|
});
|
|
process.stdout.write(
|
|
Buffer.from(canonicalValueBytes(await enumerateClosure(descriptors))),
|
|
);
|
|
process.stdout.write("\n");
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
main().catch((error) => {
|
|
process.stderr.write(
|
|
`Closure enumeration failed: ${error instanceof Error ? error.message : "unknown error"}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|