import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { gitEnv } from "../src/checkout.mjs"; import { readReviewedArtifact, ArtifactError } from "../src/artifact.mjs"; const LIMIT = 1024 * 1024; function git(cwd, ...args) { const result = spawnSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.invalid", ...args], { cwd, encoding: "utf8", env: gitEnv() }); assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`); return result.stdout.trim(); } function world(t) { const dir = mkdtempSync(join(tmpdir(), "mosaic-artifact-")); t.after(() => rmSync(dir, { recursive: true, force: true })); const work = join(dir, "work"), repo = join(dir, "working.git"); mkdirSync(work); git(work, "init", "-q"); mkdirSync(join(work, "site")); const original = Buffer.from("first\0version\n"); writeFileSync(join(work, "site", "index.html"), original); writeFileSync(join(work, "site", "one.txt"), "plain"); writeFileSync(join(work, "site", "run.htm"), "executable"); chmodSync(join(work, "site", "run.htm"), 0o755); writeFileSync(join(work, "site", "empty"), ""); writeFileSync(join(work, "site", "exact.bin"), Buffer.alloc(LIMIT, 0x61)); writeFileSync(join(work, "site", "large.bin"), Buffer.alloc(LIMIT + 1, 0x62)); symlinkSync("index.html", join(work, "site", "link.html")); git(work, "add", "."); git(work, "commit", "-qm", "first"); const first = git(work, "rev-parse", "HEAD"); const blob = git(work, "rev-parse", "HEAD:site/index.html"); git(work, "update-index", "--add", "--cacheinfo", `160000,${first},site/submodule`); writeFileSync(join(work, "site", "index.html"), "later"); git(work, "add", "site/index.html"); git(work, "commit", "-qm", "second"); const second = git(work, "rev-parse", "HEAD"); git(dir, "clone", "-q", "--bare", work, repo); return { repo, work, first, second, blob, original }; } async function refuses(args, code) { await assert.rejects(readReviewedArtifact(args), error => error instanceof ArtifactError && error.code === code); } test("reads exact committed HTML bytes, blob ID and SHA-256", async t => { const { repo, first, blob, original } = world(t); const result = await readReviewedArtifact({ repo, commit: first, path: "site/index.html" }); assert.deepEqual(result, { bytes: original, blob, sha256: createHash("sha256").update(original).digest("hex"), mime: "text/html" }); }); test("reads the reviewed commit after a later commit changes the file", async t => { const { repo, first, second, original } = world(t); assert.deepEqual((await readReviewedArtifact({ repo, commit: first, path: "site/index.html" })).bytes, original); assert.equal((await readReviewedArtifact({ repo, commit: second, path: "site/index.html" })).bytes.toString(), "later"); }); test("accepts executable HTML and empty blobs under a zero-byte limit", async t => { const { repo, first } = world(t); const executable = await readReviewedArtifact({ repo, commit: first, path: "site/run.htm" }); assert.equal(executable.mime, "text/html"); assert.equal(executable.bytes.toString(), "executable"); assert.equal((await readReviewedArtifact({ repo, commit: first, path: "site/empty", maxBytes: 0 })).bytes.length, 0); }); test("rejects traversal, empty segments, absolute paths, backslashes and NUL", async t => { const { repo, first } = world(t); for (const path of ["", "/site/index.html", "../site/index.html", "site/../site/index.html", "./site/index.html", "site/./index.html", "site//index.html", "site/", "site\\index.html", "site/\0index.html"]) { await refuses({ repo, commit: first, path }, "artifact-path-invalid"); } }); test("rejects symlink, submodule and directory entries", async t => { const { repo, second } = world(t); for (const path of ["site/link.html", "site/submodule", "site"]) { await refuses({ repo, commit: second, path }, "artifact-not-regular"); } }); test("accepts exactly one MiB and rejects larger blobs and invalid limits", async t => { const { repo, first } = world(t); const exact = await readReviewedArtifact({ repo, commit: first, path: "site/exact.bin" }); assert.deepEqual(exact.bytes, Buffer.alloc(LIMIT, 0x61)); assert.equal(exact.mime, "application/octet-stream"); await refuses({ repo, commit: first, path: "site/large.bin" }, "artifact-too-large"); await refuses({ repo, commit: first, path: "site/one.txt", maxBytes: 4 }, "artifact-too-large"); for (const maxBytes of [-1, LIMIT + 1, 1.5, NaN, "10", null]) { await refuses({ repo, commit: first, path: "site/one.txt", maxBytes }, "artifact-limit-invalid"); } }); test("rejects abbreviated IDs, refs, non-commit IDs and nonexistent commits", async t => { const { repo, first, blob } = world(t); for (const commit of [first.slice(0, 12), "HEAD", blob, "f".repeat(40)]) { await refuses({ repo, commit, path: "site/index.html" }, "artifact-commit-invalid"); } }); test("rejects missing paths and treats Git pathspec magic literally", async t => { const { repo, first } = world(t); for (const path of ["site/missing.html", "site/*.html", ":(glob)site/*.html"]) { await refuses({ repo, commit: first, path }, "artifact-missing"); } }); test("ignores replacement refs when resolving reviewed objects", async t => { const { repo, first, second, original } = world(t); git(repo, "replace", first, second); assert.deepEqual((await readReviewedArtifact({ repo, commit: first, path: "site/index.html" })).bytes, original); });