Public: anyone can read this; the crew can change it.
A SQLite file committed to a repository (.db, .sqlite, .sqlite3) renders as its tables and their first rows, read-only #10332
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10290-a-sqlite-file-committed-to-a-repository → main 8 commits fork 5 threads · 5 unresolved
8 files
changed +333
−14
docs/design/attention-budget.json +8 −8
| @@ -13,25 +13,25 @@ | ||
| 13 | 13 | }, |
| 14 | 14 | "repository": { |
| 15 | 15 | "390": { |
| 16 | "words": 189, | |
| 17 | "blocks": 28, | |
| 16 | "words": 197, | |
| 17 | "blocks": 29, | |
| 18 | 18 | "colours": 2 |
| 19 | 19 | }, |
| 20 | 20 | "1280": { |
| 21 | "words": 189, | |
| 22 | "blocks": 28, | |
| 21 | "words": 197, | |
| 22 | "blocks": 29, | |
| 23 | 23 | "colours": 2 |
| 24 | 24 | } |
| 25 | 25 | }, |
| 26 | 26 | "app": { |
| 27 | 27 | "390": { |
| 28 | "words": 189, | |
| 29 | "blocks": 28, | |
| 28 | "words": 197, | |
| 29 | "blocks": 29, | |
| 30 | 30 | "colours": 2 |
| 31 | 31 | }, |
| 32 | 32 | "1280": { |
| 33 | "words": 189, | |
| 34 | "blocks": 28, | |
| 33 | "words": 197, | |
| 34 | "blocks": 29, | |
| 35 | 35 | "colours": 2 |
| 36 | 36 | } |
| 37 | 37 | }, |
docs/features/sqlite-view.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # SQLite files as tables | |
| 2 | ||
| 3 | A `.db`, `.sqlite` or `.sqlite3` file whose first bytes are the SQLite header opens on the blob page as its tables and views (`src/web/render/sqlite.ts`). | |
| 4 | A file with one of those names but no header stays an ordinary binary file. | |
| 5 | The list shows each name, its kind, its row count (tables only; a view is never counted) and its columns; a table that cannot be read says so in its row. | |
| 6 | Click a name (`?table=<name>`) for its first 100 rows, columns capped at 100 and cells at 300 characters; NULL and a BLOB's size are written as words. | |
| 7 | The database is opened read-only in memory from the committed bytes and closed again; no SQL comes from the reader, and a table name is used only if `sqlite_master` lists it. | |
| 8 | A name the file does not hold goes back to the list with a note, and that page is not kept in the page cache. | |
| 9 | Up to 5 MB is drawn; a larger file shows Download. A truncated or damaged file shows the source with a one-line reason. Raw and Download work as for any file. | |
| 10 | ||
| 11 | ## In the terminal | |
| 12 | ||
| 13 | The terminal's file screen (`src/tui/screens/workspace.ts`) already covers a SQLite file as it does any binary: it says `Binary file, <size>.` and never prints bytes. It does not list tables; for that, open the blob page in the browser, or download the file and run `sqlite3` on it. |
src/web/pages/repo.ts +18 −6
| @@ -86,6 +86,7 @@ import { flashTo, fragment, page, redirect } from "../layout.ts"; | ||
| 86 | 86 | import { renderMarkdown } from "../markdown.ts"; |
| 87 | 87 | import { overSize } from "../render/archive.ts"; |
| 88 | 88 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 89 | import { hasTable } from "../render/sqlite.ts"; | |
| 89 | 90 | import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts"; |
| 90 | 91 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; |
| 91 | 92 | import { mediaHref } from "./media.ts"; |
| @@ -1129,6 +1130,8 @@ export function renderBlob( | ||
| 1129 | 1130 | highlighted?: (ms: number) => void; |
| 1130 | 1131 | /** Draw every line instead of the first 1,500. */ |
| 1131 | 1132 | all?: boolean; |
| 1133 | /** `?table=` on a SQLite file. */ | |
| 1134 | table?: string; | |
| 1132 | 1135 | /** Rows a worker already built for this file. */ |
| 1133 | 1136 | rows?: CodeRows; |
| 1134 | 1137 | }, |
| @@ -1147,6 +1150,7 @@ export function renderBlob( | ||
| 1147 | 1150 | media: mediaHref(base, o.ref, o.path), |
| 1148 | 1151 | pathHref: `${base}/blob/${encoded}/${o.path}`, |
| 1149 | 1152 | all: o.all === true, |
| 1153 | ...(o.table !== undefined ? { table: o.table } : {}), | |
| 1150 | 1154 | }) |
| 1151 | 1155 | : undefined; |
| 1152 | 1156 | const fellBack = drawn?.fallback; |
| @@ -1639,6 +1643,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1639 | 1643 | ctx.auth.user === undefined && repo.visibility === "public" && FULL_OID.test(ref) && ctx.url.searchParams.get("fragment") !== "1"; |
| 1640 | 1644 | const view = blobView(ctx.url.searchParams.get("view")); |
| 1641 | 1645 | const all = ctx.url.searchParams.get("all") === "1"; |
| 1646 | const table = ctx.url.searchParams.get("table")?.slice(0, 256) ?? undefined; | |
| 1642 | 1647 | // The whole page as it last went out to a stranger, encoded (`kept_page.ts`). |
| 1643 | 1648 | const keptKey = kept ? keptPageKey(ctx, repo.id) : undefined; |
| 1644 | 1649 | const finished = keptKey === undefined ? undefined : keptPage(keptKey); |
| @@ -1646,9 +1651,11 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1646 | 1651 | git.done(); |
| 1647 | 1652 | return immutableWhenAddressed(ctx.request, true, repo, ref, finished); |
| 1648 | 1653 | } |
| 1649 | const keepFinished = (response: Response) => (keptKey === undefined ? response : keepPage(keptKey, response, true)); | |
| 1654 | // A `?table=` that names nothing in the file is a stranger's string: its page is never kept. | |
| 1655 | let keepable = true; | |
| 1656 | const keepFinished = (response: Response) => (keptKey === undefined || !keepable ? response : keepPage(keptKey, response, true)); | |
| 1650 | 1657 | if (kept) { |
| 1651 | const hit = cachedBlobPage(repo.id, ref, path, String(view), all); | |
| 1658 | const hit = cachedBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all); | |
| 1652 | 1659 | if (hit !== undefined) { |
| 1653 | 1660 | git.done(); |
| 1654 | 1661 | return immutableWhenAddressed( |
| @@ -1674,7 +1681,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1674 | 1681 | const refs = await git.read(() => (kept ? keptRefs(repo.id, () => refSetFrom(scope.source)) : refSetFrom(scope.source))); |
| 1675 | 1682 | let highlightMs: number | undefined; |
| 1676 | 1683 | const renderStart = performance.now(); |
| 1677 | const shown = capped(blob.value); | |
| 1684 | const shown = capped(blob.value, path); | |
| 1685 | if (table !== undefined) keepable = shown.kind !== "too_large" && hasTable(shown.body, table); | |
| 1678 | 1686 | // A cold page for a stranger highlights and lays out its rows on a worker (#9630). |
| 1679 | 1687 | let rows: CodeRows | undefined; |
| 1680 | 1688 | if (kept && shown.kind === "text" && (view === "source" || !blobRenderer(scope, path, shown))) { |
| @@ -1691,6 +1699,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1691 | 1699 | view, |
| 1692 | 1700 | highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms), |
| 1693 | 1701 | all, |
| 1702 | ...(table !== undefined ? { table } : {}), | |
| 1694 | 1703 | }); |
| 1695 | 1704 | if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs); |
| 1696 | 1705 | const paneMs = performance.now() - renderStart - (highlightMs ?? 0); |
| @@ -1718,8 +1727,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1718 | 1727 | ), |
| 1719 | 1728 | }); |
| 1720 | 1729 | const objectSrc = drawsPdf(scope, path, shown, view); |
| 1721 | if (kept) | |
| 1722 | keepBlobPage(repo.id, ref, path, String(view), all, { | |
| 1730 | if (kept && keepable) | |
| 1731 | keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all, { | |
| 1723 | 1732 | title: `${filename} · ${repo.name}`, |
| 1724 | 1733 | explorer: explorerHtml, |
| 1725 | 1734 | content: pane, |
| @@ -1765,7 +1774,10 @@ export async function pathsJson(ctx: Ctx): Promise<Response> { | ||
| 1765 | 1774 | * The page's own render ceiling, under the source's fetch ceiling. A body |
| 1766 | 1775 | * over it is dropped and only its head kept, so a media kind is still drawn. |
| 1767 | 1776 | */ |
| 1768 | export function capped(blob: Blob): Blob { | |
| 1777 | export function capped(blob: Blob, path = ""): Blob { | |
| 1778 | // A SQLite file is read whole, up to its own cap, because the database is opened from the bytes. | |
| 1779 | if (blob.kind !== "too_large" && fileKind(path, blob.body.subarray(0, HEAD_BYTES)) === "sqlite" && blob.size <= RENDER_CAP.sqlite) | |
| 1780 | return blob; | |
| 1769 | 1781 | return blob.kind !== "too_large" && blob.size > MAX_RENDER_BYTES |
| 1770 | 1782 | ? { kind: "too_large", size: blob.size, head: blob.body.slice(0, HEAD_BYTES) } |
| 1771 | 1783 | : blob; |
src/web/render/kinds.ts +6
| @@ -19,6 +19,7 @@ export type FileKind = | ||
| 19 | 19 | | "audio" |
| 20 | 20 | | "video" |
| 21 | 21 | | "csv" |
| 22 | | "sqlite" | |
| 22 | 23 | | "model3d" |
| 23 | 24 | | "archive" |
| 24 | 25 | | "config" |
| @@ -41,6 +42,8 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 41 | 42 | audio: 500 * MB, |
| 42 | 43 | video: 4_000 * MB, |
| 43 | 44 | csv: 10 * MB, |
| 45 | // Under the source's own 5 MB read ceiling: a larger file is never read. | |
| 46 | sqlite: 5 * MB, | |
| 44 | 47 | model3d: 100 * MB, |
| 45 | 48 | archive: 50 * MB, |
| 46 | 49 | config: 1 * MB, |
| @@ -117,6 +120,9 @@ const MEDIA: Readonly<Record<string, Media>> = { | ||
| 117 | 120 | m4v: { kind: "video", name: "MPEG-4", type: "video/mp4", magic: ftyp }, |
| 118 | 121 | webm: { kind: "video", name: "WebM", type: "video/webm", magic: bytes(0x1a, 0x45, 0xdf, 0xa3) }, |
| 119 | 122 | mov: { kind: "video", name: "QuickTime", type: "video/quicktime", magic: quicktime }, |
| 123 | db: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") }, | |
| 124 | sqlite: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") }, | |
| 125 | sqlite3: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") }, | |
| 120 | 126 | stl: { kind: "model3d", name: "STL", type: "model/stl" }, |
| 121 | 127 | glb: { kind: "model3d", name: "glTF binary", type: "model/gltf-binary", magic: ascii("glTF") }, |
| 122 | 128 | }; |
src/web/render/renderers.ts +4
| @@ -15,6 +15,7 @@ import { image } from "./image.ts"; | ||
| 15 | 15 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 16 | 16 | import { markdown } from "./markdown.ts"; |
| 17 | 17 | import { pdf } from "./pdf.ts"; |
| 18 | import { sqlite } from "./sqlite.ts"; | |
| 18 | 19 | import { subtitles } from "./subtitles.ts"; |
| 19 | 20 | import { video } from "./video.ts"; |
| 20 | 21 | |
| @@ -33,6 +34,8 @@ export interface RenderInput { | ||
| 33 | 34 | readonly pathHref?: string; |
| 34 | 35 | /** `?all=1`: draw past the default window, up to the renderer's cap. */ |
| 35 | 36 | readonly all?: boolean; |
| 37 | /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */ | |
| 38 | readonly table?: string; | |
| 36 | 39 | } |
| 37 | 40 | |
| 38 | 41 | export interface Rendered { |
| @@ -53,6 +56,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 53 | 56 | markdown, |
| 54 | 57 | pdf, |
| 55 | 58 | csv, |
| 59 | sqlite, | |
| 56 | 60 | audio, |
| 57 | 61 | video, |
| 58 | 62 | config, |
src/web/render/sqlite.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * A SQLite file committed to a repository, read-only: each table and view | |
| 3 | * with its columns and row count, and `?table=<name>` for the first rows of | |
| 4 | * one. The blob is opened in memory (`Database.deserialize`), never written | |
| 5 | * anywhere, and closed before this returns. There is no SQL from the reader: | |
| 6 | * a table name is only ever one read back from `sqlite_master`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 11 | import { fileSize } from "../ui.ts"; | |
| 12 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 13 | ||
| 14 | /** Rows drawn for one table, tables listed, columns and characters per cell drawn. */ | |
| 15 | export const ROW_LIMIT = 100; | |
| 16 | export const TABLE_CAP = 200; | |
| 17 | export const COLUMN_CAP = 100; | |
| 18 | export const CELL_CHARS = 300; | |
| 19 | ||
| 20 | interface Entry { | |
| 21 | readonly name: string; | |
| 22 | readonly type: "table" | "view"; | |
| 23 | readonly rows: number | undefined; | |
| 24 | readonly columns: readonly string[]; | |
| 25 | /** Why this table could not be read, shown in its place. */ | |
| 26 | readonly note?: string; | |
| 27 | } | |
| 28 | ||
| 29 | const plural = (n: number, one: string): string => `${n.toLocaleString()} ${one}${n === 1 ? "" : "s"}`; | |
| 30 | ||
| 31 | /** True when `name` is a table or view of this database (cheap; used before a page is kept). */ | |
| 32 | export function hasTable(body: Uint8Array, name: string): boolean { | |
| 33 | let db: Database | undefined; | |
| 34 | try { | |
| 35 | db = Database.deserialize(body, { readonly: true }); | |
| 36 | return db.query("SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = ? AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'").get(name) !== null; | |
| 37 | } catch { | |
| 38 | return false; | |
| 39 | } finally { | |
| 40 | db?.close(); | |
| 41 | } | |
| 42 | } | |
| 43 | ||
outdated
Empty catch hides a failing table; show a note instead. |
||
| 44 | const quote = (name: string): string => `"${name.replaceAll('"', '""')}"`; | |
| 45 | ||
| 46 | /** One cell as text: NULL and a BLOB's size are words, a long value is cut. */ | |
| 47 | function cell(v: unknown): string { | |
| 48 | if (v === null || v === undefined) return `<td class="muted">NULL</td>`; | |
| 49 | if (v instanceof Uint8Array) return `<td class="muted num">BLOB · ${fileSize(v.length)}</td>`; | |
| 50 | const s = String(v); | |
| 51 | const cut = s.length > CELL_CHARS ? `${s.slice(0, CELL_CHARS)}…` : s; | |
| 52 | return `<td${typeof v === "number" || typeof v === "bigint" ? ' class="num"' : ""}>${escapeHtml(cut)}</td>`; | |
| 53 | } | |
| 54 | ||
| 55 | function entries(db: Database): Entry[] { | |
| 56 | const found = db | |
| 57 | .query( | |
| 58 | `SELECT name, type FROM sqlite_master | |
| 59 | WHERE type IN ('table','view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' | |
| 60 | ORDER BY name LIMIT ${TABLE_CAP}`, | |
| 61 | ) | |
| 62 | .all() as { name: string; type: "table" | "view" }[]; | |
| 63 | return found.map(({ name, type }) => { | |
| 64 | let columns: string[] = []; | |
| 65 | let rows: number | undefined; | |
| 66 | try { | |
| 67 | columns = (db.query("SELECT name FROM pragma_table_info(?)").all(name) as { name: string }[]).map((c) => c.name); | |
| 68 | // A view is a stored query of unknown cost, so it is never counted. | |
| 69 | if (type === "table") rows = (db.query(`SELECT count(*) AS n FROM ${quote(name)}`).get() as { n: number }).n; | |
| 70 | } catch { | |
| 71 | return { name, type, rows: undefined, columns, note: "could not be read" }; | |
| 72 | } | |
| 73 | return { name, type, rows, columns }; | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | function rowsOf(db: Database, entry: Entry): string { | |
| 78 | const width = Math.min(entry.columns.length, COLUMN_CAP); | |
| 79 | const data = db.query(`SELECT * FROM ${quote(entry.name)} LIMIT ${ROW_LIMIT}`).values() as unknown[][]; | |
| 80 | const head = entry.columns.slice(0, width).map((c) => `<th>${escapeHtml(c)}</th>`).join(""); | |
| 81 | const body = data.map((r) => `<tr>${r.slice(0, width).map(cell).join("")}</tr>`).join(""); | |
| 82 | return `<table class="table" data-dense aria-label="${escapeHtml(entry.name)} rows"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`; | |
| 83 | } | |
| 84 | ||
Principle 8: "1 views" — pluralise correctly. |
||
| 85 | export function sqlite(input: RenderInput): Rendered { | |
| 86 | let db: Database | undefined; | |
| 87 | try { | |
| 88 | db = Database.deserialize(input.body, { readonly: true }); | |
| 89 | const list = entries(db); | |
| 90 | const href = input.pathHref ?? ""; | |
| 91 | const wanted = input.table; | |
| 92 | const chosen = wanted === undefined ? undefined : list.find((e) => e.name === wanted); | |
| 93 | const notice = | |
outdated
Listing table lacks a scroll wrapper; overflows at 360px. Wrap in csv-scroll. |
||
| 94 | wanted !== undefined && chosen === undefined | |
| 95 | ? `<p class="muted small">This database has no table named “${escapeHtml(wanted.slice(0, 100))}”.</p>` | |
| 96 | : ""; | |
| 97 | const tables = list.filter((e) => e.type === "table").length; | |
| 98 | const summary = `${plural(tables, "table")} · ${plural(list.length - tables, "view")} · ${fileSize(input.size)}`; | |
| 99 | ||
| 100 | const items = list | |
| 101 | .map((e) => { | |
| 102 | const cols = e.columns.slice(0, COLUMN_CAP).join(", "); | |
| 103 | const count = e.note !== undefined ? e.note : e.rows === undefined ? "" : plural(e.rows, "row"); | |
| 104 | return `<tr><td><a href="${escapeHtml(href)}?table=${encodeURIComponent(e.name)}">${escapeHtml(e.name)}</a></td><td class="muted">${e.type}</td><td class="num${e.note !== undefined ? " muted" : ""}">${escapeHtml(count)}</td><td class="muted small">${escapeHtml(cols)}</td></tr>`; | |
outdated
Principle 6/7: a card around the table is a box around a thing. Drop the card class. |
||
| 105 | }) | |
| 106 | .join(""); | |
| 107 | const listing = `<div class="csv-scroll"><table class="table" data-dense aria-label="Tables and views"><thead><tr><th>Name</th><th>Kind</th><th class="num">Rows</th><th>Columns</th></tr></thead><tbody>${items}</tbody></table></div>`; | |
| 108 | const empty = list.length === 0 ? `<p class="muted small">This database has no tables.</p>` : ""; | |
| 109 | const more = list.length >= TABLE_CAP ? `<p class="muted small">Showing the first ${TABLE_CAP} tables and views.</p>` : ""; | |
| 110 | ||
| 111 | let content: string; | |
| 112 | if (chosen !== undefined) { | |
| 113 | const total = chosen.rows === undefined ? "" : ` of ${chosen.rows.toLocaleString()}`; | |
| 114 | if (chosen.note !== undefined) return { content: html`<div class="sqlite-view"><p class="small"><a href="${href}">← All tables</a> · <strong>${chosen.name}</strong> <span class="muted">${chosen.note}</span></p></div>`, lines: 0, summary }; | |
| 115 | content = `<p class="small"><a href="${escapeHtml(href)}">← All tables</a> · <strong>${escapeHtml(chosen.name)}</strong> <span class="muted">first ${Math.min(ROW_LIMIT, chosen.rows ?? ROW_LIMIT).toLocaleString()}${total} rows</span></p><div class="csv-scroll">${rowsOf(db, chosen)}</div>`; | |
| 116 | } else { | |
| 117 | content = `${notice}${listing}${empty}${more}`; | |
| 118 | } | |
| 119 | return { content: html`<div class="sqlite-view">${raw(content)}</div>`, lines: 0, summary }; | |
| 120 | } catch { | |
| 121 | return { content: html``, lines: 0, fallback: "This SQLite file is cut short or damaged, so its tables cannot be read." }; | |
| 122 | } finally { | |
| 123 | db?.close(); | |
| 124 | } | |
| 125 | } |
test/render-kinds.test.ts +3
| @@ -37,6 +37,9 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 37 | 37 | mov: [box("qt "), "video", "video/quicktime"], |
| 38 | 38 | stl: [enc("solid cube\n"), "model3d", "model/stl"], |
| 39 | 39 | glb: [enc("glTF\x02\0\0\0"), "model3d", "model/gltf-binary"], |
| 40 | db: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"], | |
| 41 | sqlite: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"], | |
| 42 | sqlite3: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"], | |
| 40 | 43 | csv: [enc("a,b\n1,2\n"), "csv", "application/octet-stream"], |
| 41 | 44 | tsv: [enc("a\tb\n"), "csv", "application/octet-stream"], |
| 42 | 45 | jsonl: [enc("{\"a\":1}\n"), "csv", "application/octet-stream"], |
test/render-sqlite.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** A committed SQLite file as its tables and first rows, read-only. */ | |
| 2 | import { Database } from "bun:sqlite"; | |
| 3 | import { describe, expect, test } from "bun:test"; | |
| 4 | import { renderBlob, capped } from "../src/web/pages/repo.ts"; | |
| 5 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 6 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 7 | import { ROW_LIMIT, TABLE_CAP, hasTable, sqlite } from "../src/web/render/sqlite.ts"; | |
| 8 | import { expectWork } from "./helpers/work.ts"; | |
| 9 | import type { RepoScope } from "../src/web/scope.ts"; | |
| 10 | ||
| 11 | const make = (): Uint8Array => { | |
| 12 | const db = new Database(":memory:"); | |
| 13 | db.run("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT, pic BLOB)"); | |
| 14 | db.run("INSERT INTO notes (body, pic) VALUES ('<script>alert(1)</script>', x'00010203')"); | |
| 15 | db.run("INSERT INTO notes (body, pic) VALUES (NULL, NULL)"); | |
| 16 | db.run('CREATE TABLE "we""ird" (n INTEGER)'); | |
| 17 | for (let i = 0; i < 250; i++) db.run('INSERT INTO "we""ird" VALUES (?)', [i]); | |
| 18 | db.run("CREATE VIEW recent AS SELECT id, body FROM notes"); | |
| 19 | const bytes = db.serialize(); | |
| 20 | db.close(); | |
| 21 | return bytes; | |
| 22 | }; | |
| 23 | const DB = make(); | |
| 24 | const draw = (body: Uint8Array, table?: string) => | |
| 25 | sqlite({ path: "a.db", size: body.length, body, media: "/m", pathHref: "/o/r/blob/main/a.db", ...(table ? { table } : {}) }); | |
| 26 | ||
| 27 | describe("sqlite kind", () => { | |
| 28 | test("the header confirms it; a .db without it is binary", () => { | |
| 29 | expect(fileKind("a.db", DB.subarray(0, 4096))).toBe("sqlite"); | |
| 30 | expect(fileKind("a.sqlite3", DB.subarray(0, 4096))).toBe("sqlite"); | |
| 31 | expect(fileKind("a.db", new Uint8Array([1, 0, 2, 3]))).toBe("binary"); | |
| 32 | expect(RENDERERS.sqlite).toBe(sqlite); | |
| 33 | }); | |
| 34 | }); | |
| 35 | ||
| 36 | describe("sqlite page", () => { | |
| 37 | test("lists tables and views with counts and columns", () => { | |
| 38 | const r = draw(DB); | |
| 39 | const h = String(r.content); | |
| 40 | expect(r.fallback).toBeUndefined(); | |
| 41 | expect(r.summary).toContain("2 tables · 1 view"); expect(r.summary).not.toContain("1 views"); | |
| 42 | expect(h).toContain('href="/o/r/blob/main/a.db?table=notes"'); | |
| 43 | expect(h).toContain("2 rows"); | |
| 44 | expect(h).toContain("250 rows"); | |
| 45 | expect(h).toContain("id, body, pic"); | |
| 46 | expect(h).toContain("recent"); | |
| 47 | }); | |
| 48 | test("?table shows rows: escaped, BLOB as size, NULL, capped at 100", () => { | |
| 49 | const h = String(draw(DB, "notes").content); | |
| 50 | expect(h).toContain("<script>alert(1)</script>"); | |
| 51 | expect(h).not.toContain("<script>"); | |
| 52 | expect(h).toContain("BLOB · 4 B"); | |
| 53 | expect(h).toContain("NULL"); | |
| 54 | const big = String(draw(DB, 'we"ird').content); | |
| 55 | expect(big.split("<tr>").length - 1).toBe(ROW_LIMIT + 1); | |
| 56 | expect(String(draw(DB, "recent").content)).toContain("<th>body</th>"); | |
| 57 | }); | |
| 58 | test("an unknown table, or an injection attempt, goes back to the list", () => { | |
| 59 | for (const t of ["nope", 'notes"; DROP TABLE notes; --', "sqlite_master"]) { | |
| 60 | const h = String(draw(DB, t).content); | |
| 61 | expect(h).toContain("has no table named"); | |
| 62 | expect(h).toContain("?table=notes"); | |
| 63 | expect(h).not.toContain("<th>pic</th>"); | |
| 64 | } | |
| 65 | }); | |
| 66 | test("a truncated or corrupt file falls back with one sentence", () => { | |
| 67 | expect(draw(DB.slice(0, 5000)).fallback).toBeDefined(); | |
| 68 | const junk = new Uint8Array(DB.length).fill(7); | |
| 69 | junk.set(DB.subarray(0, 16)); | |
| 70 | expect(draw(junk).fallback).toBeDefined(); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("sqlite on the blob page", () => { | |
| 75 | const scope = { base: "/o/r", facts: { name: "r" }, caps: { write: false }, kind: "local" } as unknown as RepoScope; | |
| 76 | const page = (name: string, body: Uint8Array, table?: string) => | |
| 77 | String( | |
| 78 | renderBlob(scope, { | |
| 79 | refs: { branches: ["main"], tags: [] }, | |
| 80 | ref: "main", | |
| 81 | path: name, | |
| 82 | blob: capped({ kind: "binary", body, size: body.length }, name), | |
| 83 | ...(table ? { table } : {}), | |
| 84 | }), | |
| 85 | ); | |
| 86 | test("rendered, with the table", () => { | |
| 87 | expect(page("a.db", DB)).toContain("Tables and views"); | |
| 88 | expect(page("a.db", DB, "notes")).toContain("<script>"); | |
| 89 | }); | |
| 90 | test("a non-SQLite .db stays binary; a corrupt one offers Download", () => { | |
| 91 | expect(page("a.db", new Uint8Array([1, 0, 2]))).toContain("Binary file"); | |
| 92 | const h = page("a.db", DB.slice(0, 5000)); | |
| 93 | expect(h).toContain("Showing the source: This SQLite file is cut short or damaged"); | |
| 94 | expect(h).toContain("Download"); | |
| 95 | }); | |
| 96 | test("a file over the 1 MB page ceiling but under the sqlite cap is drawn, not binary", () => { | |
| 97 | const db = new Database(":memory:"); | |
| 98 | db.run("CREATE TABLE big (v TEXT)"); | |
| 99 | db.run("INSERT INTO big SELECT hex(randomblob(500)) FROM (WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < 3000) SELECT n FROM c)"); | |
| 100 | const body = db.serialize(); | |
| 101 | db.close(); | |
| 102 | expect(body.length).toBeGreaterThan(1_000_000); | |
| 103 | const h = page("big.db", body); | |
| 104 | expect(h).toContain("Tables and views"); | |
| 105 | expect(h).toContain("3,000 rows"); | |
| 106 | expect(page("big.db", body, "big")).toContain("<th>v</th>"); | |
| 107 | }); | |
| 108 | test("a file over the 5 MB sqlite cap offers Download instead", () => { | |
| 109 | const body = new Uint8Array(5_500_000); | |
| 110 | body.set(DB); | |
| 111 | const h = page("huge.db", body); | |
| 112 | expect(h).not.toContain("Tables and views"); | |
| 113 | expect(h).toContain("Download"); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("sqlite details", () => { | |
| 118 | test("hasTable names only tables and views the file holds", () => { | |
| 119 | expect(hasTable(DB, "notes")).toBe(true); | |
| 120 | expect(hasTable(DB, "recent")).toBe(true); | |
| 121 | expect(hasTable(DB, "nope")).toBe(false); | |
| 122 | expect(hasTable(DB, "sqlite_master")).toBe(false); | |
| 123 | expect(hasTable(new Uint8Array([1, 2, 3]), "notes")).toBe(false); | |
| 124 | }); | |
| 125 | test("a view that fails shows a note, not nothing", () => { | |
| 126 | const db = new Database(":memory:"); | |
| 127 | db.run("CREATE TABLE t (a)"); | |
| 128 | db.run("CREATE VIEW v AS SELECT a FROM t"); | |
| 129 | db.run("DROP TABLE t"); | |
| 130 | const body = db.serialize(); | |
| 131 | db.close(); | |
| 132 | const r = draw(body); | |
| 133 | expect(String(r.content)).toContain("could not be read"); | |
| 134 | }); | |
| 135 | test("the listing scrolls on a phone and has no card", () => { | |
| 136 | const h = String(draw(DB).content); | |
| 137 | expect(h).toContain('<div class="csv-scroll"><table'); | |
| 138 | expect(h).not.toContain("card"); | |
| 139 | }); | |
| 140 | test("200 tables of a ~5 MB file list in bounded work", () => { | |
| 141 | const db = new Database(":memory:"); | |
| 142 | for (let i = 0; i < 200; i++) { | |
| 143 | db.run(`CREATE TABLE t${i} (a INTEGER, b TEXT)`); | |
| 144 | db.run(`INSERT INTO t${i} SELECT n, hex(randomblob(60)) FROM (WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < 150) SELECT n FROM c)`); | |
| 145 | } | |
| 146 | const body = db.serialize(); | |
| 147 | db.close(); | |
| 148 | const r = draw(body); | |
| 149 | const h = String(r.content); | |
| 150 | // Work, not a clock: one listing row per table, two statements each at most. | |
| 151 | expectWork(h.split("?table=").length - 1, { max: TABLE_CAP }); | |
| 152 | expect(h.split("?table=").length - 1).toBe(200); | |
| 153 | expectWork(h.length, { max: 100_000 }); | |
| 154 | expect(h).toContain("150 rows"); | |
| 155 | }); | |
| 156 | }); |
Unvalidated table string in the cache key lets strangers fill the kept-page cache.