60 lines
2.9 KiB
JavaScript
60 lines
2.9 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { extname } from "node:path";
|
|
import { gitBytes } from "../../project/src/git.mjs";
|
|
|
|
const MAX_BYTES = 1024 * 1024;
|
|
const MAX_PATH_BYTES = 4096;
|
|
|
|
export class ArtifactError extends Error {
|
|
constructor(code, message) {
|
|
super(message);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const fail = (code, message) => { throw new ArtifactError(code, message); };
|
|
|
|
export async function readReviewedArtifact({ repo, commit, path, maxBytes = MAX_BYTES }) {
|
|
if (typeof path !== "string" || !path || path.startsWith("/") || /[\\\0]/.test(path)
|
|
|| path.split("/").some(segment => !segment || segment === "." || segment === "..")
|
|
|| Buffer.byteLength(path) > MAX_PATH_BYTES) {
|
|
fail("artifact-path-invalid", "expected a bounded relative Git path");
|
|
}
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > MAX_BYTES) {
|
|
fail("artifact-limit-invalid", "invalid artifact size limit");
|
|
}
|
|
if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) {
|
|
fail("artifact-commit-invalid", "expected a full commit object ID");
|
|
}
|
|
|
|
const read = (args, limit) => gitBytes(repo, ["--no-replace-objects", ...args], limit);
|
|
let type;
|
|
try { type = (await read(["cat-file", "-t", commit], 128)).toString("utf8").trim(); }
|
|
catch { fail("artifact-commit-invalid", "commit object is not readable"); }
|
|
if (type !== "commit") fail("artifact-commit-invalid", "object is not a commit");
|
|
|
|
let listing;
|
|
try { listing = await read(["ls-tree", "-z", "--full-tree", commit, "--", `:(literal)${path}`], MAX_PATH_BYTES + 128); }
|
|
catch { fail("artifact-git", "could not read committed tree"); }
|
|
if (listing.length === 0) fail("artifact-missing", "path is absent at commit");
|
|
const match = /^(\d{6}) (blob|tree|commit) ([0-9a-f]{40})\t([^\0]+)\0$/.exec(listing.toString("utf8"));
|
|
if (!match || match[4] !== path) fail("artifact-git", "unexpected committed tree entry");
|
|
if (!["100644", "100755"].includes(match[1]) || match[2] !== "blob") {
|
|
fail("artifact-not-regular", "expected a regular committed blob");
|
|
}
|
|
|
|
const blob = match[3];
|
|
let sizeText;
|
|
try { sizeText = (await read(["cat-file", "-s", blob], 128)).toString("utf8").trim(); }
|
|
catch { fail("artifact-git", "could not read blob size"); }
|
|
if (!/^\d+$/.test(sizeText) || !Number.isSafeInteger(Number(sizeText))) fail("artifact-git", "invalid blob size");
|
|
const size = Number(sizeText);
|
|
if (size > maxBytes) fail("artifact-too-large", "artifact exceeds size limit");
|
|
let bytes;
|
|
try { bytes = await read(["cat-file", "blob", blob], maxBytes); }
|
|
catch { fail("artifact-git", "could not read bounded blob"); }
|
|
if (bytes.length !== size) fail("artifact-git", "blob size changed during read");
|
|
const mime = [".html", ".htm"].includes(extname(path).toLowerCase()) ? "text/html" : "application/octet-stream";
|
|
return { bytes, blob, sha256: createHash("sha256").update(bytes).digest("hex"), mime };
|
|
}
|