Public: anyone can read this; the crew can change it.
MCP `read_file` describes an STL model in words: triangles, size in model units, ASCII or binary #7362
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7346-mcp-read-file-describes-an-stl-model-in → main 4 commits fork 3 threads · 3 unresolved
src/http/mcp/tools/read_file.ts +16 −2
| @@ -2,6 +2,7 @@ import { repoDir } from "../../../git/repos.ts"; | ||
| 2 | 2 | import { runGit } from "../../../git/env.ts"; |
| 3 | 3 | import { isSafeRef, isSafeTreePath } from "../../../git/guard.ts"; |
| 4 | 4 | import { blobForRender, listTree } from "../../../git/read.ts"; |
| 5 | import { readStl, sayStl, stlSize, TOO_LARGE } from "../../../text/stl.ts"; | |
| 5 | 6 | import { checkConfig, configKind, type ConfigKind } from "../../../text/config_check.ts"; |
| 6 | 7 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 7 | 8 | |
| @@ -47,8 +48,7 @@ export const tool: Tool = { | ||
| 47 | 48 | return good({ type: "file", tooLarge: true, size: blob.size }); |
| 48 | 49 | const binary = blob.kind === "binary"; |
| 49 | 50 | const content = binary ? "" : new TextDecoder().decode(blob.body); |
| 50 | const kind = binary ? null : configKind(rawPath); | |
| 51 | const about = kind === null ? undefined : aboutConfig(kind, content); | |
| 51 | const about = aboutFile(rawPath, blob.body, binary ? undefined : content); | |
| 52 | 52 | return good({ |
| 53 | 53 | type: "file", |
| 54 | 54 | ref, |
| @@ -61,6 +61,20 @@ export const tool: Tool = { | ||
| 61 | 61 | }, |
| 62 | 62 | }; |
| 63 | 63 | |
| 64 | /** A file described in words beside its content; one branch per kind. */ | |
| 65 | function aboutFile(path: string, body: Uint8Array, text: string | undefined): Record<string, unknown> | undefined { | |
| 66 | if (/\.stl$/i.test(path)) { | |
| 67 | const stl = readStl(body); | |
| 68 | if (!stl.ok && stl.error === TOO_LARGE) return { kind: "stl", says: `STL, ${TOO_LARGE}` }; | |
| 69 | if (!stl.ok) return { kind: "stl", says: `STL, unreadable: ${stl.error}` }; | |
| 70 | const { format, triangles } = stl.value; | |
| 71 | return { kind: "stl", format, triangles, size: stlSize(stl.value), says: sayStl(stl.value) }; | |
| 72 | } | |
| 73 | if (text === undefined) return undefined; | |
| 74 | const kind = configKind(path); | |
| 75 | return kind === null ? undefined : aboutConfig(kind, text); | |
| 76 | } | |
| 77 | ||
| 64 | 78 | function aboutConfig(kind: ConfigKind, content: string) { |
| 65 | 79 | const check = checkConfig(kind, content); |
| 66 | 80 | if (check.ok) return { kind, parses: true }; |
src/text/stl.ts added +192
| @@ -0,0 +1,192 @@ | ||
| 1 | /** | |
| 2 | * STL, read for its shape: whether it is ASCII or binary, how many triangles | |
| 3 | * it holds, and the axis-aligned box around every vertex — in the file's own | |
| 4 | * model units, which STL never names. Only the bounds are kept, never the | |
| 5 | * mesh. A truncated or lying file is an Err naming why; nothing here throws. | |
| 6 | * | |
| 7 | * Binary (after the 3D Systems "StereoLithography Interface Specification", | |
| 8 | * 1989): an 80-byte header, a little-endian uint32 triangle count at offset | |
| 9 | * 80, then 50 bytes per triangle — a normal and three vertices as | |
| 10 | * little-endian float32 (12 × 4 bytes) and a uint16 attribute count. So a | |
| 11 | * file of n triangles is exactly 84 + 50·n bytes. | |
| 12 | * | |
| 13 | * ASCII: `solid name`, then per triangle `facet normal i j k` / `outer loop` / | |
| 14 | * three `vertex x y z` / `endloop` / `endfacet`, closed by `endsolid`. | |
| 15 | */ | |
| 16 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 17 | ||
| 18 | export type Vec3 = readonly [number, number, number]; | |
| 19 | ||
| 20 | export type Stl = { | |
| 21 | readonly format: "ascii" | "binary"; | |
| 22 | readonly triangles: number; | |
| 23 | readonly min: Vec3; | |
| 24 | readonly max: Vec3; | |
| 25 | }; | |
| 26 | ||
| 27 | const HEADER = 80; | |
| 28 | const RECORD = 50; | |
| 29 | ||
| 30 | /** Read an STL's format, triangle count and bounds from its bytes. */ | |
| 31 | export function readStl(bytes: Uint8Array): Result<Stl, string> { | |
| 32 | const binary = bytes.length >= HEADER + 4 ? expectedBinaryLength(bytes) : undefined; | |
| 33 | // A binary header may itself begin with "solid", so a length that matches | |
| 34 | // the binary layout exactly wins over the ASCII keyword. | |
| 35 | if (binary !== undefined && binary === bytes.length) return readBinary(bytes); | |
| 36 | if (startsWithSolid(bytes)) { | |
outdated
Low: a binary file whose header starts with |
||
| 37 | const read = readAscii(bytes); | |
| 38 | // Not ASCII after all: a binary header that begins with "solid" and a | |
| 39 | // length that does not fit says more as the binary mismatch. | |
| 40 | if (read.ok || binary === undefined || looksAscii(bytes)) return read; | |
| 41 | } | |
| 42 | if (binary === undefined) return err("too short for an STL header"); | |
| 43 | const n = (binary - HEADER - 4) / RECORD; | |
| 44 | return bytes.length < binary | |
| 45 | ? err(`truncated: says ${n} triangles, holds ${bytes.length} of ${binary} bytes`) | |
| 46 | : err(`says ${n} triangles but holds ${bytes.length} bytes, not ${binary}`); | |
| 47 | } | |
| 48 | ||
| 49 | /** One line: "STL (binary) · 12,340 triangles · 40.0 × 20.0 × 10.0". */ | |
| 50 | export function sayStl(stl: Stl): string { | |
| 51 | const count = `${stl.triangles.toLocaleString("en-US")} triangle${stl.triangles === 1 ? "" : "s"}`; | |
| 52 | const size = stlSize(stl).map((d) => d.toFixed(1)).join(" × "); | |
| 53 | return `STL (${stl.format}) · ${count} · ${size}`; | |
| 54 | } | |
| 55 | ||
| 56 | /** The box's extent on each axis, max − min, in model units. */ | |
| 57 | export function stlSize(stl: Stl): Vec3 { | |
| 58 | return [stl.max[0] - stl.min[0], stl.max[1] - stl.min[1], stl.max[2] - stl.min[2]]; | |
| 59 | } | |
| 60 | ||
| 61 | function expectedBinaryLength(bytes: Uint8Array): number { | |
| 62 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| 63 | return HEADER + 4 + view.getUint32(HEADER, true) * RECORD; | |
| 64 | } | |
| 65 | ||
| 66 | function startsWithSolid(bytes: Uint8Array): boolean { | |
| 67 | let i = 0; | |
| 68 | while (i < bytes.length && (bytes[i] === 0x20 || bytes[i] === 0x09 || bytes[i] === 0x0a || bytes[i] === 0x0d)) i++; | |
| 69 | const word = Buffer.from(bytes.subarray(i, i + 5)).toString("latin1"); | |
| 70 | return word.toLowerCase() === "solid"; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Whether the four bytes where a binary count would sit are all text. A real | |
| 75 | * count that is all text claims at least 0x09090909 (151 million) triangles, | |
| 76 | * gigabytes no file here holds, so text there means the file is ASCII. | |
| 77 | */ | |
| 78 | function looksAscii(bytes: Uint8Array): boolean { | |
| 79 | for (let i = HEADER; i < HEADER + 4; i++) if (bytes[i]! < 0x09 || (bytes[i]! > 0x0d && bytes[i]! < 0x20)) return false; | |
| 80 | return true; | |
| 81 | } | |
| 82 | ||
| 83 | class Box { | |
| 84 | min = [Infinity, Infinity, Infinity]; | |
| 85 | max = [-Infinity, -Infinity, -Infinity]; | |
| 86 | add(x: number, y: number, z: number): void { | |
| 87 | const p = [x, y, z]; | |
| 88 | for (let a = 0; a < 3; a++) { | |
| 89 | if (p[a]! < this.min[a]!) this.min[a] = p[a]!; | |
| 90 | if (p[a]! > this.max[a]!) this.max[a] = p[a]!; | |
| 91 | } | |
| 92 | } | |
| 93 | done(format: Stl["format"], triangles: number): Result<Stl, string> { | |
| 94 | if (triangles === 0) return err("holds no triangles"); | |
| 95 | return ok({ | |
| 96 | format, | |
| 97 | triangles, | |
| 98 | min: [this.min[0]!, this.min[1]!, this.min[2]!], | |
| 99 | max: [this.max[0]!, this.max[1]!, this.max[2]!], | |
| 100 | }); | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | function readBinary(bytes: Uint8Array): Result<Stl, string> { | |
| 105 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| 106 | const n = view.getUint32(HEADER, true); | |
| 107 | const box = new Box(); | |
| 108 | for (let t = 0; t < n; t++) { | |
| 109 | // Skip the 12-byte normal; the three vertices follow. | |
| 110 | const at = HEADER + 4 + t * RECORD + 12; | |
| 111 | for (let v = 0; v < 3; v++) { | |
| 112 | const o = at + v * 12; | |
| 113 | const x = view.getFloat32(o, true); | |
| 114 | const y = view.getFloat32(o + 4, true); | |
| 115 | const z = view.getFloat32(o + 8, true); | |
outdated
High (denial of service): on a 5 MB ASCII body this line builds about a million strings, and the loop below runs a regex on each number. I measured about 300 ms of synchronous work per |
||
| 116 | if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) | |
| 117 | return err(`triangle ${t + 1} has a vertex that is not a finite number`); | |
| 118 | box.add(x, y, z); | |
| 119 | } | |
| 120 | } | |
| 121 | return box.done("binary", n); | |
| 122 | } | |
| 123 | ||
| 124 | /** ASCII is described only up to this many bytes: one pass over 1 MB stays well under 50 ms on the one server thread. */ | |
| 125 | export const ASCII_LIMIT = 1_000_000; | |
| 126 | export const TOO_LARGE = "too large to describe here"; | |
| 127 | ||
| 128 | const FLOAT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; | |
| 129 | ||
| 130 | function isSpace(c: number): boolean { | |
| 131 | return c === 0x20 || (c >= 0x09 && c <= 0x0d); | |
| 132 | } | |
| 133 | ||
| 134 | /** Whether text[s, e) is `word` (lower case), ignoring case, without slicing. */ | |
| 135 | function isWord(text: string, s: number, e: number, word: string): boolean { | |
| 136 | if (e - s !== word.length) return false; | |
| 137 | for (let k = 0; k < word.length; k++) if ((text.charCodeAt(s + k) | 0x20) !== word.charCodeAt(k)) return false; | |
| 138 | return true; | |
| 139 | } | |
| 140 | ||
| 141 | // One pass over the text by index: keywords are compared in place and only the | |
| 142 | // three numbers of each vertex become strings, so the work is linear in bytes. | |
| 143 | function readAscii(bytes: Uint8Array): Result<Stl, string> { | |
| 144 | if (bytes.length > ASCII_LIMIT) return err(TOO_LARGE); | |
| 145 | const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("latin1"); | |
| 146 | // The solid's name runs to the end of its line and may hold any word. | |
| 147 | const firstBreak = text.search(/[\r\n]/); | |
| 148 | if (firstBreak === -1) return err("truncated: an ASCII STL with no facets"); | |
| 149 | let at = firstBreak; | |
| 150 | let s = 0; | |
| 151 | /** Advance to the next token; false at the end. Sets s and at to its bounds. */ | |
| 152 | const next = (): boolean => { | |
| 153 | while (at < text.length && isSpace(text.charCodeAt(at))) at++; | |
| 154 | s = at; | |
| 155 | while (at < text.length && !isSpace(text.charCodeAt(at))) at++; | |
| 156 | return at > s; | |
| 157 | }; | |
| 158 | const box = new Box(); | |
| 159 | let triangles = 0; | |
| 160 | let inFacet = false; | |
| 161 | let vertices = 0; | |
| 162 | while (next()) { | |
| 163 | if (isWord(text, s, at, "facet")) { | |
| 164 | if (inFacet) return err(`facet ${triangles + 1} opens before the last one ends`); | |
| 165 | inFacet = true; | |
| 166 | vertices = 0; | |
| 167 | triangles++; | |
| 168 | } else if (isWord(text, s, at, "vertex")) { | |
| 169 | if (!inFacet) return err("a vertex outside any facet"); | |
| 170 | const xyz: number[] = []; | |
| 171 | for (let a = 0; a < 3; a++) { | |
| 172 | if (!next()) return err(`truncated: facet ${triangles} ends mid-vertex`); | |
| 173 | const word = text.slice(s, at); | |
| 174 | if (!FLOAT.test(word)) return err(`facet ${triangles} has a vertex that is not three numbers`); | |
| 175 | xyz.push(Number(word)); | |
| 176 | } | |
| 177 | const [x, y, z] = xyz as [number, number, number]; | |
| 178 | if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) | |
| 179 | return err(`facet ${triangles} has a vertex that is not a finite number`); | |
| 180 | box.add(x, y, z); | |
| 181 | vertices++; | |
| 182 | } else if (isWord(text, s, at, "endfacet")) { | |
| 183 | if (!inFacet) return err("endfacet without a facet"); | |
| 184 | if (vertices !== 3) return err(`facet ${triangles} has ${vertices} vertices, not 3`); | |
| 185 | inFacet = false; | |
| 186 | } else if (isWord(text, s, at, "endsolid")) { | |
| 187 | if (inFacet) return err(`truncated: facet ${triangles} is never closed`); | |
| 188 | return box.done("ascii", triangles); | |
| 189 | } | |
| 190 | } | |
| 191 | return err(inFacet ? `truncated: facet ${triangles} is never closed` : "truncated: no endsolid"); | |
| 192 | } |
test/helpers/stl.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** A box from the origin to (x, y, z) as STL: 12 triangles, two per face. */ | |
| 2 | type V = [number, number, number]; | |
| 3 | ||
| 4 | function boxTriangles(x: number, y: number, z: number): V[][] { | |
| 5 | const p = (i: number): V => [i & 1 ? x : 0, i & 2 ? y : 0, i & 4 ? z : 0]; | |
| 6 | const faces = [ | |
| 7 | [0, 1, 3, 2], [4, 6, 7, 5], [0, 4, 5, 1], [2, 3, 7, 6], [0, 2, 6, 4], [1, 5, 7, 3], | |
| 8 | ]; | |
| 9 | return faces.flatMap(([a, b, c, d]) => [ | |
| 10 | [p(a!), p(b!), p(c!)], | |
| 11 | [p(a!), p(c!), p(d!)], | |
| 12 | ]); | |
| 13 | } | |
| 14 | ||
| 15 | export function binaryBox(x: number, y: number, z: number, header = "box"): Uint8Array { | |
| 16 | const tris = boxTriangles(x, y, z); | |
| 17 | const out = new Uint8Array(84 + 50 * tris.length); | |
| 18 | out.set(new TextEncoder().encode(header.slice(0, 80))); | |
| 19 | const view = new DataView(out.buffer); | |
| 20 | view.setUint32(80, tris.length, true); | |
| 21 | tris.forEach((tri, t) => { | |
| 22 | tri.forEach((v, i) => v.forEach((c, a) => view.setFloat32(84 + t * 50 + 12 + i * 12 + a * 4, c, true))); | |
| 23 | }); | |
| 24 | return out; | |
| 25 | } | |
| 26 | ||
| 27 | export function asciiBox(x: number, y: number, z: number): string { | |
| 28 | const facets = boxTriangles(x, y, z).map( | |
| 29 | (tri) => | |
| 30 | ` facet normal 0 0 0\n outer loop\n${tri.map((v) => ` vertex ${v.join(" ")}\n`).join("")} endloop\n endfacet\n`, | |
| 31 | ); | |
| 32 | return `solid box\n${facets.join("")}endsolid box\n`; | |
| 33 | } |
test/mcp-read-file.test.ts +33
| @@ -10,6 +10,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 10 | 10 | import { createUser } from "../src/core/users.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | import { asciiBox, binaryBox } from "./helpers/stl.ts"; | |
| 13 | 14 | |
| 14 | 15 | quietLogs(); |
| 15 | 16 | |
| @@ -65,6 +66,17 @@ beforeAll(async () => { | ||
| 65 | 66 | { path: "bad.json", content: '{\n "a": }' }, |
| 66 | 67 | { path: "good.json", content: "{}" }, |
| 67 | 68 | ] })); |
| 69 | ok( | |
| 70 | await commitToBranch(app, owner, pub, { | |
| 71 | message: "models", | |
| 72 | files: [ | |
| 73 | { path: "cube.stl", content: binaryBox(40, 20, 10) }, | |
| 74 | { path: "cube-ascii.STL", content: asciiBox(40, 20, 10) }, | |
| 75 | { path: "broken.stl", content: binaryBox(1, 1, 1).subarray(0, 100) }, | |
| 76 | { path: "huge.stl", content: asciiBox(1, 1, 1).repeat(1_000_000 / 1400 + 1) }, | |
| 77 | ], | |
| 78 | }), | |
| 79 | ); | |
| 68 | 80 | tok["owner"] = createToken(app.db, owner.id, "o", "ro").token; |
| 69 | 81 | tok["stranger"] = createToken(app.db, stranger.id, "s", "ro").token; |
| 70 | 82 | }, 30_000); |
| @@ -97,6 +109,27 @@ test("bad arguments are refused", async () => { | ||
| 97 | 109 | expect((await read(t, { repo: "ada/pub", path: "nope.txt" })).error).toContain("not_found"); |
| 98 | 110 | }); |
| 99 | 111 | |
| 112 | test("an STL file is described in words", async () => { | |
| 113 | const t = tok["stranger"]!; | |
| 114 | const bin = (await read(t, { repo: "ada/pub", path: "cube.stl" })).value; | |
| 115 | expect(bin.encoding).toBe("base64"); | |
| 116 | expect(bin.about).toEqual({ | |
| 117 | kind: "stl", | |
| 118 | format: "binary", | |
| 119 | triangles: 12, | |
| 120 | size: [40, 20, 10], | |
| 121 | says: "STL (binary) · 12 triangles · 40.0 × 20.0 × 10.0", | |
| 122 | }); | |
| 123 | const txt = (await read(t, { repo: "ada/pub", path: "cube-ascii.STL" })).value; | |
| 124 | expect(txt.about.says).toBe("STL (ascii) · 12 triangles · 40.0 × 20.0 × 10.0"); | |
| 125 | const broken = (await read(t, { repo: "ada/pub", path: "broken.stl" })).value; | |
| 126 | expect(broken.about.kind).toBe("stl"); | |
| 127 | expect(broken.about.says).toContain("truncated"); | |
| 128 | const huge = (await read(t, { repo: "ada/pub", path: "huge.stl" })).value; | |
| 129 | expect(huge.size).toBeGreaterThan(1_000_000); | |
| 130 | expect(huge.about).toEqual({ kind: "stl", says: "STL, too large to describe here" }); | |
| 131 | }); | |
| 132 | ||
| 100 | 133 | test("a config file says whether it parses; other files carry no about", async () => { |
| 101 | 134 | const t = tok["owner"]!; |
| 102 | 135 | const bad = await read(t, { repo: "ada/pub", path: "bad.json" }); |
test/text-stl.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** STL: format, triangle count and bounds from ASCII and binary bytes; a truncated or lying file is an Err, never a throw. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { ASCII_LIMIT, readStl, sayStl, stlSize, TOO_LARGE } from "../src/text/stl.ts"; | |
| 4 | import { asciiBox, binaryBox } from "./helpers/stl.ts"; | |
| 5 | import { expectBudget } from "./helpers/budget.ts"; | |
| 6 | ||
| 7 | const ascii = (s: string) => new TextEncoder().encode(s); | |
| 8 | ||
| 9 | test("a binary box: 12 triangles, bounds from the origin to its corner", () => { | |
| 10 | const r = readStl(binaryBox(40, 20, 10)); | |
| 11 | expect(r).toEqual({ ok: true, value: { format: "binary", triangles: 12, min: [0, 0, 0], max: [40, 20, 10] } }); | |
| 12 | if (r.ok) expect(sayStl(r.value)).toBe("STL (binary) · 12 triangles · 40.0 × 20.0 × 10.0"); | |
| 13 | }); | |
| 14 | ||
| 15 | test("a binary header that begins with 'solid' is still binary when the length fits", () => { | |
| 16 | const r = readStl(binaryBox(1, 2, 3, "solid exported by a CAD tool")); | |
| 17 | expect(r.ok && r.value.format).toBe("binary"); | |
| 18 | }); | |
| 19 | ||
| 20 | test("an ASCII box: 12 triangles, the same bounds", () => { | |
| 21 | const r = readStl(ascii(asciiBox(40, 20, 10))); | |
| 22 | expect(r).toEqual({ ok: true, value: { format: "ascii", triangles: 12, min: [0, 0, 0], max: [40, 20, 10] } }); | |
| 23 | if (r.ok) expect(sayStl(r.value)).toBe("STL (ascii) · 12 triangles · 40.0 × 20.0 × 10.0"); | |
| 24 | }); | |
| 25 | ||
| 26 | test("size is max − min per axis, and counts are grouped", () => { | |
| 27 | const stl = { format: "binary" as const, triangles: 12340, min: [-5, 1, 0] as const, max: [35, 21, 10] as const }; | |
| 28 | expect(stlSize(stl)).toEqual([40, 20, 10]); | |
| 29 | expect(sayStl(stl)).toBe("STL (binary) · 12,340 triangles · 40.0 × 20.0 × 10.0"); | |
| 30 | expect(sayStl({ ...stl, triangles: 1 })).toContain("· 1 triangle ·"); | |
| 31 | }); | |
| 32 | ||
| 33 | test("a truncated or lying file is an error naming why", () => { | |
| 34 | const full = binaryBox(1, 1, 1); | |
| 35 | const cut = readStl(full.subarray(0, full.length - 7)); | |
| 36 | expect(cut.ok).toBe(false); | |
| 37 | if (!cut.ok) expect(cut.error).toContain("truncated"); | |
| 38 | const lying = new Uint8Array(full); | |
| 39 | new DataView(lying.buffer).setUint32(80, 4_000_000_000, true); | |
| 40 | expect(readStl(lying).ok).toBe(false); | |
| 41 | expect(readStl(new Uint8Array(10))).toEqual({ ok: false, error: "too short for an STL header" }); | |
| 42 | const text = asciiBox(1, 1, 1); | |
| 43 | const half = readStl(ascii(text.slice(0, text.length / 2))); | |
| 44 | expect(half.ok).toBe(false); | |
| 45 | if (!half.ok) expect(half.error).toContain("truncated"); | |
| 46 | expect(readStl(ascii("solid x\nfacet normal 0 0 0\nouter loop\nvertex 1 2 nope\n")).ok).toBe(false); | |
| 47 | expect(readStl(ascii("solid empty\nendsolid empty\n"))).toEqual({ ok: false, error: "holds no triangles" }); | |
| 48 | const nan = binaryBox(1, 1, 1); | |
| 49 | new DataView(nan.buffer).setFloat32(84 + 12, NaN, true); | |
| 50 | expect(readStl(nan).ok).toBe(false); | |
| 51 | }); | |
| 52 | ||
| 53 | test("an ASCII body over the limit is too large to describe, and one at the limit reads fast", () => { | |
| 54 | const facet = "facet normal 0 0 0\nouter loop\nvertex 1 2 3\nvertex 4 5 6\nvertex 7 8 9\nendloop\nendfacet\n"; | |
| 55 | const fill = (bytes: number) => { | |
| 56 | const head = "solid big\n"; | |
| 57 | const tail = "endsolid big\n"; | |
| 58 | const n = Math.floor((bytes - head.length - tail.length) / facet.length); | |
| 59 | const body = head + facet.repeat(n) + tail; | |
| 60 | return { n, bytes: ascii(body + " ".repeat(bytes - body.length)) }; | |
| 61 | }; | |
| 62 | const at = fill(ASCII_LIMIT); | |
| 63 | expect(at.bytes.length).toBe(ASCII_LIMIT); | |
| 64 | const best = (bytes: Uint8Array) => { | |
| 65 | let ms = Infinity; | |
| 66 | for (let i = 0; i < 3; i++) { | |
| 67 | const start = performance.now(); | |
| 68 | readStl(bytes); | |
| 69 | ms = Math.min(ms, performance.now() - start); | |
| 70 | } | |
| 71 | return ms; | |
| 72 | }; | |
| 73 | // The baseline is a binary model of the same byte size read in this run, so load slows both alike. | |
| 74 | const bin = new Uint8Array(84 + 50 * Math.floor((ASCII_LIMIT - 84) / 50)); | |
| 75 | new DataView(bin.buffer).setUint32(80, (bin.length - 84) / 50, true); | |
| 76 | expect(readStl(at.bytes)).toEqual({ ok: true, value: { format: "ascii", triangles: at.n, min: [1, 2, 3], max: [7, 8, 9] } }); | |
| 77 | expectBudget(best(at.bytes), { ms: 50, against: best(bin) }); | |
| 78 | expect(readStl(fill(ASCII_LIMIT + 1).bytes)).toEqual({ ok: false, error: TOO_LARGE }); | |
| 79 | expect(readStl(fill(5_000_000).bytes)).toEqual({ ok: false, error: TOO_LARGE }); | |
| 80 | // Junk tokens are no slower to refuse: the limit is checked before any scan. | |
| 81 | expect(readStl(ascii("solid a\n" + "x ".repeat(2_500_000)))).toEqual({ ok: false, error: TOO_LARGE }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a binary header that begins with 'solid' and a length that does not fit names the length, not ASCII", () => { | |
| 85 | const full = binaryBox(1, 1, 1, "solid x"); | |
| 86 | const cut = readStl(full.subarray(0, full.length - 7)); | |
| 87 | expect(cut).toEqual({ ok: false, error: `truncated: says 12 triangles, holds ${full.length - 7} of ${full.length} bytes` }); | |
| 88 | const long = new Uint8Array(full.length + 3); | |
| 89 | long.set(full); | |
| 90 | expect(readStl(long)).toEqual({ ok: false, error: `says 12 triangles but holds ${full.length + 3} bytes, not ${full.length}` }); | |
| 91 | }); |
This call runs on the event loop for any
.stlup to 5 MB. Put the size gate here or inreadStl, so the parser never sees an unbounded body.