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 <noreply@anthropic.com>
main
dev 7 days ago
parent ee82f4382f
commit 651178a740

@ -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.

@ -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-<fecha>.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/<id>`, 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 `<bdi dir=auto>`, 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 `<primer segmento común>.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 |

@ -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}`);

@ -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<void> {
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<void> {
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<ArrayBuffer>]);
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).
</p>
{:else if report.prelude?.format === 3}
<p class="prose">
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.
</p>
{:else}
<form class="form" onsubmit={submit} novalidate>
{#if timeAndKey}
@ -351,8 +424,9 @@
{#if fixture === undefined}
<p class="hint">
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.
</p>
{/if}
</div>
@ -462,6 +536,93 @@
</section>
{/if}
{#if result.files}
{@const v = result.files}
{@const f = v.files}
<section class="block" aria-labelledby="files-title">
<h3 id="files-title">Contenido</h3>
<ul class="verdict-lines">
{#each f.verdicts as line, i (i)}
<li>{line}</li>
{/each}
</ul>
{#if f.head.author !== ''}
<p class="creator-label">autor declarado (texto del creador, sin comprobar):</p>
<p class="creator"><bdi dir="auto">{escapeInvisible(f.head.author)}</bdi></p>
{/if}
{#if f.head.comment !== ''}
<figure class="comment">
<figcaption>Comentario del creador (sin comprobar)</figcaption>
<pre><bdi dir="auto">{escapeInvisible(f.head.comment)}</bdi></pre>
</figure>
{/if}
{#if v.shown}
<pre class="plaintext">{v.shown.cut ? `${v.shown.text}\n…` : v.shown.text}</pre>
{#if v.shown.cut}
<p class="muted">Se muestra el principio del fichero, como mucho {formatInteger(SHOWN_TEXT)} caracteres.</p>
{/if}
{/if}
{#if v.primary}
{#if deleted}
<p class="muted">
{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.'}
</p>
{:else}
<div class="actions">
<a class="button" href={v.primary.url} download={v.primary.name}>Descargar {v.primary.name}</a>
{#if v.zip}
<a class="button quiet" href={v.zip.url} download={v.zip.name}>Descargar {v.zip.name}, con su carpeta</a>
{/if}
{#if v.temporary}
<button class="button quiet" type="button" onclick={deleteNow}>Borrar el fichero temporal</button>
{/if}
</div>
<p class="hint">
{#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}
</p>
{/if}
{/if}
{#if f.facts.length === 0}
<p class="muted">La cápsula no guarda ficheros, solo el comentario.</p>
{:else}
<h4 class="files-title">Ficheros ({formatInteger(f.facts.length)})</h4>
<ul class="files">
{#each f.facts.slice(0, LISTED) as file, i (i)}
<li>
<bdi dir="auto" class="mono path">{file.shown}</bdi>
<span class="muted"
>{formatByteCount(file.size)}{file.mtimeMs === undefined ? '' : `, fecha del fichero ${formatDateTime(file.mtimeMs)}`}</span
>
{#if v.each && !deleted}
<button class="button quiet small" type="button" onclick={() => saveFile(f, i, file.name)}
>Descargar</button
>
{/if}
{#each file.warnings as warning, k (k)}
<p class="warning">aviso: {warning}</p>
{/each}
</li>
{/each}
</ul>
{#if f.facts.length > LISTED}
<p class="muted">Y {formatInteger(f.facts.length - LISTED)} ficheros más, que están en el ZIP.</p>
{/if}
<p class="hint">
Las rutas son texto del creador: se muestran tal cual, con los caracteres invisibles escritos como
<code>\u…</code>. La fecha de cada fichero es la que declaró el creador y no prueba nada.
</p>
{/if}
</section>
{/if}
{#if r.steps.length > 0}
<section class="block" aria-labelledby="open-steps-title">
<h3 id="open-steps-title">Pasos 9 a 18</h3>
@ -495,6 +656,55 @@
</section>
<style>
.verdict-lines {
margin: 0;
padding-left: 1.2rem;
font-weight: 600;
}
.creator-label {
margin: 0;
color: var(--ink-2, inherit);
}
.creator {
margin: 0;
overflow-wrap: anywhere;
}
.comment {
margin: 0;
padding: 0.75rem 1rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
}
.comment figcaption {
font-weight: 600;
margin-bottom: 0.4rem;
}
.comment pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.files {
display: grid;
gap: 0.5rem;
margin: 0;
padding-left: 1.2rem;
}
.files .path {
overflow-wrap: anywhere;
margin-right: 0.5rem;
}
.files .warning {
margin: 0.2rem 0 0;
color: var(--warn, #9a5b00);
}
.files-title {
margin: 0;
}
.small {
padding: 0.1rem 0.5rem;
font-size: 0.85em;
}
.open {
display: grid;
grid-template-columns: minmax(0, 1fr);

@ -0,0 +1,65 @@
// Tests of files.ts: the warnings of the paths, compared by their key of R7
// in the words of the CLI of the reference, the files as the page shows
// them, and the names and the order of the downloads.
import { describe, expect, it } from 'vitest';
import type { Head } from '../dkc/head.ts';
import { downloadFileName, downloads, fileFacts, pathWarnings, zipFileName } from './files.ts';
const GIT_INSIDE = 'está dentro de una carpeta .git, cuyos ganchos pueden ejecutar órdenes';
const GIT = 'se llama .git y puede apuntar a otro repositorio';
const INI = 'es la configuración de una carpeta de Windows';
const DASH = "un nombre que empieza por '-' puede tomarse por una opción en una orden";
const SHORTCUT = 'es un acceso directo de Windows: puede abrir otro programa o una dirección';
const PROGRAM = 'es un programa o un script: no lo ejecutes sin saber qué hace';
const file = (path: string, size = 1, mtime?: number) => ({ path, size, start: 0, end: size, sha256: new Uint8Array(32), ...(mtime === undefined ? {} : { mtime }) });
const head = (...paths: string[]): Head => ({ salt: new Uint8Array(32), comment: '', author: '', files: paths.map((p) => file(p)), critical: [], noncritical: [] });
describe('pathWarnings', () => {
it.each([
['nota.txt', []],
['.bashrc', []],
['.git/config', [GIT_INSIDE]],
['repo/.GIT', [GIT]],
['repo/.g\u200dit', [GIT]],
['Desktop.INI', [INI]],
['fotos/-rf', [DASH]],
['Informe.LNK', [SHORTCUT]],
['enlaces/web.url', [SHORTCUT]],
['b\u00fasqueda.searchConnector-ms', [SHORTCUT]],
['x/instalar.EXE', [PROGRAM]],
['a.b.ps1', [PROGRAM]],
['Programa.app', [PROGRAM]],
['-x/.git/y.bat', [DASH, GIT_INSIDE, PROGRAM]],
] as const)('%s', (path, want) => {
expect(pathWarnings(path)).toEqual(want);
});
});
describe('fileFacts', () => {
it('shows each path as text, escaped, with its name, size, mtime in milliseconds and warnings', () => {
const h: Head = { ...head(), files: [file('docs/a\u202eb.txt', 3, 1_790_769_600), file('Informe.lnk', 0)] };
expect(fileFacts(h)).toEqual([
{ path: 'docs/a\u202eb.txt', shown: 'docs/a\\u202eb.txt', name: 'a_b.txt', size: 3, mtimeMs: 1_790_769_600_000, warnings: [] },
{ path: 'Informe.lnk', shown: 'Informe.lnk', name: 'Informe.lnk', size: 0, warnings: [SHORTCUT] },
]);
});
});
describe('the downloads', () => {
it('names a file by its last segment, made safe, and the ZIP by the common folder or by the .dkc', () => {
expect(downloadFileName('a/b/c.txt')).toBe('c.txt');
expect(zipFileName(head('fotos/a.jpg', 'fotos/2025/b.jpg'), 'capsula-20260930T120000Z.dkc')).toBe('fotos.zip');
expect(zipFileName(head('fotos/a.jpg', 'docs/b.txt'), 'capsula-20260930T120000Z.dkc')).toBe('capsula-20260930T120000Z.zip');
expect(zipFileName(head('a.txt', 'b.txt'), 'mia.DKC')).toBe('mia.zip');
expect(zipFileName(head('fotos/a.jpg', 'b.txt'), 'sin extension')).toBe('sin extension.zip');
});
it('offers nothing without files, the file itself when it is the only one, with the ZIP of its folder second, and the ZIP of several with each file', () => {
expect(downloads(head(), 'none', 'c.dkc')).toEqual({ each: false });
expect(downloads(head('nota.txt'), 'file', 'c.dkc')).toEqual({ primary: { kind: 'file', index: 0, name: 'nota.txt' }, each: false });
expect(downloads(head('informes/2026.pdf'), 'zip', 'c.dkc')).toEqual({ primary: { kind: 'file', index: 0, name: '2026.pdf' }, zip: 'informes.zip', each: false });
expect(downloads(head('a.txt', 'b/c.txt'), 'zip', 'c.dkc')).toEqual({ primary: { kind: 'zip', name: 'c.zip' }, each: true });
});
});

@ -0,0 +1,133 @@
// What the page shows of the files of a format 3 capsule once it opened
// (design of format 3, sections 3 to 5): each path as text, its warnings,
// and the downloads. It compares keys of R7, so it brings the Unicode tables
// of pathrule.ts, which the first load of the page does not carry: the page
// loads it on demand, with the opening. The warnings are those of the CLI of
// the reference (risks of cmd/datekeys), in its words.
import type { Head } from '../dkc/head.ts';
import { pathKey } from '../dkc/pathrule.ts';
import { escapeInvisible, safeFileName } from './format.ts';
import type { ZipMode } from './zipsink.ts';
const keys = (...names: string[]): Set<string> => new Set(names.map(pathKey));
const SHORTCUTS = keys('.lnk', '.url', '.library-ms', '.searchConnector-ms');
const PROGRAMS = keys(
'.exe',
'.com',
'.bat',
'.cmd',
'.scr',
'.pif',
'.msi',
'.msp',
'.cpl',
'.hta',
'.jar',
'.js',
'.jse',
'.vbs',
'.vbe',
'.wsf',
'.wsh',
'.ps1',
'.psm1',
'.reg',
'.sh',
'.command',
'.app',
);
const DESKTOP_INI = pathKey('desktop.ini');
const GIT = pathKey('.git');
/**
* The warnings of a path: a Windows shortcut or folder setting, a .git
* folder, a program, or a segment that starts with '-', compared by their
* key of R7, so that ".GIT" or "Informe.LNK" warn too (spec §29.7).
*/
export function pathWarnings(path: string): string[] {
const out: string[] = [];
const segs = path.split('/');
segs.forEach((s, i) => {
const k = pathKey(s);
if (k === GIT && i < segs.length - 1) out.push('está dentro de una carpeta .git, cuyos ganchos pueden ejecutar órdenes');
else if (k === GIT) out.push('se llama .git y puede apuntar a otro repositorio');
else if (k === DESKTOP_INI) out.push('es la configuración de una carpeta de Windows');
if (s.startsWith('-')) out.push("un nombre que empieza por '-' puede tomarse por una opción en una orden");
});
const last = segs.at(-1)!;
const dot = last.lastIndexOf('.');
if (dot > 0) {
const ext = pathKey(last.slice(dot));
if (SHORTCUTS.has(ext)) out.push('es un acceso directo de Windows: puede abrir otro programa o una dirección');
else if (PROGRAMS.has(ext)) out.push('es un programa o un script: no lo ejecutes sin saber qué hace');
}
return out;
}
/** A file of the head as the page shows it. */
export interface FileFacts {
/** The path as the head stores it, for the downloads. */
readonly path: string;
/** The path to show: its invisible characters escaped. */
readonly shown: string;
/** The name under which it is saved on its own (downloadFileName). */
readonly name: string;
readonly size: number;
/** The mtime of the head, in milliseconds, when it has one. It proves nothing. */
readonly mtimeMs?: number;
readonly warnings: readonly string[];
}
/** The files of a head as the page shows them, in its order. */
export function fileFacts(head: Head): FileFacts[] {
return head.files.map((f) => ({
path: f.path,
shown: escapeInvisible(f.path),
name: downloadFileName(f.path),
size: f.size,
...(f.mtime === undefined ? {} : { mtimeMs: f.mtime * 1000 }),
warnings: pathWarnings(f.path),
}));
}
/** The name under which a file of the capsule is saved: its last segment, made safe (safeFileName). */
export function downloadFileName(path: string): string {
return safeFileName(path.split('/').at(-1)!);
}
/**
* The name of the ZIP of the files: `<first segment>.zip` when every path
* starts with the same folder, and otherwise the name of the .dkc with .zip
* instead of its extension (design of format 3, section 3).
*/
export function zipFileName(head: Head, dkcName: string): string {
const firsts = new Set(head.files.map((f) => (f.path.includes('/') ? f.path.split('/')[0]! : undefined)));
const [only] = firsts;
const stem = firsts.size === 1 && only !== undefined ? only : dkcName.replace(/\.dkc$/i, '');
return `${safeFileName(stem)}.zip`;
}
/** What the page offers to download, and in which order. */
export interface Downloads {
/** The main download: the file itself, index of the head, or the ZIP. */
readonly primary?: { readonly kind: 'file'; readonly index: number; readonly name: string } | { readonly kind: 'zip'; readonly name: string };
/** The ZIP, second, when the main download is the one file inside a folder (decision 8). */
readonly zip?: string;
/** Whether each file is offered on its own too, besides the ZIP. */
readonly each: boolean;
}
/**
* The downloads of the files of a head laid out in `mode`: nothing without
* files; the file itself when it is the only one, even inside a folder, with
* the ZIP of the folder second; and the ZIP of all of them otherwise, with
* each file on its own.
*/
export function downloads(head: Head, mode: ZipMode, dkcName: string): Downloads {
if (mode === 'none') return { each: false };
const file = { kind: 'file', index: 0, name: downloadFileName(head.files[0]!.path) } as const;
if (mode === 'file') return { primary: file, each: false };
if (head.files.length === 1) return { primary: file, zip: zipFileName(head, dkcName), each: false };
return { primary: { kind: 'zip', name: zipFileName(head, dkcName) }, each: true };
}

@ -2,8 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { encrypt } from '../dkc/encrypt.ts';
import { equalBytes, fromHex, type Instant, quicknet, roundTime, sha256, TIME_ONLY, toHex } from '../dkc/index.ts';
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
import { memoryFile } from '../dkc/testing/zip.ts';
import { openCapsule, type OpenRequest, parseIdentities, PREVIEW_BYTES, systemClock } from './opener.ts';
import type { TempFile } from './tempfile.ts';
import { zipLayout } from './zip.ts';
import { zipEntries } from './zipsink.ts';
afterEach(() => {
vi.useRealTimers();
@ -70,9 +73,11 @@ describe('openCapsule', () => {
expect(r.ms).toBeGreaterThanOrEqual(0);
if (f.record.format === 3) {
// The files of the head, in memory, with the SHA-256 of the record.
expect([r.plaintext, r.digest, r.preview], name).toEqual([undefined, undefined, undefined]);
// A preview of the one file, when there is only one.
expect([r.plaintext, r.digest, r.preview !== undefined], name).toEqual([undefined, undefined, f.record.files?.length === 1]);
expect(r.opened.head!.files.map((x) => x.path), name).toEqual((f.record.files ?? []).map((x) => x.path));
expect(await Promise.all(r.files!.map(async (b) => toHex(await sha256(b)))), name).toEqual((f.record.files ?? []).map((x) => x.sha256));
const files = r.files!;
expect(await Promise.all(files.head.files.map(async (_, i) => toHex(await sha256(new Uint8Array(await files.file(i).arrayBuffer()))))), name).toEqual((f.record.files ?? []).map((x) => x.sha256));
continue;
}
const plaintext = readBytes(`fixtures/${f.record.plaintext_file}`);
@ -141,6 +146,48 @@ describe('openCapsule', () => {
}
});
// The temporary file of OPFS takes positioned writes, which the ZIP of a
// format 3 capsule needs to patch its CRC-32.
function zipTemp(): TempFile {
const m = memoryFile();
return { writable: m.stream, file: async () => new File([m.bytes() as Uint8Array<ArrayBuffer>], 'plaintext'), remove: async () => undefined };
}
const bytesOf = async (b: Blob): Promise<string> => toHex(await sha256(new Uint8Array(await b.arrayBuffer())));
it('opens the files of a format 3 capsule into the temporary file, the one file itself or a ZIP of which each file is a slice', async () => {
const tree = fixture('format3_tree');
const round = Math.floor(Date.UTC(2023, 7, 23, 15, 59, 27) / 1000);
const r = await openCapsule({ ...tree.request, output: zipTemp(), roundTime: round, room: 1 << 30, capsuleName: 'format3_tree.dkc' });
if (!r.ok) throw new Error(r.problem);
const files = r.files!;
expect([r.opened.error, files.mode, files.downloads.primary, files.verdicts]).toEqual([undefined, 'zip', { kind: 'zip', name: 'format3_tree.zip' }, ['Sin firma de autor.']]);
const want = (tree.record as unknown as { files: { sha256: string }[] }).files.map((f) => f.sha256);
expect(await Promise.all(files.head.files.map((_, i) => bytesOf(files.file(i))))).toEqual(want);
expect(files.zip!.size).toBe(zipLayout(zipEntries(files.head, round)).length);
expect(r.preview).toBeUndefined();
const single = fixture('format3_single');
const s = await openCapsule({ ...single.request, output: zipTemp(), roundTime: round });
if (!s.ok) throw new Error(s.problem);
expect([s.files!.mode, s.files!.zip, s.files!.downloads.primary, s.preview?.whole]).toEqual(['file', undefined, { kind: 'file', index: 0, name: 'nota.txt' }, true]);
});
it('opens the files of a format 3 capsule into memory, with the ZIP made there, of the same length', async () => {
const tree = fixture('format3_tree');
const r = await openCapsule(tree.request);
if (!r.ok) throw new Error(r.problem);
const files = r.files!;
expect([files.mode, files.downloads.primary]).toEqual(['zip', { kind: 'zip', name: 'capsula.zip' }]);
expect(files.zip!.size).toBe(zipLayout(zipEntries(files.head, 0)).length);
expect(await bytesOf(files.file(1))).toBe((tree.record as unknown as { files: { sha256: string }[] }).files[1]!.sha256);
});
it('says when the files of a format 3 capsule do not fit in the room of the temporary file', async () => {
const tree = fixture('format3_tree');
const r = await openCapsule({ ...tree.request, output: zipTemp(), roundTime: 0, room: 1000 });
if (!r.ok) throw new Error(r.problem);
expect([r.opened.error?.code, r.opened.inspection.checks.at(-1)?.step, r.noRoom?.room, (r.noRoom?.needed ?? 0) > 1000]).toEqual(['ERR_INTEGRITY', 17, 1000, true]);
});
it('does not run open when an identity line is not an identity, and never quotes it', async () => {
const f = fixture('time_and_key_recipients');
const secret = 'AGE-SECRET-KEY-1NOTAKEY';

@ -4,17 +4,22 @@
// builds what it shows from the result with opening.ts, which imports no
// noble. No release is fetched: the caller supplies it directly (spec §63
// step 10), pasted or from the record of a fixture. The files of a format 3
// capsule go to a sink: in memory for now, for the fixtures; the sink of the
// page, a ZIP in OPFS, comes with the page of format 3.
// capsule go to a sink: the ZipSink of the temporary file, or memory; what
// the page shows of them comes from files.ts, which this module brings on
// demand with the Unicode tables of the paths.
import { type Instant, readAccessKey, sha256, toHex } from '../dkc/index.ts';
import { sha256Stream } from '../dkc/digest.ts';
import type { Head } from '../dkc/head.ts';
import { open, type Opened } from '../dkc/open.ts';
import { verdictLines } from '../dkc/security.ts';
import { MemorySink } from '../dkc/sink.ts';
import { downloads, type Downloads, type FileFacts, fileFacts } from './files.ts';
import { suppliedRelease } from '../dkc/release.ts';
import { parseX25519Identity } from '../dkc/x25519.ts';
import type { SuppliedRelease } from './release-input.ts';
import type { TempFile } from './tempfile.ts';
import { NoRoom, type ZipMode, zipMode, ZipSink, zipOf } from './zipsink.ts';
export interface OpenRequest {
/** The capsule: the bytes of a fixture, or the person's file. */
@ -26,11 +31,18 @@ export interface OpenRequest {
/** A .dkk file, for time_and_key. */
readonly accessKey?: Blob;
/**
* Where the plaintext of a capsule of format 1 or 2 goes; memory when
* omitted, and then the files of a format 3 capsule go to memory too. A
* format 3 capsule needs memory: with an output, open rejects it.
* Where the plaintext of a capsule of format 1 or 2 goes, and the files of
* a format 3 capsule through a ZipSink; memory when omitted.
*/
readonly output?: TempFile;
/**
* For a format 3 capsule: the time of its round, in seconds, for the files
* without an mtime in a ZIP, and the bytes the temporary file may take.
*/
readonly roundTime?: number;
readonly room?: number;
/** The name of the .dkc, for the name of the ZIP of its files. */
readonly capsuleName?: string;
/** The clock; the system clock when omitted. */
readonly now?: () => Instant;
}
@ -48,8 +60,14 @@ export type OpenAttempt =
readonly opened: Opened;
/** The plaintext, when a capsule of format 1 or 2 opened into memory. */
readonly plaintext?: Uint8Array;
/** The content of each file of a format 3 capsule that opened, in the order of opened.head. */
readonly files?: readonly Uint8Array[];
/** The files of a format 3 capsule that opened. */
readonly files?: OpenedFiles;
/**
* The files of a format 3 capsule did not fit in the room of the
* temporary file: the opening failed at step 17 for that, not for the
* capsule.
*/
readonly noRoom?: { readonly needed: number; readonly room: number };
/** Length and SHA-256 of the plaintext, when the capsule opened. */
readonly digest?: { readonly length: number; readonly sha256: string };
/**
@ -62,6 +80,20 @@ export type OpenAttempt =
readonly ms: number;
};
/** The files of a format 3 capsule that opened, as the page offers them. */
export interface OpenedFiles {
readonly head: Head;
/** The lines of the verdicts of the security area, which go first (spec §29.7). */
readonly verdicts: readonly string[];
readonly facts: readonly FileFacts[];
readonly mode: ZipMode;
readonly downloads: Downloads;
/** File i: a slice of the temporary file, or its bytes in memory. */
file(i: number): Blob;
/** The ZIP of all the files, when the downloads offer it. */
readonly zip?: Blob;
}
/** How much of the plaintext the page reads to show it. */
export const PREVIEW_BYTES = 128 << 10;
@ -93,6 +125,35 @@ export function parseIdentities(text: string): { ok: true; ids: Uint8Array[] } |
return { ok: true, ids };
}
// What the page offers of the files of a format 3 capsule that opened, in
// the temporary file or in memory.
async function openedFiles(opened: Opened, req: OpenRequest, memory: MemorySink | undefined, zip: ZipSink | undefined): Promise<OpenedFiles> {
const head = opened.head!;
const mode = zip?.mode ?? zipMode(head);
const base = {
head,
verdicts: verdictLines(opened.verdicts!),
facts: fileFacts(head),
mode,
downloads: downloads(head, mode, req.capsuleName ?? 'capsula.dkc'),
};
if (zip !== undefined) {
const written = await req.output!.file();
return { ...base, file: (i) => written.slice(...zip.ranges[i]!), ...(mode === 'zip' ? { zip: written } : {}) };
}
const contents = memory!.opened!.files;
return {
...base,
file: (i) => new Blob([contents[i]! as Uint8Array<ArrayBuffer>]),
...(mode === 'zip' ? { zip: zipOf(head, contents, req.roundTime ?? 0) } : {}),
};
}
// The first PREVIEW_BYTES of a file, and whether they are all of it.
async function firstBytes(file: Blob): Promise<{ bytes: Uint8Array; whole: boolean }> {
return { bytes: new Uint8Array(await file.slice(0, PREVIEW_BYTES).arrayBuffer()), whole: file.size <= PREVIEW_BYTES };
}
/** Runs steps 1 to 18 on the capsule with what the person supplied. */
export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
const parsed = parseIdentities(req.identities ?? '');
@ -108,18 +169,28 @@ export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
}
}
const start = performance.now();
const sink = req.output === undefined ? new MemorySink() : undefined;
// The output takes the plaintext of formats 1 and 2, and the ZipSink the
// files of format 3 on the same writable: the format decides which one
// writes to it.
const memory = req.output === undefined ? new MemorySink() : undefined;
const zip = req.output === undefined ? undefined : new ZipSink(req.output.writable, req.roundTime ?? 0, req.room);
const opened = await open(req.capsule, {
source: suppliedRelease(req.release),
now: req.now ?? systemClock,
identities: ids,
...(accessKeyFile === undefined ? {} : { accessKeyFile }),
...(req.output === undefined ? {} : { output: req.output.writable }),
...(sink === undefined ? {} : { sink }),
sink: memory ?? zip!,
});
const ms = performance.now() - start;
if (opened.error !== undefined) return { ok: true, opened, ms };
if (opened.head !== undefined) return { ok: true, opened, files: sink!.opened!.files, ms };
if (opened.error !== undefined) {
const cause = opened.error.cause;
return { ok: true, opened, ...(cause instanceof NoRoom ? { noRoom: { needed: cause.needed, room: cause.room } } : {}), ms };
}
if (opened.head !== undefined) {
const files = await openedFiles(opened, req, memory, zip);
return { ok: true, opened, files, ...(files.head.files.length === 1 ? { preview: await firstBytes(files.file(0)) } : {}), ms };
}
if (req.output !== undefined) {
const file = await req.output.file();
const digest = { length: file.size, sha256: toHex(await sha256Stream(file.stream())) };

@ -34,6 +34,24 @@ export function zipEntries(head: Head, roundTime: number): ZipEntry[] {
return head.files.map((f) => ({ name: f.path, size: f.size, mtime: f.mtime ?? roundTime }));
}
/**
* The failure of begin when the file written would not fit in the room
* given: open reports it at step 17, ERR_INTEGRITY, with this error as its
* cause, so that the page can say what happened.
*/
export class NoRoom extends Error {
/** The bytes the file would take, and the bytes there are. */
readonly needed: number;
readonly room: number;
constructor(needed: number, room: number) {
super(`the files take ${needed} bytes, and there is room for ${room}`);
this.name = 'NoRoom';
this.needed = needed;
this.room = room;
}
}
/**
* A sink that writes the files of a capsule into one writable of OPFS, the
* file itself or a ZIP (see zipMode). It closes the writable at commit and
@ -43,20 +61,25 @@ export function zipEntries(head: Head, roundTime: number): ZipEntry[] {
export class ZipSink implements Sink {
readonly #out: WritableStream<TempChunk>;
readonly #roundTime: number;
readonly #room: number | undefined;
#writer: WritableStreamDefaultWriter<TempChunk> | undefined;
#mode: ZipMode = 'none';
#layout: ZipLayout | undefined;
#crcs: number[] = [];
#ranges: (readonly [number, number])[] = [];
/** `roundTime` is the time of the round of the capsule, in seconds, for the entries without an mtime. */
constructor(out: WritableStream<TempChunk>, roundTime: number) {
/**
* `roundTime` is the time of the round of the capsule, in seconds, for the
* entries without an mtime; `room`, when given, the bytes the file may
* take, which begin checks once the head gives the exact length.
*/
constructor(out: WritableStream<TempChunk>, roundTime: number, room?: number) {
this.#out = out;
this.#roundTime = roundTime;
this.#room = room;
}
begin(head: Head): void {
this.#writer = this.#out.getWriter();
this.#mode = zipMode(head);
if (this.#mode === 'zip') {
this.#layout = zipLayout(zipEntries(head, this.#roundTime));
@ -64,6 +87,10 @@ export class ZipSink implements Sink {
} else if (this.#mode === 'file') {
this.#ranges = [[0, head.files[0]!.size]];
}
// A begin that fails cleans up after itself: the writable, not taken,
// is aborted by its owner.
if (this.#room !== undefined && this.length > this.#room) throw new NoRoom(this.length, this.#room);
this.#writer = this.#out.getWriter();
}
create(i: number): WritableStream<Uint8Array> {

@ -62,6 +62,7 @@ export default defineConfig({
'src/lib/inspector/crc32.ts': { 100: true },
'src/lib/inspector/zip.ts': { 100: true },
'src/lib/inspector/zipsink.ts': { 100: true },
'src/lib/inspector/files.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },

Loading…
Cancel
Save

Powered by TurnKey Linux.