| 1 | import { gunzipSync, inflateRawSync } from "node:zlib"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { crc32, gzip, tar, zip, type ArchiveEntry } from "../../src/lib/archive.js"; |
| 4 | |
| 5 | const enc = new TextEncoder(); |
| 6 | const dec = new TextDecoder(); |
| 7 | const MTIME = Date.UTC(2024, 4, 6, 7, 8, 10) / 1000; |
| 8 | |
| 9 | const entries: ArchiveEntry[] = [ |
| 10 | { path: "repo-abc1234/", dir: true }, |
| 11 | { path: "repo-abc1234/README.md", mode: "100644", content: enc.encode("# hello\n".repeat(50)) }, |
| 12 | { path: "repo-abc1234/bin/run", mode: "100755", content: enc.encode("#!/bin/sh\necho hi\n") }, |
| 13 | { path: `repo-abc1234/${"deep/".repeat(30)}file.txt`, mode: "100644", content: enc.encode("long path") }, |
| 14 | { path: "repo-abc1234/link", mode: "120000", content: enc.encode("README.md") }, |
| 15 | ]; |
| 16 | |
| 17 | /** Read a tar archive's entries (ustar plus pax `path` records). */ |
| 18 | function untar(data: Uint8Array): Array<{ name: string; type: string; mode: number; body: string; link: string }> { |
| 19 | const out: Array<{ name: string; type: string; mode: number; body: string; link: string }> = []; |
| 20 | let off = 0; |
| 21 | let paxPath: string | null = null; |
| 22 | while (off + 512 <= data.length) { |
| 23 | const h = data.subarray(off, off + 512); |
| 24 | if (h.every((b) => b === 0)) break; |
| 25 | const str = (a: number, n: number) => dec.decode(h.subarray(a, a + n)).replace(/\0.*$/s, ""); |
| 26 | const size = parseInt(str(124, 12), 8); |
| 27 | const type = str(156, 1); |
| 28 | const body = data.subarray(off + 512, off + 512 + size); |
| 29 | // Header checksum: the sum of all header bytes with the checksum field as spaces. |
| 30 | let sum = 0; |
| 31 | for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : h[i]!; |
| 32 | expect(parseInt(str(148, 8), 8)).toBe(sum); |
| 33 | if (type === "x") { |
| 34 | paxPath = /\d+ path=(.*)\n/.exec(dec.decode(body))?.[1] ?? null; |
| 35 | } else if (type !== "g") { |
| 36 | out.push({ name: paxPath ?? str(0, 100), type, mode: parseInt(str(100, 8), 8), body: dec.decode(body), link: str(157, 100) }); |
| 37 | paxPath = null; |
| 38 | } |
| 39 | off += 512 + Math.ceil(size / 512) * 512; |
| 40 | } |
| 41 | return out; |
| 42 | } |
| 43 | |
| 44 | /** Read a zip archive's central directory and inflate each entry. */ |
| 45 | function unzip(data: Uint8Array): Array<{ name: string; body: string; unixMode: number }> { |
| 46 | const v = new DataView(data.buffer, data.byteOffset, data.byteLength); |
| 47 | let eocd = data.length - 22; |
| 48 | while (eocd >= 0 && v.getUint32(eocd, true) !== 0x06054b50) eocd--; |
| 49 | expect(eocd).toBeGreaterThanOrEqual(0); |
| 50 | const count = v.getUint16(eocd + 10, true); |
| 51 | let p = v.getUint32(eocd + 16, true); |
| 52 | const out: Array<{ name: string; body: string; unixMode: number }> = []; |
| 53 | for (let i = 0; i < count; i++) { |
| 54 | expect(v.getUint32(p, true)).toBe(0x02014b50); |
| 55 | const method = v.getUint16(p + 10, true); |
| 56 | const crc = v.getUint32(p + 16, true); |
| 57 | const csize = v.getUint32(p + 20, true); |
| 58 | const nlen = v.getUint16(p + 28, true); |
| 59 | const xlen = v.getUint16(p + 30, true); |
| 60 | const clen = v.getUint16(p + 32, true); |
| 61 | const attrs = v.getUint32(p + 38, true); |
| 62 | const local = v.getUint32(p + 42, true); |
| 63 | const name = dec.decode(data.subarray(p + 46, p + 46 + nlen)); |
| 64 | const start = local + 30 + v.getUint16(local + 26, true) + v.getUint16(local + 28, true); |
| 65 | const raw = data.subarray(start, start + csize); |
| 66 | const body = method === 8 ? new Uint8Array(inflateRawSync(raw)) : raw; |
| 67 | expect(crc32(body)).toBe(crc); |
| 68 | out.push({ name, body: dec.decode(body), unixMode: attrs >>> 16 }); |
| 69 | p += 46 + nlen + xlen + clen; |
| 70 | } |
| 71 | return out; |
| 72 | } |
| 73 | |
| 74 | describe("archives", () => { |
| 75 | it("computes the standard CRC-32", () => { |
| 76 | expect(crc32(enc.encode("123456789"))).toBe(0xcbf43926); |
| 77 | expect(crc32(new Uint8Array(0))).toBe(0); |
| 78 | }); |
| 79 | |
| 80 | it("writes a tar with modes, symlinks, long names and a commit comment", async () => { |
| 81 | const t = tar(entries, MTIME, "abc1234"); |
| 82 | expect(t.length % 512).toBe(0); |
| 83 | const files = untar(t); |
| 84 | expect(files.map((f) => f.name)).toEqual(entries.map((e) => ("dir" in e && !e.path.endsWith("/") ? `${e.path}/` : e.path))); |
| 85 | expect(files[1]!.body).toBe("# hello\n".repeat(50)); |
| 86 | // Like `git archive` (tar.umask 0002), which GitHub tarballs come from. |
| 87 | expect(files[2]!.mode).toBe(0o775); |
| 88 | expect(files[1]!.mode).toBe(0o664); |
| 89 | expect(files[3]!.body).toBe("long path"); |
| 90 | expect(files[4]!.type).toBe("2"); |
| 91 | expect(files[4]!.link).toBe("README.md"); |
| 92 | // gzip round trip |
| 93 | expect(new Uint8Array(gunzipSync(await gzip(t)))).toEqual(t); |
| 94 | }); |
| 95 | |
| 96 | it("writes a zip that inflates back to the same files", async () => { |
| 97 | const z = await zip(entries, MTIME, "abc1234"); |
| 98 | const files = unzip(z); |
| 99 | expect(files.map((f) => f.name)).toEqual(entries.map((e) => e.path)); |
| 100 | expect(files[1]!.body).toBe("# hello\n".repeat(50)); |
| 101 | expect(files[2]!.unixMode & 0o777).toBe(0o775); |
| 102 | expect(files[4]!.unixMode & 0o170000).toBe(0o120000); |
| 103 | expect(dec.decode(z.subarray(z.length - "abc1234".length))).toBe("abc1234"); |
| 104 | }); |
| 105 | }); |