diff --git a/scripts/e2e-recovery.mjs b/scripts/e2e-recovery.mjs index 907025c0..0b0b0a3b 100644 --- a/scripts/e2e-recovery.mjs +++ b/scripts/e2e-recovery.mjs @@ -206,6 +206,12 @@ try { }) for (const [ok, name] of results) check(ok, name) + // Saving removed this page's checkpoint, so leave an unsaved edit behind for the reloaded page to offer. + await page.evaluate(async () => { + const { useStore } = await import('/src/state/store.ts') + useStore.setState({ showBackfaces: !useStore.getState().showBackfaces }) + }) + await waitStatus('Local checkpoint saved') await page.reload({ waitUntil: 'networkidle0' }) await page.waitForSelector('[data-test="recovery-bar"]') const before = await page.$$eval('[data-test="recovery-bar"] option', (options) => options.length) diff --git a/src/App.tsx b/src/App.tsx index 0128fbee..a1ecadb3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,7 +82,7 @@ import { lengthInputText, unitWord, type DisplayUnits } from './core/units' import { useShell } from './state/shellStore' import { useMark } from './state/markStore' import { useThickness } from './state/thicknessStore' -import { imageScaleX, useFlat } from './state/flatStore' +import { edgesSettled, imageScaleX, useFlat } from './state/flatStore' import { sectionViewFrames, useSectionView } from './state/sectionViewStore' import type { FieldScale } from './core/field/colormap' import { deviationScale } from './core/deviation/deviation' @@ -1325,20 +1325,14 @@ export default function App() { flat: { openImage, closeImage: () => commitImage(null), - detectEdges: async (sensitivity) => { + detectEdges: async (sensitivity, signal) => { const flat = useFlat.getState() if (flat.subject.kind !== 'image' || !flatGrayRef.current) return flat.edgeCount - const before = flat.edgeVersion + const settled = edgesSettled(flat, signal) // A new sensitivity detects again by itself (the effect above). if (sensitivity !== flat.edgeSensitivity) flat.setEdgeSensitivity(sensitivity) else void runEdgeDetect() - await new Promise((resolve) => { - const stop = useFlat.subscribe((s) => { - if (s.edgeVersion === before) return - stop() - resolve() - }) - }) + await settled return useFlat.getState().edgeCount }, edgeIndex: () => activeEdgeIndex(), diff --git a/src/app/addressOpen.ts b/src/app/addressOpen.ts index 7ef360ff..7fd056f6 100644 --- a/src/app/addressOpen.ts +++ b/src/app/addressOpen.ts @@ -78,10 +78,14 @@ function nameOf(url: URL): string { } } +/** The most a file named in the address may hold: a link anyone can send + * must not be able to fill the tab's memory, and no project is larger. */ +export const MAX_ADDRESS_BYTES = 1024 * 1024 * 1024 + /** Fetch the file. The errors say what to do: the status for a refusal, * and for a failed request — most often a server that does not let this * site read it — the server to ask. */ -export async function fetchAddressFile({ url, name }: AddressOpen): Promise { +export async function fetchAddressFile({ url, name }: AddressOpen, maxBytes = MAX_ADDRESS_BYTES): Promise { let res: Response try { res = await fetch(url, { mode: 'cors' }) @@ -90,6 +94,25 @@ export async function fetchAddressFile({ url, name }: AddressOpen): Promise new Error(`${name} is too large to open from a link: the limit is ${Math.round(maxBytes / 1024 / 1024)} MB. Download it and open it from disk instead.`) + const length = Number(res.headers.get('content-length')) + if (length > maxBytes) throw tooLarge() + // The length a server states can be missing or wrong, so the body is read + // a piece at a time and given up on once it runs past the limit. + const chunks: Uint8Array[] = [] + let total = 0 + if (res.body) { + const reader = res.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + void reader.cancel().catch(() => {}) + throw tooLarge() + } + chunks.push(value) + } + } + return new File(chunks as BlobPart[], name, { type: res.headers.get('content-type') ?? '' }) } diff --git a/src/app/imports.ts b/src/app/imports.ts index c8024193..043d3831 100644 --- a/src/app/imports.ts +++ b/src/app/imports.ts @@ -11,7 +11,7 @@ import type { CreaseSetting } from '../core/geometry/crease' import type { Rigid } from '../core/deviation/rigid' import type { SceneManager } from '../viewer/SceneManager' import { grayscaleOf } from '../core/flat/edgeClient' -import { imagePixelsPerMm } from '../core/flat/image' +import { assertImageDecodable, imagePixelsPerMm } from '../core/flat/image' import { is3mfFile, isModelFile, isReferenceFile, isTextureFile } from '../core/formats' import type { MeshUnits } from '../core/meshUnits' import { useStore } from '../state/store' @@ -106,6 +106,7 @@ export async function prepareNominal(client: MeshWorkerClient, scene: SceneManag export async function prepareImage(file: File): Promise { const bytes = new Uint8Array(await file.arrayBuffer()) + assertImageDecodable(file.name, bytes) const meta = imagePixelsPerMm(bytes) const bitmap = await createImageBitmap(file, { imageOrientation: 'flipY' }) try { diff --git a/src/app/project.ts b/src/app/project.ts index 9e161e03..5649dbbb 100644 --- a/src/app/project.ts +++ b/src/app/project.ts @@ -167,12 +167,16 @@ export function collectProject( // a section in use writes its part over a kept one of the same key. const kept = keptParts() Object.assign(manifest, kept.parts) - members.push(...kept.members) const ctx = { sources, scanSaved: manifest.scan !== null, addMember: (name: string, bytes: Uint8Array) => members.push({ name, bytes }) } for (const section of projectSections()) { const part = section.collect(ctx) if (part !== undefined) manifest[section.key] = part } + // A kept file goes in only under a name nothing in this session writes: + // the project it came from may have used a name the scan, reference or + // image is saved under, and the archive keeps the last file of a name. + const taken = new Set(members.map((m) => m.name)) + members.push(...kept.members.filter((m) => !taken.has(m.name))) return { manifest, members } } diff --git a/src/app/recoveryStorage.ts b/src/app/recoveryStorage.ts index 330c39e7..7ca91093 100644 --- a/src/app/recoveryStorage.ts +++ b/src/app/recoveryStorage.ts @@ -15,6 +15,19 @@ interface Checkpoint extends CheckpointInfo { manifest: ProjectManifest } const lockName = (id: string) => `scanruler-recovery:${id}` +/** How many checkpoints left by closed pages are kept, and for how long. + * Every page writes a slot of its own, each with a copy of its source + * files, so without a limit the browser's storage fills session by session. */ +export const RECOVERY_KEEP = 10 +export const RECOVERY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 + +/** The checkpoints to let go of: those past the age limit, and all but the + * newest few of the rest. */ +export function staleCheckpoints(rows: readonly CheckpointInfo[], now: number, keep = RECOVERY_KEEP, maxAgeMs = RECOVERY_MAX_AGE_MS): string[] { + const newest = [...rows].sort((a, b) => b.savedAt - a.savedAt) + return newest.filter((row, i) => i >= keep || now - row.savedAt > maxAgeMs).map((row) => row.id) +} + /** Each page owns a separate slot. A tab can never overwrite another tab's * checkpoint. Metadata reads deliberately do not load the large source files. */ export class RecoveryStorage { diff --git a/src/app/scanFiles.ts b/src/app/scanFiles.ts index 655e6db5..f73e1898 100644 --- a/src/app/scanFiles.ts +++ b/src/app/scanFiles.ts @@ -11,6 +11,7 @@ import { unzipSync } from 'fflate' import { is3mfFile, isModelFile, isTextureFile } from '../core/formats' +import { assertImageDecodable } from '../core/flat/image' import type { ObjCompanions, TextureImage } from '../core/parsers/objTexture' /** What came with a model file, by the file. */ @@ -113,6 +114,7 @@ export async function scanCompanions(name: string, bytes: Uint8Array, extras: re async function decodeImage(name: string, bytes: Uint8Array): Promise { // Colours as the file has them: no colour management, and no // premultiplying, which would darken whatever the alpha channel holds. + assertImageDecodable(name, bytes) const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]), { colorSpaceConversion: 'none', premultiplyAlpha: 'none' }) try { const { width, height } = bitmap diff --git a/src/app/useRecovery.ts b/src/app/useRecovery.ts index 403e52d6..9fcc0a30 100644 --- a/src/app/useRecovery.ts +++ b/src/app/useRecovery.ts @@ -17,7 +17,7 @@ import { projectSections } from './projectSections' import { watchUnsaved } from './project' import { useShell } from '../state/shellStore' import { ProjectClient } from '../core/project/projectClient' -import { RecoveryStorage, type CheckpointInfo } from './recoveryStorage' +import { RecoveryStorage, staleCheckpoints, type CheckpointInfo } from './recoveryStorage' import { ProjectSnapshotCache, type ProjectSnapshot } from './projectSnapshot' type Snapshot = ProjectSnapshot @@ -46,6 +46,7 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope const saved = useRef(null) const hadContent = useRef(false) const refresh = useRef(() => {}) + const forgetCheckpoint = useRef(() => {}) const storage = useRef(null) const [state, setState] = useState({ dirty: false, status: '', available: [] as CheckpointInfo[], working: false }) const update = (patch: Partial) => setState((s) => @@ -62,7 +63,12 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope const reportError = (error: unknown) => { if (!disposed) update({ status: `Local recovery unavailable: ${error instanceof Error ? error.message : String(error)}. Use Save Project.` }) } - void db.left().then((available) => { if (!disposed) update({ available }) }, reportError) + // What closed pages left behind, less what is too old or too many to keep. + void db.left().then(async (rows) => { + const stale = new Set(staleCheckpoints(rows, Date.now())) + for (const id of stale) await db.remove(id) + if (!disposed) update({ available: rows.filter((row) => !stale.has(row.id)) }) + }).catch(reportError) // What was last found, for a question asked while the session is busy. let dirty = false @@ -118,6 +124,7 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope scheduleInspect() } refresh.current = scheduleInspect + forgetCheckpoint.current = () => { checkpointKey = '' } // Zustand stores use immutable updates; watch only project fields. Picking, // progress and pointer motion must not serialize a large measurement set. const watch = (store: { subscribe: (fn: (s: T, prev: T) => void) => () => void }, keys: (keyof T)[], notify = changed) => @@ -156,23 +163,35 @@ export function useRecovery(capture: () => Snapshot, blocked: () => boolean, ope document.removeEventListener('visibilitychange', visibility) window.removeEventListener('beforeunload', beforeUnload) refresh.current = () => {} + forgetCheckpoint.current = () => {} storage.current = null release() db.close() } }, [cache]) - const markSaved = (snapshot: Snapshot) => { saved.current = cache.key(snapshot); refresh.current() } + const markSaved = (snapshot: Snapshot) => { + saved.current = cache.key(snapshot) + refresh.current() + // Saved work needs no checkpoint: a later change writes a new one, even + // back to a state checkpointed before, since the slot is gone. + forgetCheckpoint.current() + const db = storage.current + void db?.remove(db.id).catch(() => {}) + } const restored = () => { saved.current = null; update({ available: [] }); refresh.current() } const restore = async (id: string) => { - if (!storage.current || callbacks.current.blocked()) return + const db = storage.current + if (!db || callbacks.current.blocked()) return update({ working: true }) let worker: ProjectClient | null = null try { - const { manifest, members } = await storage.current.read(id) + const { manifest, members } = await db.read(id) worker = new ProjectClient() const bytes = await worker.pack(manifest, members) await callbacks.current.open(new File([bytes as BlobPart], 'recovered.scanruler')) + // The work is this page's now, checkpointed in this page's own slot. + await db.remove(id).catch(() => {}) } catch (error) { update({ status: `Recovery failed: ${error instanceof Error ? error.message : String(error)}` }) } finally { worker?.dispose(); update({ working: false }) } diff --git a/src/commands/core/flat.ts b/src/commands/core/flat.ts index 4f8a486e..4ff3d5c9 100644 --- a/src/commands/core/flat.ts +++ b/src/commands/core/flat.ts @@ -237,12 +237,12 @@ const detectEdges: Command<{ sensitivity?: number }> = { 'Find the edges in the image again at a sensitivity from 0 (only the strongest) to 1 (every faint one) — what the edge methods of flat.fit and the snapping of every place follow. The chains are numbered afresh. Needs the browser. Returns how many edge chains were found.', input: obj({ sensitivity: num('0 to 1.', { minimum: 0, maximum: 1 }) }), label: () => 'detect edges', - run: async ({ sensitivity }) => { + run: async ({ sensitivity }, ctx) => { const flat = commandHost().flat if (!flat) throw new CommandError('unavailable', 'Edge detection needs the browser.') requireSheet() showWorkspace('flat') - const chains = await flat.detectEdges(sensitivity ?? useFlat.getState().edgeSensitivity) + const chains = await flat.detectEdges(sensitivity ?? useFlat.getState().edgeSensitivity, ctx.signal) return { chains } }, } diff --git a/src/commands/host.ts b/src/commands/host.ts index ac1e54a2..8ef39e91 100644 --- a/src/commands/host.ts +++ b/src/commands/host.ts @@ -51,8 +51,9 @@ export interface FlatHost { openImage(file: File): Promise /** Take the image off the sheet, as a project without one does. */ closeImage(): void - /** Find the image's edges again at this sensitivity, 0 to 1. */ - detectEdges(sensitivity: number): Promise + /** Find the image's edges again at this sensitivity, 0 to 1. The wait for + * them is given up on when `signal` aborts. */ + detectEdges(sensitivity: number, signal?: AbortSignal): Promise /** What a pick on the sheet snaps to — the subject's own edges. */ edgeIndex(): EdgeIndex | null /** The sheet on the stage, or null with nothing on it. */ diff --git a/src/core/flat/image.ts b/src/core/flat/image.ts index 6067707b..7d46b00c 100644 --- a/src/core/flat/image.ts +++ b/src/core/flat/image.ts @@ -92,3 +92,47 @@ function jpegPixelsPerMm(b: Uint8Array): PixelsPerMm | null { } return null } + +/** The most pixels an image may hold to be decoded. A header of a few bytes + * can declare a picture of billions of pixels, which the browser would try + * to decode on the page's own thread; a flatbed scan of a whole A4 sheet at + * 1200 dpi is about 140 Mpx, so this leaves room for any real one. */ +export const MAX_IMAGE_PIXELS = 200_000_000 + +/** Throws when the image's header declares more pixels than can safely be + * decoded. PNG and JPEG are read; another format, or a header that cannot + * be read, is left to the browser's own limits. */ +export function assertImageDecodable(name: string, bytes: Uint8Array, maxPixels = MAX_IMAGE_PIXELS): void { + const size = isPng(bytes) ? pngSize(bytes) : isJpeg(bytes) ? jpegSize(bytes) : null + if (size && size.width * size.height > maxPixels) { + throw new Error(`${name} is too large to open: ${size.width} x ${size.height} pixels, more than the ${Math.round(maxPixels / 1e6)} million this app can decode.`) + } +} + +/** The size in the IHDR chunk, which a PNG is required to start with. */ +function pngSize(b: Uint8Array): { width: number; height: number } | null { + if (b.length < 24 || String.fromCharCode(b[12], b[13], b[14], b[15]) !== 'IHDR') return null + return { width: u32(b, 16), height: u32(b, 20) } +} + +/** The size in the first start-of-frame segment: any SOFn marker but DHT + * (C4), JPG (C8) and DAC (CC), which share the range. */ +function jpegSize(b: Uint8Array): { width: number; height: number } | null { + let at = 2 + while (at + 4 <= b.length) { + if (b[at] !== 0xff) return null + const marker = b[at + 1] + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) { + at += 2 + continue + } + if (marker === 0xda || marker === 0xd9) return null + const length = u16(b, at + 2) + if (length < 2) return null + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return at + 9 <= b.length ? { width: u16(b, at + 7), height: u16(b, at + 5) } : null + } + at += 2 + length + } + return null +} diff --git a/src/state/flatStore.ts b/src/state/flatStore.ts index 402e6b3e..e6e70c20 100644 --- a/src/state/flatStore.ts +++ b/src/state/flatStore.ts @@ -1167,3 +1167,27 @@ export const useFlat = create()((set, get) => ({ return { profiles } }), })) + +/** Settles once the edge detection running from `from` is over: its edges + * found or failed, or its image replaced, which leaves its answer to no one. + * A detection can also never finish, so the wait is given up on, and the + * store let go of, when `signal` aborts. */ +export function edgesSettled(from: Pick, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason) + const done = () => { + stop() + signal?.removeEventListener('abort', abort) + } + const abort = () => { + done() + reject(signal!.reason) + } + const stop = useFlat.subscribe((s) => { + if (s.edgeVersion === from.edgeVersion && s.imageVersion === from.imageVersion) return + done() + resolve() + }) + signal?.addEventListener('abort', abort) + }) +} diff --git a/src/viewer/FlatScene.ts b/src/viewer/FlatScene.ts index a1c54885..e89e8481 100644 --- a/src/viewer/FlatScene.ts +++ b/src/viewer/FlatScene.ts @@ -970,13 +970,24 @@ export class FlatScene { this.container.appendChild(div) this.bandDiv = div const move = (ev: PointerEvent) => this.layoutBand(ev.clientX, ev.clientY) - const up = (ev: PointerEvent) => { + const stop = () => { document.removeEventListener('pointermove', move) document.removeEventListener('pointerup', up) + document.removeEventListener('pointercancel', cancel) + } + const up = (ev: PointerEvent) => { + stop() this.finishBand(ev.clientX, ev.clientY, ev) } + // A gesture the browser takes over selects nothing: left listening, the + // band would box-select at whatever release came next, anywhere. + const cancel = () => { + stop() + this.dropBand() + } document.addEventListener('pointermove', move) document.addEventListener('pointerup', up) + document.addEventListener('pointercancel', cancel) this.layoutBand(e.clientX, e.clientY) } diff --git a/tests/addressOpen.test.ts b/tests/addressOpen.test.ts index 2ae51543..35b806b3 100644 --- a/tests/addressOpen.test.ts +++ b/tests/addressOpen.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { describe, expect, it } from 'vitest' -import { openFromAddress, workspaceFromAddress } from '../src/app/addressOpen' +import { afterEach, describe, expect, it } from 'vitest' +import { fetchAddressFile, openFromAddress, workspaceFromAddress } from '../src/app/addressOpen' const PAGE = 'https://scanruler.example/' const STORE = 'https://files.example/' @@ -82,3 +82,36 @@ describe('workspaceFromAddress', () => { expect(workspaceFromAddress({ search: '?open=https://h.example/a.stl&workspace=%20Thickness' })).toBe('thickness') }) }) + +describe('fetchAddressFile', () => { + const realFetch = globalThis.fetch + afterEach(() => { globalThis.fetch = realFetch }) + const target = { url: 'https://host.example/big.stl', name: 'big.stl' } + + it('refuses a file the server says is too large, before reading it', async () => { + globalThis.fetch = async () => new Response(new Uint8Array(8), { headers: { 'content-length': '2000' } }) + await expect(fetchAddressFile(target, 1000)).rejects.toThrow(/too large/) + }) + + it('stops reading a body that runs past the cap', async () => { + // A body with no length that keeps coming: the read stops at the cap + // rather than at the end, which an endless one never reaches. + let sent = 0 + const long = new ReadableStream({ + pull: (c) => { + sent += 100 + c.enqueue(new Uint8Array(100)) + if (sent >= 100_000) c.close() + }, + }) + globalThis.fetch = async () => new Response(long) + await expect(fetchAddressFile(target, 1000)).rejects.toThrow(/too large/) + expect(sent).toBeLessThan(5000) + }) + + it('reads a file within the cap', async () => { + globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3])) + const file = await fetchAddressFile(target, 1000) + expect([...new Uint8Array(await file.arrayBuffer())]).toEqual([1, 2, 3]) + }) +}) diff --git a/tests/edgeWait.test.ts b/tests/edgeWait.test.ts new file mode 100644 index 00000000..ceb276e2 --- /dev/null +++ b/tests/edgeWait.test.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Waiting on an edge detection lets go of the store whatever becomes of the +// detection: done, failed, given up on by its caller, or its image gone. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { edgesSettled, useFlat } from '../src/state/flatStore' + +function countListeners() { + let live = 0 + const subscribe = useFlat.subscribe + const spy = vi.spyOn(useFlat, 'subscribe').mockImplementation((listener) => { + live++ + const stop = subscribe(listener) + return () => { + live-- + stop() + } + }) + return { live: () => live, spy } +} + +describe('edgesSettled', () => { + afterEach(() => vi.restoreAllMocks()) + + it('settles and lets go when the edges are found', async () => { + const { live } = countListeners() + const wait = edgesSettled(useFlat.getState()) + useFlat.getState().resolveEdges(3) + await wait + expect(live()).toBe(0) + }) + + it('lets go when its caller gives up, though the edges never come', async () => { + const { live } = countListeners() + const abort = new AbortController() + const wait = edgesSettled(useFlat.getState(), abort.signal) + expect(live()).toBe(1) + abort.abort() + await expect(wait).rejects.toThrow() + expect(live()).toBe(0) + }) + + it('settles when another image replaces the one being detected', async () => { + const { live } = countListeners() + const wait = edgesSettled(useFlat.getState()) + useFlat.setState((s) => ({ imageVersion: s.imageVersion + 1 })) + await wait + expect(live()).toBe(0) + }) +}) diff --git a/tests/flatBand.test.ts b/tests/flatBand.test.ts new file mode 100644 index 00000000..34277387 --- /dev/null +++ b/tests/flatBand.test.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The 2D sheet's rubber band, driven through its own methods on a stand-in +// for the scene: a gesture the browser cancels draws no region, and leaves +// nothing listening for the next release. A real FlatScene needs WebGL, so +// what is checked here is the band's handling of the document's events. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FlatScene } from '../src/viewer/FlatScene' + +type Band = { beginBand(e: PointerEvent): void; bandDiv: unknown } + +function pointer(type: string, x = 0, y = 0): PointerEvent { + return Object.assign(new Event(type), { clientX: x, clientY: y, altKey: false }) as PointerEvent +} + +describe('the rubber band', () => { + const realDocument = globalThis.document + afterEach(() => { + globalThis.document = realDocument + }) + + it('is dropped on pointercancel, and the next release selects nothing', () => { + const doc = Object.assign(new EventTarget(), { + createElement: () => ({ style: {}, className: '', remove: vi.fn() }), + }) + globalThis.document = doc as unknown as Document + const proto = FlatScene.prototype as unknown as Record + const scene = { + container: { appendChild: () => {}, getBoundingClientRect: () => ({ x: 0, y: 0 }) }, + bandDiv: null, + bandStart: null, + layoutBand: proto.layoutBand, + dropBand: proto.dropBand, + finishBand: vi.fn(), + } + ;(proto.beginBand as Band['beginBand']).call(scene, pointer('pointerdown', 10, 10)) + expect(scene.bandDiv).not.toBeNull() + doc.dispatchEvent(pointer('pointercancel', 40, 40)) + expect(scene.bandDiv).toBeNull() + doc.dispatchEvent(pointer('pointerup', 80, 80)) + expect(scene.finishBand).not.toHaveBeenCalled() + }) +}) diff --git a/tests/flatImage.test.ts b/tests/flatImage.test.ts index 1a81c355..ffa5ae77 100644 --- a/tests/flatImage.test.ts +++ b/tests/flatImage.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, it } from 'vitest' -import { imagePixelsPerMm } from '../src/core/flat/image' +import { assertImageDecodable, imagePixelsPerMm } from '../src/core/flat/image' // Containers built by hand, chunk by chunk — the parser reads metadata only, // so no pixel data (or valid CRC) is needed. @@ -88,3 +88,17 @@ describe('anything else', () => { expect(imagePixelsPerMm(new Uint8Array(0))).toBeNull() }) }) + +describe('assertImageDecodable', () => { + it('refuses an image whose header declares too many pixels to decode', () => { + // A few dozen bytes can declare 30000 x 30000: 3.6 GB once decoded. + const huge = png([chunk('IHDR', [...u32(30000), ...u32(30000), 8, 6, 0, 0, 0])]) + expect(() => assertImageDecodable('texture.png', huge)).toThrow(/too large/) + const sof = [0xff, 0xc0, ...u16(17), 8, ...u16(30000), ...u16(30000), 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1] + expect(() => assertImageDecodable('photo.jpg', new Uint8Array([0xff, 0xd8, ...sof]))).toThrow(/too large/) + }) + + it('lets an image of ordinary size through', () => { + expect(() => assertImageDecodable('texture.png', png([IHDR]))).not.toThrow() + }) +}) diff --git a/tests/projectSections.test.ts b/tests/projectSections.test.ts index cf8f1826..c159d5b1 100644 --- a/tests/projectSections.test.ts +++ b/tests/projectSections.test.ts @@ -37,6 +37,16 @@ describe('parts of a project no plugin here owns', () => { expect(back.members.map((m) => m.name)).toContain('future/widget.bin') }) + it('never write over a file the session saves itself', () => { + // A project with no scan of its own can carry a file under the name the + // scan is saved as; once the user loads a scan, the scan must win. + const manifest = { ...saved({ future: { widgets: [] } }), scan: null } + keepUnownedParts(manifest, new Map([['scan.stl', new Uint8Array([6, 6, 6])]])) + const back = collectProject(sources(), null, 'test') + const scans = back.members.filter((m) => m.name === 'scan.stl') + expect(scans.map((m) => [...m.bytes])).toEqual([[1, 2, 3]]) + }) + it('count as something to save, even with nothing else', () => { expect(projectHasContent(emptySources())).toBe(false) keepUnownedParts(saved({ future: { widgets: [] } }), new Map()) diff --git a/tests/recoveryPrune.test.ts b/tests/recoveryPrune.test.ts new file mode 100644 index 00000000..04d3c493 --- /dev/null +++ b/tests/recoveryPrune.test.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Which checkpoints left by closed pages are let go of. The storage itself is +// IndexedDB, which the tests do not have; the choice is checked on its own. +import { describe, expect, it } from 'vitest' +import { RECOVERY_KEEP, RECOVERY_MAX_AGE_MS, staleCheckpoints } from '../src/app/recoveryStorage' + +const DAY = 24 * 60 * 60 * 1000 +const NOW = 1_800_000_000_000 +const row = (id: string, ageMs: number) => ({ id, name: id, savedAt: NOW - ageMs }) + +describe('staleCheckpoints', () => { + it('lets go of checkpoints older than the age limit', () => { + const rows = [row('fresh', DAY), row('old', RECOVERY_MAX_AGE_MS + DAY)] + expect(staleCheckpoints(rows, NOW)).toEqual(['old']) + }) + + it('keeps only the newest few, however recent the rest', () => { + const rows = Array.from({ length: RECOVERY_KEEP + 3 }, (_, i) => row(`p${i}`, i * 1000)) + expect(staleCheckpoints(rows, NOW).sort()).toEqual(rows.slice(RECOVERY_KEEP).map((r) => r.id).sort()) + }) + + it('keeps everything recent and few', () => { + expect(staleCheckpoints([row('a', 0), row('b', DAY)], NOW)).toEqual([]) + }) +})