Skip to content
120 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { execFileSync } from "node:child_process";
3import { lstatSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import { test } from "node:test";
7import { gunzipSync } from "node:zlib";
8
9import { paxRecord, splitPath, tar, tarGz } from "./tar.ts";
10
11const text = (value: string) => new TextEncoder().encode(value);
12const SHA = "4807077b296e6edbf410d55e72749d3e1170c291";
13const ROOT = "acme-rocket-4807077";
14const LONG = `${ROOT}/${"deep/".repeat(40)}${"n".repeat(120)}.txt`;
15
16const 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 */
31function 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
36function hasTar(): boolean {
37 try {
38 execFileSync("tar", ["--version"], { stdio: "ignore" });
39 return true;
40 } catch {
41 return false;
42 }
43}
44
45test("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
55test("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
64test("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
83test("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});