1import type { Database } from "sql.js";
2
3import { boundedZip } from "@t3tools/client-runtime/zip/bounded-zip";
4import {
5 ARCHIVE_ENTRY_MAX_BYTES,
6 ARCHIVE_MAX_ENTRIES,
7 ARCHIVE_MAX_RATIO,
8 BINARY_PREVIEW_MAX_BYTES,
9 SQLITE_PAGE_SIZE,
10 type ArchiveEntry,
11 type BinaryWorkerRequest,
12 type BinaryWorkerResult,
13} from "./binaryPreviewProtocol";
14
15let database: Database | null = null;
16let tables: string[] = [];
17let files = new Map<string, Uint8Array<ArrayBuffer>>();
18const reply = (result: BinaryWorkerResult) => self.postMessage(result, { transfer: [] });
19const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
20
21async function gunzip(bytes: ArrayBuffer) {
22 const reader = new Blob([bytes])
23 .stream()
24 .pipeThrough(new DecompressionStream("gzip"))
25 .getReader();
26 const chunks: Uint8Array<ArrayBuffer>[] = [];
27 let length = 0;
28 try {
29 while (true) {
30 const { value, done } = await reader.read();
31 if (done) break;
32 length += value.length;
33 if (length > BINARY_PREVIEW_MAX_BYTES || length > bytes.byteLength * ARCHIVE_MAX_RATIO)
34 throw new Error(
35 "The expanded archive exceeds the preview size or compression ratio limit.",
36 );
37 chunks.push(value);
38 }
39 } finally {
40 await reader.cancel();
41 }
42 return new Uint8Array(await new Blob(chunks).arrayBuffer());
43}
44
45function tarEntries(bytes: Uint8Array<ArrayBuffer>) {
46 const entries: ArchiveEntry[] = [];
47 const text = (offset: number, length: number) =>
48 new TextDecoder().decode(bytes.subarray(offset, offset + length)).split("\0", 1)[0] ?? "";
49 const number = (offset: number, length: number) => {
50 const value = text(offset, length).trim();
51 if (!/^[0-7]*$/.test(value)) throw new Error("This TAR uses an unsupported numeric format.");
52 return Number.parseInt(value || "0", 8);
53 };
54 let longPath: string | null = null;
55 const globalPax: Record<string, string> = {};
56 let localPax: Record<string, string> = {};
57 let offset = 0;
58 while (offset + 512 <= bytes.length) {
59 if (bytes.subarray(offset, offset + 512).every((byte) => byte === 0)) break;
60 if (entries.length >= ARCHIVE_MAX_ENTRIES)
61 throw new Error("This archive contains more than 2,048 entries.");
62 let checksum = 0;
63 for (let index = 0; index < 512; index++)
64 checksum += index >= 148 && index < 156 ? 32 : bytes[offset + index]!;
65 if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged.");
66 const type = text(offset + 156, 1);
67 const metadata = type === "L" || type === "x" || type === "g";
68 const attributes: Record<string, string> = metadata ? {} : { ...globalPax, ...localPax };
69 const size = attributes.size ? Number(attributes.size) : number(offset + 124, 12);
70 if (attributes.size && !/^\d+$/.test(attributes.size))
71 throw new Error("An archive entry is too large or damaged.");
72 const prefix =
73 text(offset + 257, 6) === "ustar" && text(offset + 263, 2) === "00"
74 ? text(offset + 345, 155)
75 : "";
76 const path =
77 attributes.path || longPath || [prefix, text(offset, 100)].filter(Boolean).join("/");
78 if (!metadata) {
79 longPath = null;
80 localPax = {};
81 }
82 if (
83 path.length > 1024 ||
84 !Number.isSafeInteger(size) ||
85 size < 0 ||
86 size > BINARY_PREVIEW_MAX_BYTES ||
87 offset + 512 + size > bytes.length
88 )
89 throw new Error("An archive entry is too large or damaged.");
90 const content = bytes.subarray(offset + 512, offset + 512 + size);
91 if (type === "L") longPath = new TextDecoder().decode(content).replace(/\0.*$/s, "");
92 else if (type === "x" || type === "g") {
93 if (size > 65536) throw new Error("Archive metadata is too large.");
94 for (let cursor = 0; cursor < content.length;) {
95 const separator = content.indexOf(32, cursor);
96 if (separator < cursor || separator - cursor > 10)
97 throw new Error("The TAR metadata is damaged.");
98 const lengthText = new TextDecoder().decode(content.subarray(cursor, separator));
99 const length = Number(lengthText);
100 const end = cursor + length;
101 if (
102 !/^[1-9]\d*$/.test(lengthText) ||
103 !Number.isSafeInteger(length) ||
104 end > content.length ||
105 end <= separator + 1 ||
106 content[end - 1] !== 10
107 )
108 throw new Error("The TAR metadata is damaged.");
109 const record = new TextDecoder().decode(content.subarray(separator + 1, end - 1));
110 const equals = record.indexOf("=");
111 if (equals < 1) throw new Error("The TAR metadata is damaged.");
112 const key = record.slice(0, equals);
113 const value = record.slice(equals + 1);
114 if (["path", "size", "mtime"].includes(key)) {
115 if (type === "x") localPax[key] = value;
116 else if (value) globalPax[key] = value;
117 else delete globalPax[key];
118 }
119 cursor = end;
120 }
121 } else if (type === "" || type === "0" || type === "5") {
122 if (attributes.mtime && !/^[+-]?\d+(?:\.\d+)?$/.test(attributes.mtime))
123 throw new Error("The TAR metadata is damaged.");
124 const modified =
125 (attributes.mtime ? Number(attributes.mtime) : number(offset + 136, 12)) * 1000;
126 const date = new Date(modified);
127 entries.push({
128 path,
129 size,
130 directory: type === "5",
131 modified: Number.isNaN(date.getTime()) ? null : date.toISOString(),
132 });
133 if (type !== "5") files.set(path, content.slice());
134 }
135 offset += 512 + Math.ceil(size / 512) * 512;
136 }
137 if (entries.length === 0) throw new Error("This archive has no readable entries.");
138 return entries;
139}
140
141async function handle(input: BinaryWorkerRequest) {
142 if (input.type === "entry") {
143 const bytes = files.get(input.path);
144 if (!bytes) throw new Error("This archive entry is unavailable.");
145 if (bytes.length > ARCHIVE_ENTRY_MAX_BYTES)
146 throw new Error("Entry previews are limited to 10 MB.");
147 reply({ type: "entry", path: input.path, bytes });
148 return;
149 }
150 if (input.type === "rows") {
151 if (
152 !database ||
153 !tables.includes(input.table) ||
154 !Number.isSafeInteger(input.page) ||
155 input.page < 0
156 )
157 throw new Error("This table is unavailable.");
158 const columns =
159 database
160 .exec(`PRAGMA table_xinfo(${quote(input.table)})`)[0]
161 ?.values.filter((row) => row[6] !== 1)
162 .map((row) => String(row[1]))
163 .slice(0, 50) ?? [];
164 const fields = columns.map((name) => {
165 const field = quote(name);
166 return `CASE WHEN typeof(${field}) = 'blob' THEN '[blob: ' || length(${field}) || ' bytes]' ELSE substr(CAST(${field} AS TEXT), 1, 1024) END`;
167 });
168 const result = database.exec(
169 `SELECT ${fields.join(",")} FROM ${quote(input.table)} LIMIT ${SQLITE_PAGE_SIZE} OFFSET ${input.page * SQLITE_PAGE_SIZE}`,
170 )[0];
171 reply({
172 type: "rows",
173 table: input.table,
174 page: input.page,
175 columns,
176 rows:
177 result?.values.map((row) => row.map((cell) => (cell === null ? "NULL" : String(cell)))) ??
178 [],
179 });
180 return;
181 }
182 if (input.bytes.byteLength > BINARY_PREVIEW_MAX_BYTES)
183 throw new Error("Preview limited to files under 50 MB.");
184 if (input.kind === "sqlite") {
185 const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([
186 import("sql.js"),
187 import("sql.js/dist/sql-wasm.wasm?url"),
188 ]);
189 const SQL = await initSqlJs({ locateFile: () => wasmUrl });
190 database = new SQL.Database(new Uint8Array(input.bytes));
191 database.run("PRAGMA query_only=ON; PRAGMA trusted_schema=OFF;");
192 tables =
193 database
194 .exec(
195 "SELECT name FROM sqlite_schema WHERE type='table' AND substr(name, 1, 7) != 'sqlite_' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name LIMIT 101",
196 )[0]
197 ?.values.map((row) => String(row[0])) ?? [];
198 if (tables.length > 100) throw new Error("Database previews are limited to 100 tables.");
199 reply({
200 type: "sqlite",
201 tables: tables.map((name) => ({
202 name,
203 count: Number(
204 database!.exec(`SELECT count(*) FROM ${quote(name)}`)[0]?.values[0]?.[0] ?? 0,
205 ),
206 })),
207 });
208 } else {
209 files = new Map();
210 let entries: ArchiveEntry[];
211 if (
212 /\.(zip|jar)$/i.test(input.name) ||
213 (input.bytes.byteLength >= 4 &&
214 [0x04034b50, 0x06054b50].includes(new DataView(input.bytes).getUint32(0, true)))
215 ) {
216 const archive = await boundedZip(input.bytes, BINARY_PREVIEW_MAX_BYTES);
217 entries = [...archive].map(([path, file]) => {
218 files.set(path, file.bytes);
219 return {
220 path,
221 size: file.bytes.length,
222 directory: file.directory,
223 modified: Number.isNaN(file.date.getTime()) ? null : file.date.toISOString(),
224 };
225 });
226 } else if (/\.(gz|tgz)$/i.test(input.name) || new Uint8Array(input.bytes)[0] === 0x1f) {
227 const bytes = await gunzip(input.bytes);
228 if (/\.(tar\.gz|tgz)$/i.test(input.name)) entries = tarEntries(bytes);
229 else {
230 const path = input.name.replace(/\.gz$/i, "") || "contents";
231 if (/\.tar$/i.test(path)) entries = tarEntries(bytes);
232 else {
233 files.set(path, bytes);
234 entries = [{ path, size: bytes.length, directory: false, modified: null }];
235 }
236 }
237 } else entries = tarEntries(new Uint8Array(input.bytes));
238 reply({ type: "archive", entries });
239 }
240}
241
242self.addEventListener("message", (event: MessageEvent<BinaryWorkerRequest>) => {
243 void handle(event.data).catch((cause: unknown) =>
244 reply({
245 type: "error",
246 message: cause instanceof Error ? cause.message : "Could not preview this file.",
247 }),
248 );
249});