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

The blob page draws a PDF, image or video over 1 MB: its kind is read from the first bytes, never the whole file #7461

Merged opened this

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

Merged

mason/forge:mason/7384-the-blob-page-draws-a-pdf-image-or-video → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/7384-the-blob-page-draws-a-pdf-image-or-video was merged into main as 8ee195f.

Review

Sign in to review this change.
8 files changed +288 −21
docs/features/file-media.md +14 −6
@@ -72,12 +72,20 @@ QuickTime files are inside, for browsers that refuse `video/quicktime`.
7272 - **In the terminal** an audio or video file is not played; the file pane
7373 says so, names the format and size, and prints the media route on the
7474 forge's address (`src/tui/ui/media-note.ts`).
75 - **A long file:** the page reads at most 1 MB of a file. Audio and video are
76 drawn from the media route alone (`STREAMED` in `render/renderers.ts`), so
77 past that a file is drawn by its extension (`claimedMedia`) and its bytes
78 are confirmed by the media route, which serves a contradicted one as
79 `application/octet-stream` and the player shows the note. Under 1 MB, the
80 bytes decide first, and a `.mp4` that is text keeps the source view.
75 - **A long file:** the page reads at most 1 MB of a file's body. Media
76 kinds (image, svg, pdf, audio, video, model3d — `MEDIA_KINDS` in
77 `render/renderers.ts`) are drawn from the media route alone, so they are
78 bounded by `RENDER_CAP`, not by that ceiling: past it the page reads only
79 the first `HEAD_BYTES` (`blobHead` in `src/git/read.ts`, which stops
80 `cat-file` once they are in) and the size, `fileKind` decides, and the
81 renderer gets an empty `body` and the `media` address. A `.pdf` whose
82 bytes are text keeps the over-cap note. Where the source gave no bytes (a
83 mirrored origin), the extension decides (`claimedMedia`) and the media
84 route confirms the bytes, serving a contradicted file as
85 `application/octet-stream`, and the player shows the note.
86 - **Text kinds** (markdown, csv, config, text) and binary keep the page's
87 1 MB ceiling: over it the page says "over the render cap" and offers
88 Download.
8189 - Over `RENDER_CAP` (500 MB audio, 4 GB video) the page offers Download.
8290
8391 ## Adding a renderer
src/git/read.ts +38
@@ -372,6 +372,44 @@ async function viaReader(
372372 }
373373 }
374374
375 /**
376 * A blob's first `bytes` bytes, and never one more off the pipe: `cat-file`
377 * is stopped once they are in, so a 4 GB file costs what its head does. For
378 * a page that must say what a file too large to read is.
379 */
380 export async function blobHead(
381 dir: string,
382 rev: string,
383 path: string,
384 bytes: number,
385 ): Promise<Uint8Array | undefined> {
386 const spec = `${rev}:${path}`;
387 assertBatchSafe(spec);
388 const proc = Bun.spawn(["git", "cat-file", "blob", spec], {
389 cwd: dir,
390 env: gitEnv(),
391 stdin: "ignore",
392 stdout: "pipe",
393 stderr: "ignore",
394 });
395 const reader = proc.stdout.getReader();
396 const head = new Uint8Array(bytes);
397 let filled = 0;
398 try {
399 while (filled < bytes) {
400 const next = await reader.read();
401 if (next.done || next.value === undefined) break;
402 const take = Math.min(next.value.length, bytes - filled);
403 head.set(next.value.subarray(0, take), filled);
404 filled += take;
405 }
406 } finally {
407 proc.kill("SIGKILL");
408 }
409 if (filled === 0 && (await proc.exited) !== 0) return undefined;
410 return head.subarray(0, filled);
411 }
412
375413 function classify(body: Uint8Array, size: number, oid: string): BlobForRender {
376414 // NUL in the first 8KB is git's own binary heuristic.
377415 if (body.subarray(0, 8000).includes(0))
src/view/local.ts +6 −1
@@ -32,6 +32,7 @@ import { diffCommit, type Diff } from "../git/diff.ts";
3232 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
3333 import {
3434 blobForRender,
35 blobHead,
3536 commitLog,
3637 listRefs,
3738 listTree,
@@ -42,6 +43,7 @@ import {
4243 import { repoDir } from "../git/repos.ts";
4344 import { err, ok, type Result } from "../kernel/result.ts";
4445 import type { Page } from "../origins/types.ts";
46 import { HEAD_BYTES } from "../web/render/kinds.ts";
4547 import type {
4648 Blob,
4749 Branch,
@@ -201,7 +203,10 @@ export function localSource(
201203 if (!isSafeRef(ref) || !isSafeTreePath(path)) return err("not_found");
202204 const blob = await blobForRender(dir, ref, path, MAX_BLOB_BYTES);
203205 if (blob.kind === "missing") return err("not_found");
204 return ok(blob);
206 if (blob.kind !== "too_large") return ok(blob);
207 // Too large to read, but its first bytes still say what it is.
208 const head = await blobHead(dir, ref, path, HEAD_BYTES);
209 return ok(head === undefined ? blob : { ...blob, head });
205210 },
206211
207212 async readme(ref) {
src/view/source.ts +9 −1
@@ -121,7 +121,15 @@ export type Blob =
121121 readonly body: Uint8Array;
122122 readonly size: number;
123123 }
124 | { readonly kind: "too_large"; readonly size: number };
124 | {
125 readonly kind: "too_large";
126 readonly size: number;
127 /**
128 * Its first bytes (`HEAD_BYTES`), when the source read them: enough to
129 * say what the file is, never the file.
130 */
131 readonly head?: Uint8Array;
132 };
125133
126134 /** An issue or a pull, as a list row. */
127135 export interface IssueSummary {
src/web/pages/repo.ts +7 −3
@@ -71,6 +71,7 @@ import { field, readForm } from "../form.ts";
7171 import { html, when, type Html } from "../html.ts";
7272 import { flashTo, fragment, page, redirect } from "../layout.ts";
7373 import { renderMarkdown } from "../markdown.ts";
74 import { HEAD_BYTES } from "../render/kinds.ts";
7475 import { rendererFor } from "../render/renderers.ts";
7576 import { mediaHref } from "./media.ts";
7677 import { browseBase, localScope, type RepoScope } from "../scope.ts";
@@ -1514,10 +1515,13 @@ export async function pathsJson(ctx: Ctx): Promise<Response> {
15141515 );
15151516 }
15161517
1517 /** The page's own render ceiling, under the source's fetch ceiling. */
1518 function capped(blob: Blob): Blob {
1518 /**
1519 * The page's own render ceiling, under the source's fetch ceiling. A body
1520 * over it is dropped and only its head kept, so a media kind is still drawn.
1521 */
1522 export function capped(blob: Blob): Blob {
15191523 return blob.kind !== "too_large" && blob.size > MAX_RENDER_BYTES
1520 ? { kind: "too_large", size: blob.size }
1524 ? { kind: "too_large", size: blob.size, head: blob.body.slice(0, HEAD_BYTES) }
15211525 : blob;
15221526 }
15231527
src/web/render/renderers.ts +21 −9
@@ -17,7 +17,7 @@ export interface RenderInput {
1717 readonly size: number;
1818 /**
1919 * The whole body, when the page read it; empty for a file over the page's
20 * read ceiling, which only a `STREAMED` kind is drawn from.
20 * read ceiling, which only a media kind (`MEDIA_KINDS`) is drawn from.
2121 */
2222 readonly body: Uint8Array;
2323 /** `/:owner/:name/media/:ref/*path`, for `<img>`, `<video>` and `fetch()`. */
@@ -42,23 +42,35 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
4242
4343 /**
4444 * Kinds drawn wholly from the media route, never from the body: a file of
45 * one of these too large for the page to read is still drawn, by its name,
46 * and the media route confirms its bytes before it serves them as media.
45 * one of these too large for the page to read is still drawn up to its
46 * `RENDER_CAP`, decided by its first bytes (or by its name, when the source
47 * gave none), and the media route confirms its bytes before it serves them.
48 * Every other kind is bounded by the page's own read ceiling.
4749 */
48 const STREAMED: ReadonlySet<FileKind> = new Set<FileKind>(["audio", "video"]);
50 const MEDIA_KINDS: ReadonlySet<FileKind> = new Set<FileKind>([
51 "image",
52 "svg",
53 "pdf",
54 "audio",
55 "video",
56 "model3d",
57 ]);
4958
5059 /** The renderer for a file and the bytes it is handed, or none: the source view. */
5160 export function rendererFor(
5261 path: string,
5362 blob: Blob,
63 renderers: Partial<Readonly<Record<FileKind, Renderer>>> = RENDERERS,
5464 ): { render: Renderer; body: Uint8Array } | undefined {
5565 const kind =
56 blob.kind === "too_large"
57 ? claimedMedia(path)?.kind
58 : fileKind(path, blob.body.subarray(0, HEAD_BYTES));
66 blob.kind !== "too_large"
67 ? fileKind(path, blob.body.subarray(0, HEAD_BYTES))
68 : blob.head !== undefined
69 ? fileKind(path, blob.head)
70 : claimedMedia(path)?.kind;
5971 if (kind === undefined || blob.size > RENDER_CAP[kind]) return undefined;
60 if (blob.kind === "too_large" && !STREAMED.has(kind)) return undefined;
61 const render = RENDERERS[kind];
72 if (blob.kind === "too_large" && !MEDIA_KINDS.has(kind)) return undefined;
73 const render = renderers[kind];
6274 if (render === undefined) return undefined;
6375 return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body };
6476 }
test/blob-media-ceiling.test.ts added +192
@@ -0,0 +1,192 @@
1 /**
2 * A media file over the page's 1 MB read ceiling still reaches its renderer,
3 * up to its kind's `RENDER_CAP` (#7384): its kind is read from its first
4 * bytes, never from the whole file. A text kind keeps the 1 MB ceiling.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { blobHead } from "../src/git/read.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { repoDir } from "../src/git/repos.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { localSource } from "../src/view/local.ts";
18 import type { Blob } from "../src/view/source.ts";
19 import { blobBody, capped } from "../src/web/pages/repo.ts";
20 import { HEAD_BYTES } from "../src/web/render/kinds.ts";
21 import { rendererFor, type RenderInput, type Renderer } from "../src/web/render/renderers.ts";
22 import { seedAdmin } from "./helpers/admin.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24
25 quietLogs();
26
27 const MB = 1_000_000;
28 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
29 /** `size` bytes opening on `head`, the rest filler of the same nature. */
30 const filled = (head: string, size: number, fill: number): Uint8Array => {
31 const out = new Uint8Array(size).fill(fill);
32 out.set(enc(head), 0);
33 return out;
34 };
35
36 const PDF = filled("%PDF-1.7\n", 3 * MB, 0);
outdated

Use ~6 MB instead of 50 MB: enough to pass the 5 MB fetch ceiling, far lighter on memory and time.

37 const MARKDOWN = filled("# Notes\n\n", 3 * MB, 0x61);
38 /** Just over the source's own 5 MB fetch ceiling, so only the head is ever read. */
39 const BIG_PDF = filled("%PDF-1.7\n", 6 * MB, 0);
40 const BIG_VIDEO = filled("\0\0\0\x20ftypisom", 6 * MB, 0);
41 /** A `.mp4` whose bytes are words: its bytes, not its name, decide. */
42 const WORDS_MP4 = filled("not a video, just words\n", 3 * MB, 0x61);
43
44 const blob = (body: Uint8Array): Blob => ({ kind: "binary", body, size: body.length });
45
46 /** A renderer that records what it was handed. */
47 function stub(): { render: Renderer; seen: RenderInput[] } {
48 const seen: RenderInput[] = [];
49 return { seen, render: (input) => (seen.push(input), { content: "" as never, lines: 0 }) };
50 }
51
52 describe("the page ceiling, by kind", () => {
53 test("a 3 MB PDF reaches its renderer with an empty body", () => {
54 const pdf = stub();
55 const found = rendererFor("doc/big.pdf", capped(blob(PDF)), { pdf: pdf.render });
56 expect(found).toBeDefined();
57 found!.render({ path: "doc/big.pdf", size: PDF.length, body: found!.body, media: "/m" });
58 expect(pdf.seen).toHaveLength(1);
59 expect(pdf.seen[0]!.body.length).toBe(0);
60 expect(pdf.seen[0]!.media).toBe("/m");
61 });
62
63 test("capped keeps only the head of a body it drops", () => {
64 const cut = capped(blob(PDF));
65 expect(cut.kind).toBe("too_large");
66 if (cut.kind !== "too_large") return;
67 expect(cut.size).toBe(PDF.length);
68 expect(cut.head?.length).toBe(HEAD_BYTES);
69 });
70
71 test("a 3 MB Markdown file still shows the over-cap note", () => {
72 const md = stub();
73 const cut = capped({ kind: "text", body: MARKDOWN, size: MARKDOWN.length });
74 expect(rendererFor("notes.md", cut, { markdown: md.render })).toBeUndefined();
75 expect(md.seen).toHaveLength(0);
76 expect(String(blobBody("/a/r", "main", "notes.md", cut).content)).toContain(
77 "over the render cap",
78 );
79 });
80
81 test("a media name its head contradicts is not drawn", () => {
82 const video = stub();
83 expect(rendererFor("words.mp4", capped(blob(WORDS_MP4)), { video: video.render })).toBeUndefined();
84 });
85
86 test("over its kind's cap, a media file is not drawn", () => {
87 const head = PDF.subarray(0, HEAD_BYTES);
88 const pdf = stub();
89 expect(rendererFor("x.pdf", { kind: "too_large", size: 100 * MB + 1, head }, { pdf: pdf.render })).toBeUndefined();
90 expect(rendererFor("x.pdf", { kind: "too_large", size: 100 * MB, head }, { pdf: pdf.render })).toBeDefined();
91 });
92 });
93
94 describe("a large blob on the page", () => {
95 let dataDir = "";
96 let app: App;
97 let servers: Servers;
98 let base = "";
99 let dir = "";
100 let repo: Repo;
101
102 beforeAll(async () => {
103 dataDir = await mkdtemp(join(tmpdir(), "forge-blob-ceiling-"));
104 app = createApp({ dataDir, port: 0 });
105 servers = await serve(app);
106 base = servers.url;
107 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-ceiling-passphrase" });
108 if (!a.ok) throw new Error("signup failed");
109 const created = await createRepo(app, a.value, { name: "big", visibility: "public" });
110 if (!created.ok) throw new Error("repo failed");
111 repo = created.value;
112 dir = repoDir(app.reposRoot, "alice", "big");
113 // Over the commit route's file ceiling, so the files go in by plumbing.
114 const made = await commitToBranch(app, a.value, repo, {
115 message: "start",
116 files: [{ path: "README.md", content: "# big\n" }],
117 });
118 if (!made.ok) throw new Error("commit failed");
119 const git = (args: string[], stdin?: Uint8Array | string): string => {
120 const input = typeof stdin === "string" ? enc(stdin) : (stdin ?? "ignore");
121 const run = Bun.spawnSync(["git", ...args], {
122 cwd: dir,
123 stdin: input,
124 // The check's machine has no git identity; the commit names its own.
125 env: {
126 ...process.env,
127 GIT_AUTHOR_NAME: "alice",
128 GIT_AUTHOR_EMAIL: "[email protected]",
129 GIT_COMMITTER_NAME: "alice",
130 GIT_COMMITTER_EMAIL: "[email protected]",
131 },
132 });
133 if (run.exitCode !== 0) throw new Error(`git ${args[0]}: ${run.stderr.toString()}`);
134 return run.stdout.toString().trim();
135 };
136 const files: Record<string, Uint8Array> = {
137 "big.pdf": BIG_PDF,
138 "big.mp4": BIG_VIDEO,
139 "words.mp4": WORDS_MP4,
140 "notes.md": MARKDOWN,
141 };
142 const lines = [git(["ls-tree", "main"])];
143 for (const [name, body] of Object.entries(files))
144 lines.push(`100644 blob ${git(["hash-object", "-w", "--stdin"], body)}\t${name}`);
145 const tree = git(["mktree"], lines.join("\n") + "\n");
146 const commit = git(["commit-tree", tree, "-p", "main", "-m", "big files"]);
147 git(["update-ref", "refs/heads/main", commit]);
148 }, 60_000);
149
150 afterAll(async () => {
151 await servers?.stop();
outdated

Wall-clock assertion: drop it (flake). Assert on content instead.

152 app?.close();
153 await rm(dataDir, { recursive: true, force: true });
154 });
155
156 test("blobHead reads the first bytes and no more", async () => {
157 const head = await blobHead(dir, "main", "big.pdf", HEAD_BYTES);
158 expect(head?.length).toBe(HEAD_BYTES);
159 expect(new TextDecoder().decode(head!.subarray(0, 5))).toBe("%PDF-");
160 expect(await blobHead(dir, "main", "missing.pdf", HEAD_BYTES)).toBeUndefined();
161 });
162
163 test("a video past the source's ceiling is drawn from its head", async () => {
outdated

Drop the timing bound and assert the response lacks "over the render cap"; status 200 alone passes without the change.

164 const res = await fetch(`${base}/alice/big/blob/main/big.mp4`);
165 const text = await res.text();
166 expect(res.status).toBe(200);
167 expect(text).toContain("<video");
168 expect(text).toContain('src="/alice/big/media/main/big.mp4"');
169 expect(text).not.toContain("over the render cap");
170 expect(text.length).toBeLessThan(200_000);
171 });
172
173 test("a PDF past the source's ceiling comes with its head, and reaches its renderer", async () => {
174 const source = localSource(app, repo);
175 const read = await source.blob("main", "big.pdf");
176 expect(read.ok).toBe(true);
177 if (!read.ok || read.value.kind !== "too_large") throw new Error("expected too_large");
178 expect(read.value.size).toBe(BIG_PDF.length);
179 expect(read.value.head?.length).toBe(HEAD_BYTES);
180 const pdf = stub();
181 const found = rendererFor("big.pdf", capped(read.value), { pdf: pdf.render });
182 expect(found?.body.length).toBe(0);
183 });
184
185 test("a 3 MB .mp4 of words, and a 3 MB Markdown file, keep the over-cap note", async () => {
186 for (const path of ["words.mp4", "notes.md"]) {
187 const text = await (await fetch(`${base}/alice/big/blob/main/${path}`)).text();
188 expect(text).toContain("over the render cap");
189 expect(text).not.toContain("<video");
190 }
191 });
192 });
test/spawn-pipes.test.ts +1 −1
@@ -25,7 +25,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
2525 "src/crew/workdir.ts": { sites: ["stderr"], reason: "stderr read to its end alongside the exit; stdout ignored" },
2626 "src/git/env.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end, finally clears the timer" },
2727 "src/git/local.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "stdout and stderr both read to the end" },
28 "src/git/read.ts": { sites: ["stdout", "stdout"], reason: "cat-file stdout is read by the long-lived reader, and by blobStream until its window ends or the reader hangs up (then killed); stderr ignored" },
28 "src/git/read.ts": { sites: ["stdout", "stdout", "stdout"], reason: "cat-file stdout is read by the long-lived reader, by blobStream until its window ends or the reader hangs up, and by blobHead until its bytes are in (then killed); stderr ignored" },
2929 "src/git/smart-http.ts": { sites: ["stdout"], reason: "stdout is a pull-pipe FIFO (the response body, read on demand); stderr ignored" },
3030 "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout (a pull-pipe FIFO) and stderr into the channel" },
3131 "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs