| 1 | /** |
| 2 | * Gzipped ustar of a directory. Used to upload a generated Docker |
| 3 | * context (Railway `/up`, and any other gzip-tarball consumer). |
| 4 | */ |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as zlib from "node:zlib"; |
| 9 | |
| 10 | const encoder = new TextEncoder(); |
| 11 | |
| 12 | const pad512 = (size: number) => (512 - (size % 512)) % 512; |
| 13 | |
| 14 | const 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 | |
| 24 | const 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 | |
| 38 | const 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 | |
| 66 | const 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 | |
| 81 | const 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 | |
| 93 | const 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 | |
| 104 | type WalkEntry = { |
| 105 | rel: string; |
| 106 | abs: string; |
| 107 | dir: boolean; |
| 108 | mode: number; |
| 109 | }; |
| 110 | |
| 111 | const 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 | |
| 148 | const 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 | */ |
| 173 | export 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 | }); |