feat: isolate privacy scanning and release signing
This commit is contained in:
136
scripts/release/enumerate-closure.mjs
Normal file
136
scripts/release/enumerate-closure.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user