Public: anyone can read this; the crew can change it.

A .zip, .tar or .tar.gz file in a repository shows the list of what is inside it, without unpacking it #10325

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10289-a-zip-tar-or-tar-gz-file-in-a-repository → main 4 commits fork 4 threads · 4 unresolved

  • Merged. torque/10289-a-zip-tar-or-tar-gz-file-in-a-repository was merged into main as 8c4013d.

Review

Sign in to review this change.
11 files changed +649 −5
docs/design/areas/render.md +4
@@ -8,3 +8,7 @@ No script, no pdf.js.
88 - `.pdf-fallback` — inside the object, shown only when the viewer cannot draw: an "Open the PDF" link and a `.btn` Download (`?download=1`).
99
1010 The header shows the size (`1.2 MB · PDF`) through `Rendered.summary`. Both themes use tokens only.
11
12 ## Archive listing
13
14 A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the existing `.table` alone, with no `.card` around it (path, size, modified; `data-dense`), and a `tfoot` for the count and unpacked size. No new class: `.archive-view` is only a hook on the table and has no rule. How it works: `docs/features/archive-listing.md`. A list that cannot be read falls back to the Download card with the reason as its one sentence.
docs/features/archive-listing.md added +34
@@ -0,0 +1,34 @@
1 # Archive listing
2
3 A `.zip`, `.tar` or `.tar.gz` (also `.tgz`) in a repository shows what is inside it, without unpacking it to disk. Code: `src/web/render/archive.ts` (the bounded reader, `listArchive`), `archive-view.ts` (the page), `src/tui/ui/archive-note.ts` (the terminal).
4
5 ## What opens
6
7 The name picks the kind and the bytes confirm it (zip signature, `ustar` marker, gzip signature). A `.gz` counts only as the end of `.tar.gz`. A file named `.zip` whose bytes are not a zip stays a plain binary file.
8
9 ## What is shown
10
11 - **Page:** a table of path, size and modified date (UTC), sorted by path, with the entry count and the unpacked size under it. Names are escaped; control and bidi-override characters show as `�`.
12 - **Terminal:** in the workspace, the file's note gives the format, the entry count and the unpacked size (`zip, 14 entries, 3.4 MB unpacked.`). Same gating as the page.
13
14 ## Limits
15
16 | Limit | Value | Where |
17 |---|---|---|
18 | Entries listed | 2,000 (the rest are counted, and the footer says how many are not listed) | `ENTRY_CAP` |
19 | Archive file size | 50 MB | `RENDER_CAP.archive` |
20 | Unpacked size of a `.tar.gz` | 128 MB (gunzip stops there) | `GUNZIP_CAP` |
21
22 Zip and tar are read from their directory and headers only; no entry's contents are decompressed. Only `.tar.gz` is gunzipped, in memory, under the 128 MB cap.
23
24 ## Fallbacks
25
26 When the list cannot be shown, the page gives the Download card titled "Archive", with one sentence:
27
28 - Over 50 MB: "This archive is over 50 MB, too large to list here. Download it instead."
29 - A `.tar.gz` past the cap: "This archive unpacks to more than 128 MB, so its list is not read here."
30 - Encrypted zip: "This zip is encrypted, so its list is not shown."
31 - Damaged or cut short: "This archive is damaged or cut short, so its list cannot be read."
32 - Damaged gzip: "This archive is damaged, so its list cannot be read."
33
34 The terminal shows the same sentences.
src/tui/screens/workspace.ts +5 −1
@@ -79,6 +79,7 @@ import {
7979 } from "../ui/image.ts";
8080 import { fill, len, split } from "../ui/layout.ts";
8181 import { renderMarkdown, type Line } from "../ui/markdown.ts";
82 import { archiveNote } from "../ui/archive-note.ts";
8283 import { mediaNote } from "../ui/media-note.ts";
8384 import { tokColor, type Tokens } from "../ui/theme.ts";
8485 import { card, emptyState, input, scrollbar } from "../ui/widgets.ts";
@@ -740,7 +741,10 @@ export function workspaceScreen(
740741 // something with. Everything else keeps the honest note: showing
741742 // bytes as text is how a terminal ends up beeping at you.
742743 if (looksLikeImage(t.path)) picture = read.value.body;
743 else note = `Binary file, ${formatSize(read.value.size)}.`;
744 else
745 note =
746 archiveNote(t.path, read.value.body, read.value.size, formatSize) ??
747 `Binary file, ${formatSize(read.value.size)}.`;
744748 } else raw = read.value.body;
745749 }
746750 entry = { note, raw, picture };
src/tui/ui/archive-note.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * What the terminal says for a .zip, .tar or .tar.gz: how many entries it
3 * holds and how big they are unpacked, read through `listArchive` and gated
4 * as the page is (the kind from name and bytes, no more than the archive
5 * size cap). `undefined` for any file that is not a readable-sized archive.
6 */
7
8 import { count, listArchive, overSize } from "../../web/render/archive.ts";
9 import { fileKind, HEAD_BYTES, RENDER_CAP } from "../../web/render/kinds.ts";
10
11 const NAME = { zip: "zip", tar: "tar", "tar.gz": "tar.gz" } as const;
12
13 export function archiveNote(
14 path: string,
15 body: Uint8Array,
16 size: number,
17 /** The terminal's own size formatter, so units read as elsewhere. */
18 fmt: (bytes: number) => string,
19 ): string | undefined {
20 if (fileKind(path, body.subarray(0, HEAD_BYTES)) !== "archive") return undefined;
21 if (size > RENDER_CAP.archive) return `${fmt(size)} — ${overSize(RENDER_CAP.archive)}`;
22 const listed = listArchive(body);
23 if ("error" in listed) return listed.error;
24 const n = listed.count;
25 return `${NAME[listed.format]}, ${count(n)} ${n === 1 ? "entry" : "entries"}, ${fmt(listed.bytes)} unpacked.`;
26 }
src/web/pages/repo.ts +15 −4
@@ -84,8 +84,9 @@ import { type CodeRows, rowsMarkup } from "../code_rows.ts";
8484 import { rowsOffThread } from "../code_rows_pool.ts";
8585 import { flashTo, fragment, page, redirect } from "../layout.ts";
8686 import { renderMarkdown } from "../markdown.ts";
87 import { overSize } from "../render/archive.ts";
8788 import { csvJs, isCsvPath } from "../render/csv.ts";
88 import { HEAD_BYTES } from "../render/kinds.ts";
89 import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts";
8990 import { RENDERERS, rendererFor } from "../render/renderers.ts";
9091 import { mediaHref } from "./media.ts";
9192 import { browseBase, localScope, type RepoScope } from "../scope.ts";
@@ -699,6 +700,7 @@ export function blobBody(
699700 highlighted?: (ms: number) => void,
700701 all = false,
701702 ready?: CodeRows,
703 why?: string,
702704 ): { content: Html; lines: number } {
703705 const encoded = encodeURIComponent(ref);
704706 if (blob.kind === "binary") {
@@ -706,8 +708,8 @@ export function blobBody(
706708 lines: 0,
707709 content: empty({
708710 icon: "file",
709 title: "Binary file",
710 note: `${fileSize(blob.size)} — not shown, but you can download it.`,
711 title: why !== undefined ? "Archive" : "Binary file",
outdated

The reason is spliced in by lower-casing the first letter of a sentence, which is brittle. It also leaves the title as "Binary file" for a valid archive that is only too big or damaged. Pass the sentence in ready-made and let the card title say "Archive" in that case. Also, the em dash joins a size to a sentence that already ends in a full stop, which reads awkwardly.

712 note: why !== undefined ? `${fileSize(blob.size)} · ${why}` : `${fileSize(blob.size)} — not shown, but you can download it.`,
711713 action: btn("Download", {
712714 href: downloadHref(base, ref, path),
713715 variant: "primary",
@@ -1095,6 +1097,12 @@ function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<ty
10951097 return found?.render === RENDERERS.pdf && scope.kind !== "local" ? undefined : found;
10961098 }
10971099
1100 /** Why an archive too big to list is not listed, as one sentence. */
1101 function overCap(path: string, blob: Blob): string | undefined {
1102 if (blob.kind !== "binary" || fileKind(path, blob.body.subarray(0, HEAD_BYTES)) !== "archive") return undefined;
1103 return blob.size > RENDER_CAP.archive ? overSize(RENDER_CAP.archive) : undefined;
1104 }
1105
10981106 /** Whether the blob page of this file draws a PDF, and so needs `object-src`. */
10991107 export function drawsPdf(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean {
11001108 return (
@@ -1145,7 +1153,10 @@ export function renderBlob(
11451153 const { content, lines, summary } =
11461154 drawn !== undefined && fellBack === undefined
11471155 ? drawn
1148 : { ...blobBody(base, o.ref, o.path, blob, o.highlighted, o.all, o.rows), summary: undefined };
1156 : {
1157 ...blobBody(base, o.ref, o.path, blob, o.highlighted, o.all, o.rows, fellBack ?? overCap(o.path, blob)),
1158 summary: undefined,
1159 };
11491160 const sizeText =
11501161 blob.kind === "too_large" ? "" : sizeWithTokens(blob.size, o.path);
11511162 const fileHref = `${base}/blob/${encoded}/${o.path}`;
src/web/render/archive-view.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * A .zip, .tar or .tar.gz as a table of what is inside: path, size and
3 * modified date, drawn on the server from `listArchive`. Nothing is unpacked.
4 */
5
6 import { escapeHtml, html, raw } from "../html.ts";
7 import { fileSize } from "../ui.ts";
8 import { count, ENTRY_CAP, listArchive, type Entry } from "./archive.ts";
9 import type { Rendered, RenderInput } from "./renderers.ts";
10
11 /** `2026-11-09 12:30`, UTC, as the archive stores it. */
12 const when = (d: Date | null): string => (d === null ? "" : d.toISOString().slice(0, 16).replace("T", " "));
13
14 /** Control and bidi-override characters would hide or reorder a name. */
15 const printable = (s: string): string =>
16 s.replace(/[\u0000-\u001f\u007f-\u009f‪-‮⁦-⁩]/g, "�");
17
18 /** The folder part muted, the name plain; both escaped. */
19 function name(e: Entry): string {
20 const path = printable(e.path);
21 const trimmed = e.dir ? path.slice(0, -1) : path;
22 const cut = trimmed.lastIndexOf("/") + 1;
23 const tail = escapeHtml(trimmed.slice(cut)) + (e.dir ? "/" : "");
24 return `<span class="muted">${escapeHtml(trimmed.slice(0, cut))}</span>${tail}`;
25 }
26
27 const FORMAT = { zip: "ZIP", tar: "tar", "tar.gz": "tar.gz" } as const;
28
29 export function archive(input: RenderInput): Rendered {
30 const listed = listArchive(input.body);
31 if ("error" in listed) return { content: html``, lines: 0, fallback: listed.error };
32 let rows = "";
33 for (const e of listed.entries)
34 rows += `<tr><td>${name(e)}</td><td class="num">${e.dir ? "" : fileSize(e.size)}</td><td class="num">${when(e.mtime)}</td></tr>`;
35 const left = listed.count - listed.entries.length;
36 const more =
37 left > 0
38 ? ` ${count(left)} more ${left === 1 ? "entry is" : "entries are"} not listed (the page shows the first ${count(ENTRY_CAP)}).`
39 : "";
40 const label = input.path.slice(input.path.lastIndexOf("/") + 1);
41 return {
42 lines: 0,
43 summary: `${fileSize(input.size)} · ${FORMAT[listed.format]}`,

Principles 6 and 7: the table is wrapped in a .card, which is a box around a thing. The list is already rows with a hairline from .table. Drop the card wrapper and keep the hook class on the table. Update the note in docs/design/areas/render.md to match.

44 content: html`<table class="table archive-view" data-dense aria-label="${label} contents">
45 <thead><tr><th>Path</th><th class="num">Size</th><th class="num">Modified</th></tr></thead>
46 <tbody>${raw(rows)}</tbody>
47 <tfoot><tr><td colspan="3" class="muted small">${count(listed.count)} ${listed.count === 1 ? "entry" : "entries"} · ${fileSize(listed.bytes)} unpacked.${more}</td></tr></tfoot>
48 </table>`,
49 };
50 }
src/web/render/archive.ts added +281
@@ -0,0 +1,281 @@
1 /**
2 * The entry list of a .zip, .tar or .tar.gz, and nothing else: names, sizes
3 * and modified times read from the bytes of the archive itself. No entry is
4 * unpacked, written or run; a gzip is inflated in memory under a hard cap,
5 * only so its tar headers can be read.
6 *
7 * Formats: ZIP appnote 6.3.10 (end of central directory §4.3.16, central
8 * directory header §4.3.12, zip64 §4.5.3); tar POSIX.1-2001 ustar, with the
9 * pax `x` (§ extended header) and GNU `L` long-name records.
10 */
11
12 import { gunzipSync } from "node:zlib";
13
14 /** Entries kept for display, whatever the archive holds. */
15 export const ENTRY_CAP = 2_000;
16 /** The most a gzip may inflate to before the list is given up. */
17 export const GUNZIP_CAP = 128 * 1024 * 1024;
18
19 export interface Entry {
20 readonly path: string;
21 /** Unpacked bytes. */
22 readonly size: number;
23 /** Modified time, or null when the archive gives none a person could read. */
24 readonly mtime: Date | null;
25 readonly dir: boolean;
26 }
27
28 export interface Listing {
29 readonly format: "zip" | "tar" | "tar.gz";
30 /** The first `ENTRY_CAP` entries, sorted by path. */
31 readonly entries: readonly Entry[];
32 /** Every entry the archive holds. */
33 readonly count: number;
34 /** Unpacked bytes of every entry, shown or not. */
35 readonly bytes: number;
36 }
37
38 export interface Unreadable {
39 readonly error: string;
40 }
41
42 class Corrupt extends Error {}
43 const corrupt = (): never => {
44 throw new Corrupt();
45 };
46
47 const UTF8 = new TextDecoder("utf-8");
48
49 // ---- zip -----------------------------------------------------------------
50
51 const EOCD = 0x06054b50;
52 const EOCD64 = 0x06064b50;
53 const LOCATOR64 = 0x07064b50;
54 const CENTRAL = 0x02014b50;
55
56 /** MS-DOS date and time, read as UTC; null when the fields are not a date. */
57 function dosTime(date: number, time: number): Date | null {
58 const year = ((date >> 9) & 127) + 1980;
59 const month = (date >> 5) & 15;
60 const day = date & 31;
61 const hour = time >> 11;
62 const minute = (time >> 5) & 63;
63 const second = (time & 31) * 2;
64 if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59)
65 return null;
66 const d = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
67 return d.getUTCDate() === day ? d : null;
68 }
69
70 function readZip(bytes: Uint8Array, keep: number): Omit<Listing, "format"> {
71 const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
72 let eocd = -1;
73 for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) {
74 if (v.getUint32(i, true) === EOCD) {
75 eocd = i;
76 break;
77 }
78 }
79 if (eocd < 0) corrupt();
80 let total = v.getUint16(eocd + 10, true);
81 let offset = v.getUint32(eocd + 16, true);
82 if (total === 0xffff || offset === 0xffffffff) {
83 const loc = eocd - 20;
84 if (loc < 0 || v.getUint32(loc, true) !== LOCATOR64) corrupt();
85 const at = Number(v.getBigUint64(loc + 8, true));
86 if (at < 0 || at + 56 > bytes.length || v.getUint32(at, true) !== EOCD64) corrupt();
87 total = Number(v.getBigUint64(at + 32, true));
88 offset = Number(v.getBigUint64(at + 48, true));
89 }
90 if (offset > eocd) corrupt();
91 const kept: Entry[] = [];
92 let count = 0;
93 let sum = 0;
94 let p = offset;
95 for (let n = 0; n < total; n++) {
96 if (p + 46 > bytes.length || v.getUint32(p, true) !== CENTRAL) corrupt();
97 const flags = v.getUint16(p + 8, true);
98 if (flags & 1) throw new Corrupt("encrypted");
99 let size = v.getUint32(p + 24, true);
100 const nameLen = v.getUint16(p + 28, true);
101 const extraLen = v.getUint16(p + 30, true);
102 const commentLen = v.getUint16(p + 32, true);
103 const end = p + 46 + nameLen + extraLen + commentLen;
104 if (end > bytes.length) corrupt();
105 if (size === 0xffffffff) {
106 // zip64 extra field (id 1): the unpacked size comes first.
107 let e = p + 46 + nameLen;
108 const stop = e + extraLen;
109 while (e + 4 <= stop) {
110 const id = v.getUint16(e, true);
111 const len = v.getUint16(e + 2, true);
112 if (id === 1 && len >= 8 && e + 12 <= stop) {
113 size = Number(v.getBigUint64(e + 4, true));
114 break;
115 }
116 e += 4 + len;
117 }
118 if (size === 0xffffffff) corrupt();
119 }
120 count++;
121 sum += size;
122 if (kept.length < keep) {
123 const path = UTF8.decode(bytes.subarray(p + 46, p + 46 + nameLen));
124 kept.push({
125 path,
126 size,
127 mtime: dosTime(v.getUint16(p + 14, true), v.getUint16(p + 12, true)),
128 dir: path.endsWith("/"),
129 });
130 }
131 p = end;
132 }
133 return { entries: kept, count, bytes: sum };
134 }
135
136 // ---- tar -----------------------------------------------------------------
137
138 const BLOCK = 512;
139
140 const cstr = (b: Uint8Array, from: number, len: number): string => {
141 let end = from;
142 while (end < from + len && b[end] !== 0) end++;
143 return UTF8.decode(b.subarray(from, end));
144 };
145
146 /** An octal field, or GNU base-256 when its top bit is set. */
147 function number(b: Uint8Array, from: number, len: number): number {
148 if (b[from]! & 0x80) {
149 let n = b[from]! & 0x7f;
150 for (let i = 1; i < len; i++) n = n * 256 + b[from + i]!;
151 return n;
152 }
153 const text = cstr(b, from, len).trim();
154 if (text === "") return 0;
155 if (!/^[0-7]+$/.test(text)) return corrupt();
156 return parseInt(text, 8);
157 }
158
159 /** The header checksum: every byte summed, the checksum field as spaces. */
160 function checksumOk(b: Uint8Array, at: number): boolean {
161 let sum = 0;
162 for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : b[at + i]!;
163 return sum === number(b, at + 148, 8);
164 }
165
166 /** pax records: `len key=value\n`, the length counting itself. */
167 function pax(data: Uint8Array): Map<string, string> {
168 const out = new Map<string, string>();
169 let p = 0;
170 while (p < data.length) {
171 let q = p;
172 while (q < data.length && data[q] !== 0x20) q++;
173 const len = parseInt(UTF8.decode(data.subarray(p, q)), 10);
174 if (!(len > q - p + 1) || p + len > data.length) corrupt();
175 const rec = UTF8.decode(data.subarray(q + 1, p + len - 1));
176 const eq = rec.indexOf("=");
177 if (eq > 0) out.set(rec.slice(0, eq), rec.slice(eq + 1));
178 p += len;
179 }
180 return out;
181 }
182
183 function readTar(b: Uint8Array, keep: number): Omit<Listing, "format"> {
184 const kept: Entry[] = [];
185 let count = 0;
186 let sum = 0;
187 let p = 0;
188 let longName: string | undefined;
189 let override = new Map<string, string>();
190 while (p + BLOCK <= b.length) {
191 if (b.subarray(p, p + BLOCK).every((x) => x === 0)) break;
192 if (!checksumOk(b, p)) corrupt();
193 const type = String.fromCharCode(b[p + 156]!);
194 let size = number(b, p + 124, 12);
195 const data = p + BLOCK;
196 if (data + size > b.length) corrupt();
197 const next = data + Math.ceil(size / BLOCK) * BLOCK;
198 if (type === "x") {
199 override = pax(b.subarray(data, data + size));
200 } else if (type === "L") {
201 longName = cstr(b, data, size);
202 } else if (type !== "g") {
203 const ustar = cstr(b, p + 257, 5) === "ustar";
204 const prefix = ustar ? cstr(b, p + 345, 155) : "";
205 const base = cstr(b, p, 100);
206 let path = longName ?? override.get("path") ?? (prefix ? `${prefix}/${base}` : base);
207 const sizeOverride = override.get("size");
208 if (sizeOverride !== undefined && /^\d+$/.test(sizeOverride)) size = Number(sizeOverride);
209 const mt = override.get("mtime");
210 const secs = mt !== undefined && /^\d+(\.\d+)?$/.test(mt) ? Number(mt) : number(b, p + 136, 12);
211 const dir = type === "5" || path.endsWith("/");
212 if (dir && !path.endsWith("/")) path += "/";
213 const shown = type === "0" || type === "\0" || type === "7" ? size : 0;
214 count++;
215 sum += shown;
216 if (kept.length < keep) {
217 const t = new Date(secs * 1000);
218 kept.push({ path, size: shown, mtime: secs > 0 && !Number.isNaN(t.getTime()) ? t : null, dir });
219 }
220 longName = undefined;
221 override = new Map();
222 }
223 p = next;
224 }
225 if (count === 0) corrupt();
226 return { entries: kept, count, bytes: sum };
227 }
228
229 // ---- entry point -----------------------------------------------------------
230
231 const isZip = (b: Uint8Array): boolean => b[0] === 0x50 && b[1] === 0x4b && b[2] === 3 && b[3] === 4;
232 const isGzip = (b: Uint8Array): boolean => b[0] === 0x1f && b[1] === 0x8b;
233
234 const byPath = (a: Entry, b: Entry): number => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
235
236 /** A count with thousands grouped by commas, whatever the server locale. */
237 export const count = (n: number): string => String(n).replace(/\B(?=(\d{3})+$)/g, ",");
238
239 const mb = (n: number): string => `${count(Math.round(n / 1_000_000))} MB`;
240
241 /** The sentence for an archive over the size the page reads. */
242 export const overSize = (cap: number): string =>
243 `This archive is over ${count(Math.round(cap / 1_000_000))} MB, too large to list here. Download it instead.`;
244
245 /** The list of what an archive holds, or one sentence on why it cannot be. */
246 export function listArchive(
247 body: Uint8Array,
248 opts: { readonly cap?: number; readonly entries?: number } = {},
249 ): Listing | Unreadable {
250 const cap = opts.cap ?? GUNZIP_CAP;
251 const keep = opts.entries ?? ENTRY_CAP;
252 try {
253 let format: Listing["format"];
254 let part: Omit<Listing, "format">;
255 if (isZip(body)) {
256 format = "zip";
257 part = readZip(body, keep);
outdated

The tar.gz size error says "unpacks to more than 128 MB" using toLocaleString, so the wording depends on the server locale. Format it without locale. The same applies to the counts in archive-view.ts.

258 } else if (isGzip(body)) {
259 format = "tar.gz";
260 let tar: Uint8Array;
261 try {
262 tar = gunzipSync(body, { maxOutputLength: cap });
263 } catch (e) {
264 if ((e as { code?: string }).code === "ERR_BUFFER_TOO_LARGE")
265 return { error: `This archive unpacks to more than ${mb(cap)}, so its list is not read here.` };
266 return { error: "This archive is damaged, so its list cannot be read." };
267 }
268 part = readTar(tar, keep);
269 } else {
270 format = "tar";
271 part = readTar(body, keep);
272 }
273 return { format, ...part, entries: [...part.entries].sort(byPath) };
274 } catch (e) {
275 if (e instanceof Corrupt && e.message === "encrypted")
276 return { error: "This zip is encrypted, so its list is not shown." };
277 if (e instanceof Corrupt || e instanceof RangeError)
278 return { error: "This archive is damaged or cut short, so its list cannot be read." };
279 throw e;
280 }
281 }
src/web/render/kinds.ts +24
@@ -20,6 +20,7 @@ export type FileKind =
2020 | "video"
2121 | "csv"
2222 | "model3d"
23 | "archive"
2324 | "config"
2425 | "markdown"
2526 | "text"
@@ -40,6 +41,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
4041 video: 4_000 * MB,
4142 csv: 10 * MB,
4243 model3d: 100 * MB,
44 archive: 50 * MB,
4345 config: 1 * MB,
4446 markdown: 1 * MB,
4547 text: 1 * MB,
@@ -134,6 +136,24 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
134136 mdx: "markdown",
135137 };
136138
139 /**
140 * Archives: the extension names the kind and the bytes confirm it. Their
141 * media type is never an archive type; the media route sends octet-stream.
142 * `gz` counts only as the end of `.tar.gz`.
143 */
144 const ARCHIVES: Readonly<Record<string, Magic>> = {
145 zip: bytes(0x50, 0x4b, 0x03, 0x04),
146 tar: ascii("ustar", 257),
147 tgz: bytes(0x1f, 0x8b),
148 gz: bytes(0x1f, 0x8b),
149 };
150
151 const isArchive = (path: string, head: Uint8Array): boolean => {
152 const ext = extension(path);
153 if (ext === "gz" && !/\.tar\.gz$/i.test(path)) return false;
154 return ARCHIVES[ext]?.(head) === true;
155 };
156
137157 /** For a caller that has only a name: the kind its extension gives. */
138158 export const NO_BYTES: Uint8Array = new Uint8Array(0);
139159
@@ -165,6 +185,7 @@ function confirmed(path: string, head: Uint8Array): Media | undefined {
165185 export function fileKind(path: string, head: Uint8Array): FileKind {
166186 const media = confirmed(path, head);
167187 if (media !== undefined) return media.kind;
188 if (isArchive(path, head)) return "archive";
168189 if (looksBinary(head)) return "binary";
169190 return TEXTUAL[extension(path)] ?? "text";
170191 }
@@ -193,4 +214,7 @@ export function mediaType(path: string, head: Uint8Array): string {
193214 export const KNOWN_EXTENSIONS: readonly string[] = [
194215 ...Object.keys(MEDIA),
195216 ...Object.keys(TEXTUAL),
217 "zip",
218 "tar",
219 "tgz",
196220 ];
src/web/render/renderers.ts +2
@@ -6,6 +6,7 @@
66
77 import type { Html } from "../html.ts";
88 import type { Blob } from "../../view/source.ts";
9 import { archive } from "./archive-view.ts";
910 import { audio } from "./audio.ts";
1011 import { config } from "./config.ts";
1112 import { isFormConfig } from "./config-format.ts";
@@ -54,6 +55,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
5455 audio,
5556 video,
5657 config,
58 archive,
5759 };
5860
5961 /**
test/render-archive.test.ts added +205
@@ -0,0 +1,205 @@
1 /** Archives listed, not unpacked: zip, tar, tar.gz, and the limits on each. */

Add a test for the TUI path once it exists. Also add one for the over-50 MB fallback sentence, which is only checked indirectly through rendererFor, and one for a name with bidi or control characters, since printable has no test.

2 import { describe, expect, test } from "bun:test";
3 import { gzipSync } from "node:zlib";
4 import { ENTRY_CAP, listArchive } from "../src/web/render/archive.ts";
5 import { archive } from "../src/web/render/archive-view.ts";
6 import { fileKind } from "../src/web/render/kinds.ts";
7 import { rendererFor, RENDERERS } from "../src/web/render/renderers.ts";
8
9 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
10 const cat = (...parts: Uint8Array[]): Uint8Array => {
11 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
12 let o = 0;
13 for (const p of parts) (out.set(p, o), (o += p.length));
14 return out;
15 };
16
17 interface F { name: string; size?: number; dir?: boolean }
18
19 /** A zip with only a central directory, as the lister reads it. */
20 function zip(files: F[], flags = 0): Uint8Array {
21 const dosDate = ((2026 - 1980) << 9) | (11 << 5) | 9;
22 const dosTime = (12 << 11) | (30 << 5);
23 const central: Uint8Array[] = [];
24 for (const f of files) {
25 const name = enc(f.name);
26 const h = new Uint8Array(46);
27 const v = new DataView(h.buffer);
28 v.setUint32(0, 0x02014b50, true);
29 v.setUint16(8, flags, true);
30 v.setUint16(12, dosTime, true);
31 v.setUint16(14, dosDate, true);
32 v.setUint32(24, f.size ?? 0, true);
33 v.setUint16(28, name.length, true);
34 central.push(cat(h, name));
35 }
36 const dir = cat(...central);
37 const local = enc("PK\x03\x04");
38 const e = new Uint8Array(22);
39 const v = new DataView(e.buffer);
40 v.setUint32(0, 0x06054b50, true);
41 v.setUint16(8, files.length, true);
42 v.setUint16(10, files.length, true);
43 v.setUint32(12, dir.length, true);
44 v.setUint32(16, local.length, true);
45 return cat(local, dir, e);
46 }
47
48 function tarHeader(name: string, size: number, type = "0"): Uint8Array {
49 const h = new Uint8Array(512);
50 h.set(enc(name), 0);
51 h.set(enc(size.toString(8).padStart(11, "0")), 124);
52 h.set(enc(Math.floor(Date.UTC(2026, 10, 9, 12, 30) / 1000).toString(8).padStart(11, "0")), 136);
53 h.set(enc(" "), 148);
54 h[156] = type.charCodeAt(0);
55 h.set(enc("ustar\x0000"), 257);
56 let sum = 0;
57 for (const b of h) sum += b;
58 h.set(enc(sum.toString(8).padStart(6, "0") + "\0 "), 148);
59 return h;
60 }
61 function tar(files: F[]): Uint8Array {
62 const parts: Uint8Array[] = [];
63 for (const f of files) {
64 const size = f.dir ? 0 : (f.size ?? 0);
65 parts.push(tarHeader(f.name, size, f.dir ? "5" : "0"));
66 parts.push(new Uint8Array(Math.ceil(size / 512) * 512));
67 }
68 parts.push(new Uint8Array(1024));
69 return cat(...parts);
70 }
71
72 const ok = (r: ReturnType<typeof listArchive>) => {
73 if ("error" in r) throw new Error(r.error);
74 return r;
75 };
76
77 describe("archive lists", () => {
78 test("a zip: sorted by path, sizes summed, DOS date read", () => {
79 const r = ok(listArchive(zip([{ name: "b.txt", size: 20 }, { name: "a/", dir: true }, { name: "a/c.txt", size: 5 }])));
80 expect(r.format).toBe("zip");
81 expect(r.entries.map((e) => e.path)).toEqual(["a/", "a/c.txt", "b.txt"]);
82 expect(r.entries[0]!.dir).toBe(true);
83 expect(r.count).toBe(3);
84 expect(r.bytes).toBe(25);
85 expect(r.entries[2]!.mtime?.toISOString()).toBe("2026-11-09T12:30:00.000Z");
86 });
87 test("a tar: headers read, directories have no size", () => {
88 const r = ok(listArchive(tar([{ name: "src", dir: true }, { name: "src/x.ts", size: 700 }])));
89 expect(r.format).toBe("tar");
90 expect(r.entries.map((e) => [e.path, e.size, e.dir])).toEqual([["src/", 0, true], ["src/x.ts", 700, false]]);
91 expect(r.bytes).toBe(700);
92 expect(r.entries[1]!.mtime?.toISOString()).toBe("2026-11-09T12:30:00.000Z");
93 });
94 test("a tar.gz: inflated in memory, then read as tar", () => {
95 const r = ok(listArchive(gzipSync(tar([{ name: "a.txt", size: 3 }]))));
96 expect(r.format).toBe("tar.gz");
97 expect(r.entries.map((e) => e.path)).toEqual(["a.txt"]);
98 });
99 });
100
101 describe("archive limits", () => {
102 test("a truncated zip says why instead of failing", () => {
103 const z = zip([{ name: "a", size: 1 }]);
104 const r = listArchive(z.subarray(0, z.length - 10));
105 expect("error" in r && r.error).toMatch(/damaged or cut short/);
106 });
107 test("an encrypted zip is not listed", () => {
108 const r = listArchive(zip([{ name: "a" }], 1));
109 expect("error" in r && r.error).toMatch(/encrypted/);
110 });
111 test("a corrupt tar header fails its checksum", () => {
112 const t = tar([{ name: "a", size: 1 }]);
113 t[0] = 0x7a;
114 expect("error" in listArchive(t)).toBe(true);
115 });
116 test("a gzip bomb stops at the cap", () => {
117 const bomb = gzipSync(new Uint8Array(8 * 1024 * 1024));
118 expect(bomb.length).toBeLessThan(20_000);
119 const r = listArchive(bomb, { cap: 1024 * 1024 });
120 expect("error" in r && r.error).toMatch(/unpacks to more than/);
121 });
122 test("more than 2,000 entries: the first 2,000, and the rest counted", () => {
123 const files = Array.from({ length: ENTRY_CAP + 25 }, (_, i) => ({ name: `f${i}`, size: 1 }));
124 const r = ok(listArchive(zip(files)));
125 expect(r.entries.length).toBe(ENTRY_CAP);
126 expect(r.count).toBe(ENTRY_CAP + 25);
127 expect(r.bytes).toBe(ENTRY_CAP + 25);
128 const page = archive({ path: "x.zip", size: 1, body: zip(files), media: "/m" });
129 expect(String(page.content)).toContain("25 more entries are not listed");
130 });
131 });
132
133 describe("archive page", () => {
134 const draw = (path: string, body: Uint8Array) => archive({ path, size: body.length, body, media: "/m" });
135 test("a name that is markup is escaped", () => {
136 const out = String(draw("x.zip", zip([{ name: "<script>alert(1)</script>", size: 1 }])).content);
137 expect(out).not.toContain("<script>");
138 expect(out).toContain("&lt;script&gt;");
139 });
140 test("a footer gives the count and unpacked size", () => {
141 const out = String(draw("x.tar", tar([{ name: "a", size: 1000 }, { name: "b", size: 1000 }])).content);
142 expect(out).toContain("2 entries");
143 expect(out).toContain("unpacked");
144 });
145 test("a damaged archive falls back with a reason, never throws", () => {
146 const r = draw("x.zip", enc("PK\x03\x04garbage"));
147 expect(r.fallback).toMatch(/damaged/);
148 });
149 test("the extension names the kind and the bytes must agree", () => {
150 const z = zip([{ name: "a" }]);
151 expect(fileKind("a.zip", z)).toBe("archive");
152 expect(fileKind("a.zip", enc("not a zip"))).toBe("text");
153 expect(fileKind("a.tar.gz", gzipSync(enc("x")))).toBe("archive");
154 expect(fileKind("a.tgz", gzipSync(enc("x")))).toBe("archive");
155 expect(fileKind("a.gz", gzipSync(enc("x")))).toBe("binary");
156 expect(fileKind("a.tar", tar([{ name: "a" }]))).toBe("archive");
157 });
158 test("a blob page reaches it, and not past the inline limit", () => {
159 const z = zip([{ name: "a" }]);
160 const hit = rendererFor("a.zip", { kind: "binary", body: z, size: z.length });
161 expect(hit?.render).toBe(RENDERERS.archive!);
162 expect(rendererFor("a.zip", { kind: "binary", body: z, size: 60_000_000 })).toBeUndefined();
163 });
164 });
165
166 describe("wording and the terminal", () => {
167 test("counts do not depend on the locale", async () => {
168 const { count } = await import("../src/web/render/archive.ts");
169 expect(count(0)).toBe("0");
170 expect(count(2000)).toBe("2,000");
171 expect(count(1234567)).toBe("1,234,567");
172 });
173
174 test("the over-50 MB sentence", async () => {
175 const { overSize } = await import("../src/web/render/archive.ts");
176 expect(overSize(50_000_000)).toBe("This archive is over 50 MB, too large to list here. Download it instead.");
177 });
178
179 test("a name with control and bidi characters is shown replaced", () => {
180 const z = zip([{ name: "a‮b\u0007c.txt", size: 1 }]);
181 const out = archive({ path: "x.zip", body: z, size: z.length } as never).content.toString();
182 expect(out).not.toContain("‮");
183 expect(out).not.toContain("\u0007");
184 expect(out).toContain("a�b�c.txt");
185 });
186
187 test("the table is not inside a card", () => {
188 const z = zip([{ name: "a.txt", size: 1 }]);
189 const out = archive({ path: "x.zip", body: z, size: z.length } as never).content.toString();
190 expect(out).not.toContain("card");
191 expect(out).toContain('class="table archive-view"');
192 });
193
194 test("the terminal note gives count and unpacked size, gated like the page", async () => {
195 const { archiveNote } = await import("../src/tui/ui/archive-note.ts");
196 const fmt = (n: number): string => `${n} B`;
197 const z = zip([{ name: "a.txt", size: 10 }, { name: "b/c.txt", size: 5 }]);
198 expect(archiveNote("x.zip", z, z.length, fmt)).toBe("zip, 2 entries, 15 B unpacked.");
199 expect(archiveNote("x.txt", z, z.length, fmt)).toBeUndefined();
200 expect(archiveNote("x.zip", enc("not a zip at all"), 16, fmt)).toBeUndefined();
201 expect(archiveNote("x.zip", z, 60_000_000, fmt)).toContain("over 50 MB");
202 const bad = z.slice(0, z.length - 10);
203 expect(archiveNote("x.zip", bad, bad.length, fmt)).toContain("damaged");
204 });
205 });
test/render-kinds.test.ts +3
@@ -47,6 +47,9 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
4747 toml: [enc("a = 1\n"), "config", "application/octet-stream"],
4848 ini: [enc("[a]\n"), "config", "application/octet-stream"],
4949 conf: [enc("a 1\n"), "config", "application/octet-stream"],
50 zip: [of(0x50, 0x4b, 3, 4, 0), "archive", "application/octet-stream"],
51 tar: [enc("x".repeat(257) + "ustar\0"), "archive", "application/octet-stream"],
52 tgz: [of(0x1f, 0x8b, 8), "archive", "application/octet-stream"],
5053 md: [enc("# a\n"), "markdown", "application/octet-stream"],
5154 markdown: [enc("# a\n"), "markdown", "application/octet-stream"],
5255 mdx: [enc("# a\n"), "markdown", "application/octet-stream"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs