1/**
2 * Gzipped ustar of a directory. Used to upload a generated Docker
3 * context (Railway `/up`, and any other gzip-tarball consumer).
4 */
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as zlib from "node:zlib";
9
10const encoder = new TextEncoder();
11
12const pad512 = (size: number) => (512 - (size % 512)) % 512;
13
14const writeBytes = (
15 buf: Uint8Array,
16 offset: number,
17 value: string,
18 length: number,
19) => {
20 const bytes = encoder.encode(value);
21 buf.set(bytes.subarray(0, length), offset);
22};
23
24const writeOctal = (
25 buf: Uint8Array,
26 offset: number,
27 value: number,
28 length: number,
29) => {
30 writeBytes(
31 buf,
32 offset,
33 `${value.toString(8).padStart(length - 1, "0")}\0`,
34 length,
35 );
36};
37
38const headerBlock = (input: {
39 name: string;
40 size: number;
41 type: string;
42 mode: number;
43 prefix?: string;
44}): Uint8Array => {
45 const buf = new Uint8Array(512);
46 writeBytes(buf, 0, input.name, 100);
47 writeOctal(buf, 100, input.mode, 8);
48 writeOctal(buf, 108, 0, 8);
49 writeOctal(buf, 116, 0, 8);
50 writeOctal(buf, 124, input.size, 12);
51 writeOctal(buf, 136, Math.floor(Date.now() / 1000), 12);
52 for (let i = 148; i < 156; i++) buf[i] = 0x20;
53 buf[156] = input.type.charCodeAt(0);
54 writeBytes(buf, 257, "ustar", 6);
55 buf[262] = 0;
56 writeBytes(buf, 263, "00", 2);
57 if (input.prefix !== undefined) {
58 writeBytes(buf, 345, input.prefix, 155);
59 }
60 let sum = 0;
61 for (const byte of buf) sum += byte;
62 writeBytes(buf, 148, `${sum.toString(8).padStart(6, "0")}\0 `, 8);
63 return buf;
64};
65
66const splitUstarName = (
67 rel: string,
68): { name: string; prefix?: string } | undefined => {
69 if (rel.length <= 100) return { name: rel };
70 for (let i = rel.length - 101; i >= 1; i--) {
71 if (rel[i] !== "/") continue;
72 const prefix = rel.slice(0, i);
73 const name = rel.slice(i + 1);
74 if (prefix.length <= 155 && name.length > 0 && name.length <= 100) {
75 return { prefix, name };
76 }
77 }
78 return undefined;
79};
80
81const gnuLongLink = (name: string): Uint8Array[] => {
82 const bytes = encoder.encode(`${name}\0`);
83 const header = headerBlock({
84 name: "././@LongLink",
85 size: bytes.length,
86 type: "L",
87 mode: 0,
88 });
89 const pad = pad512(bytes.length);
90 return pad > 0 ? [header, bytes, new Uint8Array(pad)] : [header, bytes];
91};
92
93const concat = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
94 const total = chunks.reduce((n, chunk) => n + chunk.byteLength, 0);
95 const out = new Uint8Array(total);
96 let offset = 0;
97 for (const chunk of chunks) {
98 out.set(chunk, offset);
99 offset += chunk.byteLength;
100 }
101 return out;
102};
103
104type WalkEntry = {
105 rel: string;
106 abs: string;
107 dir: boolean;
108 mode: number;
109};
110
111const walkDirectory = (
112 root: string,
113): Effect.Effect<WalkEntry[], unknown, FileSystem.FileSystem | Path.Path> =>
114 Effect.gen(function* () {
115 const fs = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;
117 const files: WalkEntry[] = [];
118 const walk = (dir: string, rel: string): Effect.Effect<void, unknown> =>
119 Effect.gen(function* () {
120 const entries = [...(yield* fs.readDirectory(dir))].sort();
121 for (const entry of entries) {
122 const abs = path.join(dir, entry);
123 const childRel = rel.length === 0 ? entry : `${rel}/${entry}`;
124 const posixRel = childRel.replaceAll("\\", "/");
125 const stat = yield* fs.stat(abs);
126 if (stat.type === "Directory") {
127 files.push({
128 rel: posixRel,
129 abs,
130 dir: true,
131 mode: stat.mode & 0o7777,
132 });
133 yield* walk(abs, posixRel);
134 } else if (stat.type === "File") {
135 files.push({
136 rel: posixRel,
137 abs,
138 dir: false,
139 mode: stat.mode & 0o7777,
140 });
141 }
142 }
143 });
144 yield* walk(root, "");
145 return files;
146 });
147
148const fileBlocks = (
149 rel: string,
150 size: number,
151 type: "0" | "5",
152 mode: number,
153): Uint8Array[] => {
154 const split = splitUstarName(rel);
155 if (split === undefined) {
156 return [
157 ...gnuLongLink(rel),
158 headerBlock({
159 name: rel.slice(0, 100),
160 size,
161 type,
162 mode,
163 }),
164 ];
165 }
166 return [headerBlock({ ...split, size, type, mode })];
167};
168
169/**
170 * Pack `root` into a gzipped tar (ustar + GNU long-name). Paths are
171 * relative to `root` with POSIX separators.
172 */
173export const tarGzipDirectory = Effect.fn(function* (
174 root: string,
175 options?: { preserveMode?: boolean },
176) {
177 const fs = yield* FileSystem.FileSystem;
178 const files = yield* walkDirectory(root);
179 const chunks: Uint8Array[] = [];
180 for (const file of files) {
181 if (file.dir) {
182 chunks.push(
183 ...fileBlocks(
184 `${file.rel}/`,
185 0,
186 "5",
187 options?.preserveMode ? file.mode : 0o755,
188 ),
189 );
190 continue;
191 }
192 const content = yield* fs.readFile(file.abs);
193 chunks.push(
194 ...fileBlocks(
195 file.rel,
196 content.byteLength,
197 "0",
198 options?.preserveMode ? file.mode : 0o644,
199 ),
200 );
201 chunks.push(content);
202 const pad = pad512(content.byteLength);
203 if (pad > 0) chunks.push(new Uint8Array(pad));
204 }
205 chunks.push(new Uint8Array(1024));
206 const tar = concat(chunks);
207 return yield* Effect.sync(() => zlib.gzipSync(tar));
208});