From 651178a740f2d80bbbc8b73f306319385f720bed Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 30 Sep 2026 23:55:03 +0200 Subject: [PATCH] Format 3, step 6: /inspect opens format 3 The page opens capsules of format 3. The files go to the temporary file through a ZipSink, a lone file of one segment as it is and a ZIP otherwise, or to memory; the page shows the verdicts first, then the declared author and the comment as unchecked text of the creator, and then each path as text in a bdi, with its size, its mtime and the warnings of the CLI of the reference, compared by their key of R7. - files.ts: pathWarnings, fileFacts, and the names and order of the downloads: the file itself when it is the only one, with the ZIP of its folder second (decision 8), or the ZIP and each file. - opener.ts: OpenedFiles, and noRoom when the ZIP does not fit. - zipsink.ts: NoRoom, thrown by begin before writing anything. - check-build.mjs: the tables of pathrule-tables.ts never come with the first load of a page, and do come with the code on demand of /inspect and /create. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 8 + scripts/check-build.mjs | 7 + src/lib/components/OpenPanel.svelte | 250 +++++++++++++++++++++++++--- src/lib/inspector/files.test.ts | 65 ++++++++ src/lib/inspector/files.ts | 133 +++++++++++++++ src/lib/inspector/opener.test.ts | 51 +++++- src/lib/inspector/opener.ts | 93 +++++++++-- src/lib/inspector/zipsink.ts | 33 +++- vitest.config.ts | 1 + 10 files changed, 611 insertions(+), 36 deletions(-) create mode 100644 src/lib/inspector/files.test.ts create mode 100644 src/lib/inspector/files.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2b862..b96a099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../docs`). La versión que lo publique la decide el autor. +### Paso 6: `/inspect` abre el formato 3 + +- La página abre las cápsulas de formato 3: los ficheros van al fichero temporal por un `ZipSink`, o a la memoria, y se muestran primero los veredictos, después el autor declarado y el comentario, y luego cada ruta como texto, con su tamaño, su fecha y los avisos de la CLI de la referencia. Se descarga el fichero, si es el único, con el ZIP de su carpeta como segunda opción, o el ZIP de todos y cada fichero por separado. +- `files.ts` (avisos, rutas y descargas) y `opener.ts` (`OpenedFiles`, la falta de espacio), cargados bajo demanda; `ZipSink` comprueba el espacio al empezar. +- `check-build.mjs` exige que las tablas de las rutas no entren en la primera carga de ninguna página. + ### Paso 5: el ZIP de la página - `zipsink.ts`: `ZipSink`, el sumidero de la página sobre el fichero temporal de OPFS. Un fichero de un segmento va tal cual, y los demás casos a un ZIP propio, con el CRC-32 de cada entrada parcheado con una escritura posicionada y el directorio central al hacer commit; nada se publica antes del paso 18. `zipOf` hace el mismo ZIP en memoria. diff --git a/README.md b/README.md index 557af13..daa506d 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,13 @@ Tras los pasos 1 a 8, si la cápsula es válida y su fecha de apertura ya pasó - **El texto en claro** se muestra, cuando la cápsula se abre y es texto (`plaintextPreview`): UTF-8 imprimible, hasta 100 000 caracteres de sus primeros 128 KiB. Se muestra el CR LF de Windows como salto de línea y se omite el BOM, como hace un editor; la descarga conserva los bytes exactos. Lo que no es texto solo se ofrece para descargar. El de un fixture se abre en memoria, con su SHA-256 comparado con el del registro. El contenido va justo debajo del veredicto, antes de los pasos 9 a 18. - **El nombre de la descarga.** La cápsula no guarda el nombre del fichero que sella (§6, §55.2), así que se ofrece el del `.dkc` sin la extensión, como hace `age` con `informe.pdf.age`. Si ese nombre no tiene extensión propia, como el `capsula-.dkc` de `/create`, toma la del contenido (`contentExtension`): `.txt` para un texto, y `.pdf`, `.png`, `.jpg`, `.gif`, `.webp`, `.wav`, `.zip`, `.7z`, `.gz`, `.mp3`, `.mp4` u `.ogg` por sus primeros bytes. El de un fichero propio va a un fichero temporal del almacenamiento privado del navegador (OPFS, `tempfile.ts`), escrito con `createWritable`. El navegador guarda lo escrito en un fichero de intercambio que solo se confirma al cerrarlo, y `open` lo cierra tras el paso 18 y lo aborta ante cualquier fallo (§56). Después se ofrece para descargar, con el nombre del `.dkc` sin la extensión, como hace `age`, y cada carácter no imprimible cambiado por `_`, para que un carácter de control bidireccional no disfrace la extensión. Antes de empezar se compara el tamaño de `PAYLOAD_AGE` con la cuota libre (`navigator.storage.estimate()`). - **El fichero temporal se borra** al pulsar "Borrar", al abrir o cargar otra cápsula y al salir de la página (`pagehide`). Si el navegador terminó antes, se borra en la siguiente visita. Cada pestaña escribe en su propio directorio, `datekeys-open/`, y tiene un Web Lock con ese nombre mientras existe. Así la limpieza de otra pestaña nunca borra un fichero en uso; sin Web Locks, solo borra lo que tiene más de un día. Si el navegador no tiene OPFS o `createWritable`, o los rechaza (una ventana privada, datos del sitio bloqueados), o la cuota no alcanza, la página abre en memoria hasta 64 MiB. Una apertura en curso se detiene si se carga otra cápsula: su salida deja de aceptar datos, `open` falla en el paso 17 y su fichero se borra. Si la página vuelve de la caché de atrás y adelante tras borrar el fichero, ya no lo ofrece. +- **El formato 3** guarda ficheros con sus rutas (§29.2 a §29.7). Van al mismo fichero temporal por un `ZipSink`: un fichero de un solo segmento, tal cual; en los demás casos, un ZIP propio, y cada fichero es un tramo de ese ZIP (apartado 5 del diseño del formato 3). Sin OPFS, van a la memoria de la página, y el ZIP se compone allí con `zipOf`. Lo que se muestra sale de `files.ts`, que se carga con la apertura porque compara claves de R7: + - primero, las líneas de los veredictos del área de seguridad; después, el autor declarado y el comentario, como texto del creador sin comprobar, en un recuadro (§29.7); + - cada ruta como texto, en un ``, con los invisibles escritos como `\u…`, su tamaño y la fecha que declaró el creador; hasta 500 en la lista, y todas en el ZIP; + - los avisos de la CLI de la referencia, comparados por la clave de R7: un acceso directo (`.lnk`, `.url`, `.library-ms`, `.searchConnector-ms`), `desktop.ini`, una carpeta `.git`, un programa o un script, y un segmento que empieza por '-'; + - las descargas: el fichero, con su nombre pasado por `safeFileName`, si es el único, con el ZIP de su carpeta como segunda opción; si hay varios, el ZIP, llamado `.zip` o como el `.dkc`, y cada fichero por separado. + + Si el ZIP no cabe en el espacio que deja el navegador, `ZipSink` falla al empezar, con la longitud exacta, y la página lo dice. `check-build.mjs` comprueba que las tablas de `pathrule-tables.ts` no entran en la primera carga de ninguna página y sí en la carga bajo demanda de `/inspect` y `/create`. - **La fecha** se vuelve a mirar cuando llega y cuando la pestaña vuelve a verse, así que una cápsula inspeccionada antes de su fecha se puede abrir sin cargarla otra vez. `open` comprueba de nuevo el reloj en el paso 9. - **El resultado** muestra cada paso de 9 a 18 como muestra los de 1 a 8, con los checks que registra la referencia (desde la v0.9, también el 17 cuando se supera), el release verificado, las extensiones de CONTROL_CBOR, el tamaño y el SHA-256 del texto en claro y, en el formato 2, la regla de relleno y P. También avisa de que el texto está autenticado, pero no prueba quién lo escribió ni que sea el original si otros abrieron la cápsula antes (§55.1). @@ -167,6 +174,7 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una | `src/lib/inspector/opening.ts` | `buildOpenReport`: el modelo de la apertura (pasos 9 a 18, release, extensiones de CONTROL_CBOR, texto en claro), sin DOM ni reloj; nombre del fichero descifrado | | `src/lib/inspector/tempfile.ts` | El fichero temporal de OPFS, con un directorio y un Web Lock por pestaña y por zona (la apertura y crear), la cuota libre y la limpieza de lo que quedó. Su `writable` acepta además trozos con posición (`TempChunk`), como `FileSystemWritableFileStream`, para parchear el CRC-32 del ZIP | | `src/lib/inspector/crc32.ts`, `zip.ts` | El CRC-32 de ZIP, por trozos, y la disposición de un ZIP cuyas entradas se conocen de antemano: almacenadas, con nombres UTF-8 (bit 11), sin descriptores de datos ni entradas de carpeta; la hora DOS en UTC entre 1980 y 2107, el campo NTFS 0x000A, que manda, y 0x5455 cuando cabe en 32 bits con signo; ZIP64 por tamaño, posición o número de entradas | +| `src/lib/inspector/files.ts` | Lo que la página muestra de los ficheros de una cápsula de formato 3: cada ruta como texto, sus avisos por la clave de R7 con los textos de la CLI de la referencia, y el nombre y el orden de las descargas. Se carga bajo demanda con la apertura, porque trae las tablas de las rutas | | `src/lib/inspector/zipsink.ts` | El sumidero de la página para el formato 3 (apartado 5 del diseño): un fichero de un segmento va tal cual al fichero temporal, y los demás casos a un ZIP en ese fichero, con cada cabecera en su posición, el CRC-32 parcheado al cerrar cada fichero y el directorio central al hacer commit; cada fichero queda como un tramo continuo. `zipOf` hace el mismo ZIP en memoria, como un `Blob` de sus partes | | `src/lib/inspector/localtime.ts` | Una fecha y una hora locales de una zona como instante UTC, con `Intl`: las horas que no existen y las repetidas; la lista de zonas | | `src/lib/inspector/create-input.ts` | El formulario de `/create`, sin DOM, reloj ni writer: `planCapsule` comprueba los campos en su orden y da lo que se muestra antes de cifrar; los destinatarios y los nombres de los ficheros | diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs index 846b1ea..300f5f6 100644 --- a/scripts/check-build.mjs +++ b/scripts/check-build.mjs @@ -19,6 +19,8 @@ // the fixtures (.dkk files, plaintexts, identities, payload identities, // access material, CONTROL_CBOR, and the heads, salts, comments and paths // of format 3) is anywhere in the build; +// - a page loads the Unicode tables of the paths of format 3 with its first +// load, not on demand; // - the client bundle holds tlock-js, drand-client or Babel's helpers, or a // nested copy of a package other than the noble copy under // @noble/post-quantum (plan of phase 2, section 3 and decision 5), as @@ -349,6 +351,11 @@ for (const f of htmlFiles.filter(existsSync)) { const { eager, lazy } = pageScripts(f); const first = [...bundled(eager)].filter((n) => OPENING_PACKAGES.test(n)); if (first.length > 0) fail(`${rel(f)} loads ${first.join(', ')} with the page, not on demand`); + // The Unicode tables of the paths of format 3 (pathrule-tables.ts, some + // 120 KB) come with the opening and the writer, never with the page. + const holdsTables = (c) => Object.keys(chunks[c]?.modules ?? {}).some((id) => id.replaceAll('\\', '/').endsWith('src/lib/dkc/pathrule-tables.ts')); + if ([...eager].some(holdsTables)) fail(`${rel(f)} loads the tables of pathrule-tables.ts with the page, not on demand`); + if (ON_DEMAND[basename(f)] !== undefined && ![...lazy].some(holdsTables)) fail(`${rel(f)}: the code loaded on demand lacks pathrule-tables.ts`); const later = bundled(lazy); for (const n of ON_DEMAND[basename(f)] ?? []) { if (!later.has(n)) fail(`${rel(f)}: the code loaded on demand lacks ${n}`); diff --git a/src/lib/components/OpenPanel.svelte b/src/lib/components/OpenPanel.svelte index c0bac63..277c2d7 100644 --- a/src/lib/components/OpenPanel.svelte +++ b/src/lib/components/OpenPanel.svelte @@ -16,10 +16,18 @@ // start of the plaintext is shown when it is text (plaintextPreview). An // opening in progress stops when the panel is destroyed, and its file is // removed. + // + // The files of a format 3 capsule go to that same temporary file through a + // ZipSink: the file itself when there is one of one segment, and a ZIP + // otherwise, of which each file is a slice (design of format 3, section + // 5). The page shows the verdicts of the security area first, then the + // declared author and the comment, then each path as text with its + // warnings (spec §29.7), and offers the downloads. import { onDestroy, tick } from 'svelte'; import { toHex } from '$lib/dkc/index.ts'; import type { Fixture } from '$lib/inspector/fixtures.ts'; - import { errorGloss, escapeInvisible, formatByteCount, formatInteger } from '$lib/inspector/format.ts'; + import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger } from '$lib/inspector/format.ts'; + import type { OpenedFiles } from '$lib/inspector/opener.ts'; import { buildOpenReport, contentExtension, type OpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } from '$lib/inspector/opening.ts'; import { drandReleaseURL, parseReleaseText, releaseText } from '$lib/inspector/release-input.ts'; import type { Report } from '$lib/inspector/report.ts'; @@ -46,9 +54,27 @@ /** The largest plaintext opened in memory when the browser has no OPFS. */ const MEMORY_LIMIT = 64 << 20; + /** The files of a format 3 capsule that opened, and their downloads. */ + interface FilesView { + readonly files: OpenedFiles; + /** The main download and the ZIP, as object URLs, for the person's own capsule. */ + readonly primary?: { readonly url: string; readonly name: string; readonly size: number }; + readonly zip?: { readonly url: string; readonly name: string; readonly size: number }; + /** Whether each file has its own download. */ + readonly each: boolean; + /** Whether the files are in the temporary file, or in memory. */ + readonly temporary: boolean; + /** The start of the one file, when there is one and it is text. */ + readonly shown?: { readonly text: string; readonly cut: boolean }; + } + + /** How many files the list shows; the ZIP holds them all. */ + const LISTED = 500; + interface Result { readonly report: OpenReport; readonly ms: number; + readonly files?: FilesView; /** The plaintext offered for download: a temporary file, or memory. */ readonly download?: { readonly url: string; readonly name: string; readonly size: number; readonly temporary: boolean }; } @@ -83,9 +109,9 @@ const drandURL = $derived(drandReleaseURL(report.profile!.chainHash, round)); const payloadLength = $derived(report.prelude?.payloadLength ?? 0); - // The temporary file and the object URL of the last opening. + // The temporary file and the object URLs of the last opening. let temp: TempFile | undefined; - let objectURL: string | undefined; + let objectURLs: string[] = []; // The temporary file of the opening in progress, until it becomes `temp` // or is removed: discard() removes it too, so a panel destroyed or a page // left in the middle of an opening leaves nothing behind. @@ -95,8 +121,8 @@ let openId = 0; async function discard(): Promise { - if (objectURL !== undefined) URL.revokeObjectURL(objectURL); - objectURL = undefined; + for (const url of objectURLs) URL.revokeObjectURL(url); + objectURLs = []; const files = [temp, pending]; temp = undefined; pending = undefined; @@ -111,10 +137,28 @@ // pagehide also fires when the page goes into the back/forward cache: if // it comes back, it must not offer what was deleted here. function leave(): void { - if (result?.download !== undefined) deleted = true; + if (result?.download !== undefined || result?.files?.primary !== undefined) deleted = true; void discard(); } + // An object URL of this opening, revoked by discard. + function objectURL(blob: Blob): string { + const url = URL.createObjectURL(blob); + objectURLs.push(url); + return url; + } + + // Saves file i of a format 3 capsule on its own: an object URL of its + // slice, revoked a minute after the click. + function saveFile(files: OpenedFiles, i: number, name: string): void { + const url = URL.createObjectURL(files.file(i)); + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } + async function deleteNow(): Promise { await discard(); deleted = true; @@ -148,9 +192,9 @@ try { const opener = await import('$lib/inspector/opener.ts'); if (stale()) return; + let free: number | undefined; if (fixture === undefined) { const platform = browserPlatform(); - let free: number | undefined; if (platform !== undefined) { try { free = await freeSpace(platform); @@ -178,6 +222,11 @@ ...(timeAndKey && accessKey !== undefined ? { accessKey } : {}), // A stale opening stops writing, so open aborts the file and stops. ...(out === undefined ? {} : { output: { writable: cancellable(out.writable, stale), file: () => out.file(), remove: () => out.remove() } }), + // Format 3: the files without an mtime take the time of the round in + // a ZIP, which must fit in the room the browser leaves. + roundTime: Math.floor((report.unlock?.epochMs ?? 0) / 1000), + ...(free === undefined ? {} : { room: free }), + capsuleName: report.fileName, }); if (stale()) { if (attempt.ok) attempt.plaintext?.fill(0); @@ -187,6 +236,12 @@ await fail(attempt.problem, attempt.field); return; } + if (attempt.noRoom !== undefined) { + await fail( + `Los ficheros de la cápsula ocupan ${formatByteCount(attempt.noRoom.needed)} y el navegador deja ${formatByteCount(attempt.noRoom.room)} libres para esta página. Libera espacio o usa la CLI (datekeys decrypt).`, + ); + return; + } const opened = attempt.opened.error === undefined; let shown: { text: string; cut: boolean } | undefined; // The extension of the download, from the content: the capsule does @@ -208,24 +263,47 @@ }, ); let download: Result['download']; - if (opened && fixture === undefined) { + let files: FilesView | undefined; + if (attempt.files !== undefined) { + const f = attempt.files; + const d = f.downloads; + const offer = fixture === undefined; + const primary = + offer && d.primary !== undefined + ? d.primary.kind === 'zip' + ? { url: objectURL(f.zip!), name: d.primary.name, size: f.zip!.size } + : { url: objectURL(f.file(d.primary.index)), name: d.primary.name, size: f.file(d.primary.index).size } + : undefined; + const zip = offer && d.zip !== undefined ? { url: objectURL(f.zip!), name: d.zip, size: f.zip!.size } : undefined; + files = { + files: f, + ...(primary === undefined ? {} : { primary }), + ...(zip === undefined ? {} : { zip }), + each: offer && d.each, + temporary: t !== undefined, + ...(shown === undefined ? {} : { shown }), + }; + if (t !== undefined) { + temp = t; + pending = undefined; + t = undefined; + } + } else if (opened && fixture === undefined) { const name = plaintextFileName(report.fileName, ext); if (t !== undefined) { const file = await t.file(); if (stale()) return; - objectURL = URL.createObjectURL(file); temp = t; pending = undefined; t = undefined; - download = { url: objectURL, name, size: file.size, temporary: true }; + download = { url: objectURL(file), name, size: file.size, temporary: true }; } else { const blob = new Blob([attempt.plaintext! as Uint8Array]); - objectURL = URL.createObjectURL(blob); - download = { url: objectURL, name, size: blob.size, temporary: false }; + download = { url: objectURL(blob), name, size: blob.size, temporary: false }; } } attempt.plaintext?.fill(0); - result = download === undefined ? { report: r, ms: attempt.ms } : { report: r, ms: attempt.ms, download }; + result = { report: r, ms: attempt.ms, ...(download === undefined ? {} : { download }), ...(files === undefined ? {} : { files }) }; announcement = r.opened ? 'Cápsula abierta: pasos 9 a 18 superados.' : `Apertura rechazada en el paso ${r.failure!.step}, ${r.failure!.code}.`; @@ -270,11 +348,6 @@ La fecha de apertura todavía no ha llegado según el reloj de este dispositivo. Hasta entonces drand no publica la firma de la ronda {round} y nadie puede abrir la cápsula, tampoco esta página (paso 9, ERR_RELEASE_UNAVAILABLE).

- {:else if report.prelude?.format === 3} -

- Esta cápsula es de formato 3: guarda varios ficheros, con sus rutas. Esta página todavía no los entrega; lo hará en - su próxima versión. -

{:else}
{#if timeAndKey} @@ -351,8 +424,9 @@ {#if fixture === undefined}

El texto descifrado se escribe en un fichero temporal privado de este navegador y solo se muestra y se ofrece - si age lo autentica entero (§56); si es texto, la página muestra el principio. Se borra cuando lo pides, al abrir o cargar otra cápsula y al salir de la página; sin - ese fichero, se abre en la memoria de la página hasta 64 MiB. + si age lo autentica entero (§56); si es texto, la página muestra el principio. Si la cápsula guarda varios + ficheros, ese fichero temporal es un ZIP con todos ellos. Se borra cuando lo pides, al abrir o cargar otra + cápsula y al salir de la página; sin ese fichero, se abre en la memoria de la página hasta 64 MiB.

{/if} @@ -462,6 +536,93 @@ {/if} + {#if result.files} + {@const v = result.files} + {@const f = v.files} +
+

Contenido

+
    + {#each f.verdicts as line, i (i)} +
  • {line}
  • + {/each} +
+ {#if f.head.author !== ''} +

autor declarado (texto del creador, sin comprobar):

+

{escapeInvisible(f.head.author)}

+ {/if} + {#if f.head.comment !== ''} +
+
Comentario del creador (sin comprobar)
+
{escapeInvisible(f.head.comment)}
+
+ {/if} + {#if v.shown} +
{v.shown.cut ? `${v.shown.text}\n…` : v.shown.text}
+ {#if v.shown.cut} +

Se muestra el principio del fichero, como mucho {formatInteger(SHOWN_TEXT)} caracteres.

+ {/if} + {/if} + {#if v.primary} + {#if deleted} +

+ {v.temporary + ? 'Fichero temporal borrado. Para descargar los ficheros otra vez, abre de nuevo la cápsula.' + : 'Los ficheros ya no están en la página. Para descargarlos otra vez, abre de nuevo la cápsula.'} +

+ {:else} +
+ Descargar {v.primary.name} + {#if v.zip} + Descargar {v.zip.name}, con su carpeta + {/if} + {#if v.temporary} + + {/if} +
+

+ {#if v.temporary} + Están en un fichero temporal privado de este navegador. Se borra cuando lo pides, al abrir o cargar otra + cápsula y al salir de la página. + {:else} + Están en la memoria de esta página, porque el navegador no le deja un fichero temporal privado. Se liberan + al abrir o cargar otra cápsula y al salir de la página. + {/if} +

+ {/if} + {/if} + {#if f.facts.length === 0} +

La cápsula no guarda ficheros, solo el comentario.

+ {:else} +

Ficheros ({formatInteger(f.facts.length)})

+
    + {#each f.facts.slice(0, LISTED) as file, i (i)} +
  • + {file.shown} + {formatByteCount(file.size)}{file.mtimeMs === undefined ? '' : `, fecha del fichero ${formatDateTime(file.mtimeMs)}`} + {#if v.each && !deleted} + + {/if} + {#each file.warnings as warning, k (k)} +

    aviso: {warning}

    + {/each} +
  • + {/each} +
+ {#if f.facts.length > LISTED} +

Y {formatInteger(f.facts.length - LISTED)} ficheros más, que están en el ZIP.

+ {/if} +

+ Las rutas son texto del creador: se muestran tal cual, con los caracteres invisibles escritos como + \u…. La fecha de cada fichero es la que declaró el creador y no prueba nada. +

+ {/if} +
+ {/if} + {#if r.steps.length > 0}

Pasos 9 a 18

@@ -495,6 +656,55 @@