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}