| 1 | import assert from "node:assert/strict"; |
| 2 | import { execFileSync } from "node:child_process"; |
| 3 | import { lstatSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; |
| 6 | import { test } from "node:test"; |
| 7 | import { gunzipSync } from "node:zlib"; |
| 8 | |
| 9 | import { paxRecord, splitPath, tar, tarGz } from "./tar.ts"; |
| 10 | |
| 11 | const text = (value: string) => new TextEncoder().encode(value); |
| 12 | const SHA = "4807077b296e6edbf410d55e72749d3e1170c291"; |
| 13 | const ROOT = "acme-rocket-4807077"; |
| 14 | const LONG = `${ROOT}/${"deep/".repeat(40)}${"n".repeat(120)}.txt`; |
| 15 | |
| 16 | const entries = [ |
| 17 | { path: `${ROOT}/README.md`, data: text("hello\n") }, |
| 18 | { path: `${ROOT}/bin/run`, data: text("#!/bin/sh\necho hi\n"), kind: "exec" as const }, |
| 19 | { path: `${ROOT}/latest`, data: text("README.md"), kind: "symlink" as const }, |
| 20 | { path: `${ROOT}/empty`, data: new Uint8Array() }, |
| 21 | { path: LONG, data: text("far down\n") }, |
| 22 | { path: `${ROOT}/${"p".repeat(90)}/${"q".repeat(90)}.md`, data: text("split\n") }, |
| 23 | ]; |
| 24 | |
| 25 | /** Whether `tar` can be run here. */ |
| 26 | /** |
| 27 | * Why the archive is not read back here, if it is not: no tar, or Windows, |
| 28 | * whose tar takes `C:` for a remote host and whose files keep no Unix |
| 29 | * modes or symlinks to check. CI reads it back on Linux. |
| 30 | */ |
| 31 | function readBackSkip(): string | false { |
| 32 | if (process.platform === "win32") return "Windows keeps no Unix modes or symlinks to check"; |
| 33 | return !hasTar() && "no tar here"; |
| 34 | } |
| 35 | |
| 36 | function hasTar(): boolean { |
| 37 | try { |
| 38 | execFileSync("tar", ["--version"], { stdio: "ignore" }); |
| 39 | return true; |
| 40 | } catch { |
| 41 | return false; |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | test("a pax record's length counts itself", () => { |
| 46 | const record = new TextDecoder().decode(paxRecord("comment", text(SHA))); |
| 47 | assert.equal(record, `52 comment=${SHA}\n`); |
| 48 | assert.equal(record.length, 52); |
| 49 | // Where adding the length's digits adds a digit. |
| 50 | const value = "x".repeat(91); |
| 51 | const near = new TextDecoder().decode(paxRecord("path", text(value))); |
| 52 | assert.equal(Number(near.split(" ")[0]), near.length); |
| 53 | }); |
| 54 | |
| 55 | test("a long path splits into ustar's prefix and name when it can", () => { |
| 56 | assert.deepEqual(splitPath(text("a/b")), { name: text("a/b"), prefix: new Uint8Array() }); |
| 57 | const split = splitPath(text(`${"p".repeat(90)}/${"q".repeat(90)}`))!; |
| 58 | assert.equal(new TextDecoder().decode(split.prefix), "p".repeat(90)); |
| 59 | assert.equal(new TextDecoder().decode(split.name), "q".repeat(90)); |
| 60 | assert.equal(splitPath(text(`a/${"n".repeat(120)}`)), null); |
| 61 | assert.equal(splitPath(text(`${"p".repeat(200)}/b`)), null); |
| 62 | }); |
| 63 | |
| 64 | test("headers are ustar, with the commit in a pax global header", () => { |
| 65 | const out = tar(entries, { mtime: 1_700_000_000, comment: SHA }); |
| 66 | assert.equal(out.length % 512, 0); |
| 67 | const decoder = new TextDecoder(); |
| 68 | // The global header first, as git archive writes it. |
| 69 | assert.equal(decoder.decode(out.subarray(0, 17)), "pax_global_header"); |
| 70 | assert.equal(String.fromCharCode(out[156]!), "g"); |
| 71 | assert.equal(decoder.decode(out.subarray(257, 263)), "ustar\0"); |
| 72 | assert.equal(decoder.decode(out.subarray(512, 564)), `52 comment=${SHA}\n`); |
| 73 | // Every header's checksum holds. |
| 74 | const second = out.subarray(1024, 1536); |
| 75 | const stored = parseInt(decoder.decode(second.subarray(148, 154)), 8); |
| 76 | let sum = 0; |
| 77 | for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : second[i]!; |
| 78 | assert.equal(stored, sum); |
| 79 | // Ends in two zero blocks. |
| 80 | assert.ok(out.subarray(out.length - 1024).every((byte) => byte === 0)); |
| 81 | }); |
| 82 | |
| 83 | test("tar reads the archive back: files, folders, modes, a symlink and long paths", { skip: readBackSkip() }, async () => { |
| 84 | const dir = mkdtempSync(join(tmpdir(), "g1t-tar-")); |
| 85 | try { |
| 86 | const file = join(dir, "archive.tar.gz"); |
| 87 | const archive = await tarGz(entries, { mtime: 1_700_000_000, comment: SHA }); |
| 88 | // A gzip stream around the tar. |
| 89 | assert.equal(gunzipSync(archive).length % 512, 0); |
| 90 | writeFileSync(file, archive); |
| 91 | const listed = execFileSync("tar", ["-tzf", file], { encoding: "utf8" }).trim().split("\n"); |
| 92 | assert.equal(listed[0], `${ROOT}/`); |
| 93 | for (const entry of entries) assert.ok(listed.includes(entry.path), entry.path); |
| 94 | assert.ok(listed.includes(`${ROOT}/bin/`)); |
| 95 | // One top-level folder, as actions/checkout expects. |
| 96 | assert.deepEqual([...new Set(listed.map((path) => path.split("/")[0]))], [ROOT]); |
| 97 | const out = join(dir, "out"); |
| 98 | execFileSync("mkdir", [out]); |
| 99 | execFileSync("tar", ["-xzf", file, "-C", out]); |
| 100 | assert.equal(readFileSync(join(out, ROOT, "README.md"), "utf8"), "hello\n"); |
| 101 | assert.equal(readFileSync(join(out, LONG), "utf8"), "far down\n"); |
| 102 | assert.equal(readFileSync(join(out, ROOT, "empty"), "utf8"), ""); |
| 103 | assert.equal(lstatSync(join(out, ROOT, "bin/run")).mode & 0o111, 0o111); |
| 104 | assert.equal(lstatSync(join(out, ROOT, "README.md")).mode & 0o111, 0); |
| 105 | assert.ok(lstatSync(join(out, ROOT, "latest")).isSymbolicLink()); |
| 106 | assert.equal(readlinkSync(join(out, ROOT, "latest")), "README.md"); |
| 107 | // Dated by the commit. |
| 108 | assert.equal(Math.round(lstatSync(join(out, ROOT, "README.md")).mtimeMs / 1000), 1_700_000_000); |
| 109 | // git's own reader finds the commit. |
| 110 | try { |
| 111 | const id = execFileSync("sh", ["-c", `gzip -dc "${file}" | git get-tar-commit-id`], { encoding: "utf8" }).trim(); |
| 112 | assert.equal(id, SHA); |
| 113 | } catch (error) { |
| 114 | if ((error as { status?: number }).status === 127) return; |
| 115 | throw error; |
| 116 | } |
| 117 | } finally { |
| 118 | rmSync(dir, { recursive: true, force: true }); |
| 119 | } |
| 120 | }); |