Skip to content
167 linesCodeBlameRaw
1/**
2 * A gzipped tar archive of files, made in memory, as `git archive` makes
3 * one: POSIX ustar headers, a pax global header carrying the commit
4 * (`comment=<sha>`, which `git get-tar-commit-id` reads), a directory
5 * entry before each folder's files, executable files and symlinks kept as
6 * they are in git, and a pax extended header for a path or link target too
7 * long for ustar. Everything is owned by root and dated by the commit.
8 * Enough for downloading a commit: each file stays under 8 GB (the caller
9 * caps them far below that).
10 */
11
12export type TarEntry = {
13 /** The path in the archive, folders separated by `/`. */
14 path: string;
15 /** The file's bytes; for a symlink, where it points. */
16 data: Uint8Array;
17 kind?: "blob" | "exec" | "symlink";
18};
19
20export type TarOptions = {
21 /** Seconds since the epoch, what every entry is dated. */
22 mtime: number;
23 /** The commit, kept in a pax global header as `git archive` keeps it. */
24 comment?: string;
25};
26
27const BLOCK = 512;
28const encoder = new TextEncoder();
29
30/** `git archive`'s modes, with its default umask of 002. */
31const MODES = { blob: 0o664, exec: 0o775, symlink: 0o777, dir: 0o775 } as const;
32
33/** `value` in octal, zero-padded to fill `width` bytes with a NUL at the end. */
34function octal(value: number, width: number): string {
35 return value.toString(8).padStart(width - 1, "0") + "\0";
36}
37
38function put(header: Uint8Array, at: number, width: number, value: string | Uint8Array) {
39 const bytes = typeof value === "string" ? encoder.encode(value) : value;
40 header.set(bytes.subarray(0, width), at);
41}
42
43/**
44 * `path` as ustar's `name` (100 bytes) and `prefix` (155), split at a
45 * slash; null when it does not fit.
46 */
47export function splitPath(path: Uint8Array): { name: Uint8Array; prefix: Uint8Array } | null {
48 if (path.length <= 100) return { name: path, prefix: new Uint8Array() };
49 // The last slash that leaves at most 100 bytes after it and 155 before.
50 for (let at = Math.min(path.length - 1, 155); at > 0; at--) {
51 if (path[at] !== 0x2f) continue;
52 if (path.length - at - 1 > 100) return null;
53 if (path.length - at - 1 === 0) continue;
54 return { name: path.subarray(at + 1), prefix: path.subarray(0, at) };
55 }
56 return null;
57}
58
59type Header = { name: Uint8Array; prefix?: Uint8Array; mode: number; size: number; mtime: number; type: string; link?: Uint8Array };
60
61function header(entry: Header): Uint8Array {
62 const out = new Uint8Array(BLOCK);
63 put(out, 0, 100, entry.name);
64 put(out, 100, 8, octal(entry.mode, 8));
65 put(out, 108, 8, octal(0, 8));
66 put(out, 116, 8, octal(0, 8));
67 put(out, 124, 12, octal(entry.size, 12));
68 put(out, 136, 12, octal(entry.mtime, 12));
69 put(out, 148, 8, " ");
70 put(out, 156, 1, entry.type);
71 if (entry.link) put(out, 157, 100, entry.link);
72 put(out, 257, 6, "ustar\0");
73 put(out, 263, 2, "00");
74 put(out, 265, 32, "root");
75 put(out, 297, 32, "root");
76 put(out, 329, 8, octal(0, 8));
77 put(out, 337, 8, octal(0, 8));
78 if (entry.prefix) put(out, 345, 155, entry.prefix);
79 let sum = 0;
80 for (const byte of out) sum += byte;
81 put(out, 148, 8, sum.toString(8).padStart(6, "0") + "\0 ");
82 return out;
83}
84
85/** One pax record, `<length> <key>=<value>\n`, its length counting itself. */
86export function paxRecord(key: string, value: Uint8Array): Uint8Array {
87 const rest = encoder.encode(` ${key}=`).length + value.length + 1;
88 let length = rest + String(rest).length;
89 if (String(length).length !== String(rest).length) length = rest + String(length).length;
90 const out = new Uint8Array(length);
91 const head = encoder.encode(`${length} ${key}=`);
92 out.set(head);
93 out.set(value, head.length);
94 out[length - 1] = 0x0a;
95 return out;
96}
97
98function padded(data: Uint8Array): Uint8Array[] {
99 const rest = data.length % BLOCK;
100 return rest === 0 ? [data] : [data, new Uint8Array(BLOCK - rest)];
101}
102
103function concat(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
104 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
105 let at = 0;
106 for (const part of parts) {
107 out.set(part, at);
108 at += part.length;
109 }
110 return out;
111}
112
113/** The tar archive of `entries` (uncompressed), each folder's entry before its files. */
114export function tar(entries: TarEntry[], options: TarOptions): Uint8Array<ArrayBuffer> {
115 const parts: Uint8Array[] = [];
116 const mtime = Math.max(0, Math.floor(options.mtime));
117 if (options.comment) {
118 const record = paxRecord("comment", encoder.encode(options.comment));
119 parts.push(header({ name: encoder.encode("pax_global_header"), mode: 0o666, size: record.length, mtime, type: "g" }), ...padded(record));
120 }
121 const add = (path: string, kind: "blob" | "exec" | "symlink" | "dir", data: Uint8Array) => {
122 const fullPath = encoder.encode(kind === "dir" ? `${path}/` : path);
123 const link = kind === "symlink" ? data : undefined;
124 const body = kind === "blob" || kind === "exec" ? data : new Uint8Array();
125 const split = splitPath(fullPath);
126 const records: Uint8Array[] = [];
127 if (!split) records.push(paxRecord("path", fullPath));
128 if (link && link.length > 100) records.push(paxRecord("linkpath", link));
129 if (records.length > 0) {
130 const pax = concat(records);
131 const name = encoder.encode("././@PaxHeader");
132 parts.push(header({ name, mode: 0o666, size: pax.length, mtime, type: "x" }), ...padded(pax));
133 }
134 const type = kind === "dir" ? "5" : kind === "symlink" ? "2" : "0";
135 parts.push(
136 header({
137 name: split?.name ?? fullPath.subarray(0, 100),
138 prefix: split?.prefix,
139 mode: MODES[kind],
140 size: body.length,
141 mtime,
142 type,
143 link: link && link.length <= 100 ? link : link?.subarray(0, 100),
144 }),
145 );
146 if (body.length > 0) parts.push(...padded(body));
147 };
148 const folders = new Set<string>();
149 for (const entry of entries) {
150 const segments = entry.path.split("/").filter(Boolean);
151 for (let depth = 1; depth < segments.length; depth++) {
152 const folder = segments.slice(0, depth).join("/");
153 if (folders.has(folder)) continue;
154 folders.add(folder);
155 add(folder, "dir", new Uint8Array());
156 }
157 add(segments.join("/"), entry.kind ?? "blob", entry.data);
158 }
159 parts.push(new Uint8Array(BLOCK * 2));
160 return concat(parts);
161}
162
163/** {@link tar}, gzipped. */
164export async function tarGz(entries: TarEntry[], options: TarOptions): Promise<Uint8Array<ArrayBuffer>> {
165 const stream = new Blob([tar(entries, options)]).stream().pipeThrough(new CompressionStream("gzip"));
166 return new Uint8Array(await new Response(stream).arrayBuffer());
167}