diff --git a/CHANGELOG.md b/CHANGELOG.md index b96a099..ee40082 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 7: `/create` con ficheros y carpetas + +- La página cifra ficheros y carpetas, elegidos o soltados, con un comentario y un autor declarado. Las rutas se pueden editar y se comprueban mientras se escriben, cada problema en su fila y en español; los ficheros de los sistemas quedan fuera, tachados, salvo que se marquen; y una casilla, marcada por defecto, guarda la fecha de cada fichero. +- El tamaño exacto del `.dkc` antes de escribir, con los ficheros medidos una vez (`measureFiles`, `headLengthOf` y `bodyLengthOf` en `lengths.ts`), y el progreso de las dos lecturas de `encryptFiles`. +- `create-files.ts` (la lista, sin tablas) y `create-check.ts` (las reglas en español, bajo demanda), al 100 %. Go abre una cápsula de la página, y `/inspect` también. + ### 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. diff --git a/README.md b/README.md index daa506d..acd88cd 100644 --- a/README.md +++ b/README.md @@ -139,16 +139,26 @@ Medido en Chromium (el navegador de la app de escritorio) sobre la compilación ### Crear -`/create` (plan de la fase 3, sección 9, con las decisiones del paso 6) cifra un fichero propio en un `.dkc` y, si se pide, en una `.dkk` portable, todo en el navegador y sin red. Desde el paso 4 del plan del formato 3 el `.dkc` es de formato 3, con el fichero bajo su nombre (§62.1, regla 1); los ficheros, las carpetas, las rutas editables, el comentario y el autor llegan con el paso 7. +`/create` (plan de la fase 3, sección 9, con las decisiones del paso 6, y apartados 3 y 5 del diseño del formato 3) cifra ficheros y carpetas propios, con un comentario y un autor declarado, en un `.dkc` de formato 3 (§62.1, regla 1) y, si se pide, en una `.dkk` portable, todo en el navegador y sin red. -- **El formulario** (`create-input.ts`) pide el fichero, elegido o soltado en la página. Su nombre es la ruta del fichero en la cápsula, y su fecha de modificación la mtime, los dos cifrados en el head; un nombre que rompe una regla de las rutas (§29.5) hace fallar la escritura con el texto de la regla. Después, el día y la hora, en la zona del dispositivo, en otra de las que conoce el navegador o en UTC. Y la política: «solo con la fecha» (`time_only`, la de por defecto) o «con la fecha y una clave» (`time_and_key`). Esta lleva destinatarios `age1…`, uno por línea y comprobados mientras se escriben (`recipient.ts`, sin noble), y la casilla de la clave portable, marcada por defecto; como mucho 16 credenciales. Una ayuda plegable explica qué es un destinatario de `age` y cómo se consigue: quien abrirá la cápsula crea su par con `age-keygen` y envía solo su `age1…`. Los campos se comprueban en su orden, y el foco va al campo del primer problema. +- **Lo que guarda** (`create-files.ts`, con la primera carga): ficheros elegidos con `multiple`, una carpeta con `webkitdirectory` o lo que se suelte en la página, carpetas incluidas, que se recorren con `webkitGetAsEntry` hasta el final. Un fichero va con su nombre, y el de una carpeta con el nombre de la carpeta delante, como `webkitRelativePath` y la CLI de la referencia. Las carpetas vacías no se guardan, y un recorrido se detiene en cuanto pasa de 65 535 ficheros. Una ruta que ya está en la lista se sustituye. Los ficheros que crean los sistemas dentro de una carpeta (`.DS_Store`, `Thumbs.db` y `desktop.ini`, comparados como `strings.EqualFold`, `._*` y lo que hay bajo `__MACOSX`) quedan fuera, tachados, con una casilla para marcarlos, como los deja fuera `collect.go`; uno elegido suelto, no. +- **Las rutas se pueden editar**, y se comprueban a medida que se escriben con las reglas del lector (`create-check.ts`, bajo demanda con las tablas de Unicode): cada problema en su fila, en español y con su regla, y un choque de R7 en las dos rutas. Una prueba de propiedad exige que la página no vea ningún problema exactamente cuando `checkPath` y `checkTree` aceptan las rutas. Una lista de más de 2000 ficheros se vuelve a comprobar tras una pausa al escribir, con el problema de cada ruta y la clave de cada segmento en memoria; se muestran los 500 primeros y los que han tenido un problema. Cada fila da el tamaño, la fecha y la ruta original si cambió. +- **El comentario y el autor declarado** son opcionales, se comprueban igual (§29.6) y van cifrados en el head, con el comentario en LF. La casilla de la fecha de modificación, marcada por defecto, guarda la de cada fichero, que se omite si cae fuera de 1970 a 9999 (§62.1, regla 16). Una cápsula puede guardar solo un comentario (decisión 9). +- **El formulario** (`create-input.ts`) mide los ficheros una vez por cambio de la lista (`measureFiles`), y con ellos da el tamaño exacto del `.dkc` a cada cambio del comentario, del autor o de la fecha. Después, el día y la hora, en la zona del dispositivo, en otra de las que conoce el navegador o en UTC. Y la política: «solo con la fecha» (`time_only`, la de por defecto) o «con la fecha y una clave» (`time_and_key`). Esta lleva destinatarios `age1…`, uno por línea y comprobados mientras se escriben (`recipient.ts`, sin noble), y la casilla de la clave portable, marcada por defecto; como mucho 16 credenciales. Una ayuda plegable explica qué es un destinatario de `age` y cómo se consigue: quien abrirá la cápsula crea su par con `age-keygen` y envía solo su `age1…`. Los campos se comprueban en su orden, y el foco va al campo del primer problema: una ruta que no vale lleva el foco a su fila. - **La hora local** (`localtime.ts`) se convierte al instante UTC con `Intl` y las reglas de zona que conoce hoy el navegador. Una hora que la zona se salta al adelantar los relojes se rechaza. De una que repite al atrasarlos se toma la más tardía, para no abrir nunca antes de lo querido. La cápsula no guarda la zona. -- **Antes de cifrar**, la página muestra el instante efectivo, que es el de la ronda, en la zona elegida y en UTC, y cuánto cae después del pedido. También la ronda, la `dk1_`, la hora del dispositivo junto a la UTC, el tamaño exacto del `.dkc` (`capsuleLength`) y lo que la cápsula deja ver hasta la fecha (§55.2). Y los avisos: el de protocolo preliminar (§74), siempre; los de §53 y §50, con sus textos, si el instante efectivo está a más de 365 días (`LONG_HORIZON_SECONDS`); y uno informativo si está a menos de una hora. La hora del dispositivo se lee cada segundo mientras la pestaña se ve y al crear la cápsula; la página no la pregunta a ningún servidor. Si el navegador no conoce la zona del dispositivo (V8 da entonces `Etc/Unknown`), la página empieza en UTC. -- **El writer se carga bajo demanda**: la página importa `creator.ts` con `import()` al pulsar «Crear la cápsula», y con él `encrypt.ts`, noble, `age-encryption` y las tablas de Unicode de las rutas. El relleno es siempre `reforzado`, y no hay extensiones. Lo escrito no se ofrece si no mide lo que se mostró o si los pasos 1 a 8 lo rechazan, y ante cualquier fallo se borra la `.dkk`. Si la fecha llega mientras la página se prepara para escribir, el writer la rechaza con su propio reloj (§62.1, regla 2), y la página lo dice en el campo de la fecha. +- **Antes de cifrar**, la página muestra el instante efectivo, que es el de la ronda, en la zona elegida y en UTC, y cuánto cae después del pedido. También la ronda, la `dk1_`, la hora del dispositivo junto a la UTC, el contenido, el tamaño exacto del `.dkc` (`capsuleLength` con `bodyLengthOf`) y lo que la cápsula deja ver hasta la fecha (§55.2). Y los avisos: el de protocolo preliminar (§74), siempre; los de §53 y §50, con sus textos, si el instante efectivo está a más de 365 días (`LONG_HORIZON_SECONDS`); y uno informativo si está a menos de una hora. La hora del dispositivo se lee cada segundo mientras la pestaña se ve y al crear la cápsula; la página no la pregunta a ningún servidor. Si el navegador no conoce la zona del dispositivo (V8 da entonces `Etc/Unknown`), la página empieza en UTC. +- **El writer se carga bajo demanda**: la página importa `creator.ts` con `import()` al pulsar «Crear la cápsula», y con él `encrypt.ts`, noble, `age-encryption` y las tablas de Unicode de las rutas. El relleno es siempre `reforzado`, y no hay extensiones. `encryptFiles` lee cada fichero dos veces, y la página muestra el progreso de las dos pasadas: la primera, en bytes de los ficheros, contada por `creator.ts` con un stream propio que lee a demanda; la segunda, en bytes del `.dkc`. «Cancelar» detiene cualquiera de las dos, y un fichero que cambia entre ellas hace fallar la escritura. Lo escrito no se ofrece si no mide lo que se mostró o si los pasos 1 a 8 lo rechazan, y ante cualquier fallo se borra la `.dkk`. Si la fecha llega mientras la página se prepara para escribir, el writer la rechaza con su propio reloj (§62.1, regla 2), y la página lo dice en el campo de la fecha. - **El `.dkc`** se escribe en un fichero temporal de OPFS, `datekeys-create//capsule` (`tempfile.ts`), que el navegador solo confirma cuando el writer lo cierra con la cápsula completa y comprobada. Si el navegador no tiene OPFS o lo rechaza, o la cuota no alcanza, se escribe en memoria, hasta 64 MiB. La cuota se comprueba con el tamaño exacto antes del primer byte. «Cancelar» detiene la escritura tras el trozo en curso: la salida se aborta y el fichero se borra. El fichero temporal se borra al pulsar «Borrar el fichero temporal», al crear otra cápsula y al salir de la página. Si el navegador terminó antes, se borra en la siguiente visita a `/create` o a `/inspect`, que limpian las dos zonas. - **La `.dkk`** (152 bytes sin extensiones) vive solo en la memoria de la página: nunca en OPFS, y nunca se muestra como `AGE-SECRET-KEY-1…`. Sus bytes se borran al pulsar «Olvidar la clave», al crear otra cápsula y al salir, y con ellos las URL de sus descargas. Junto a ella va el aviso de §7.4. Si la cápsula solo se abre con su `.dkk` y no se ha descargado, la página pide confirmación antes de borrarla, al olvidarla o al crear otra, y avisa antes de salir: el navegador pregunta al cerrar o recargar, y la página, al seguir un enlace del sitio. Salir de la página también cancela una escritura en curso. - **Las descargas** van por separado, con nombres que se pueden editar. Por defecto son `capsula-.dkc` y `.dkk`, que no dicen cuándo se creó la cápsula. La URL `blob:` de cada descarga se revoca un minuto después del clic. -- **El resultado** muestra el `capsule_id`, la política, el tamaño y el relleno, y el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector. +- **El resultado** muestra el `capsule_id`, la política, el contenido, el tamaño y el relleno, y el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector. + +Comprobado el 30-09-2026 en Chromium, con el formato 3: +- una lista de dos ficheros y una carpeta con `.DS_Store`, un fichero bajo `__MACOSX`, una ruta con «:», dos nombres que solo cambian en mayúsculas y una fecha de 1969: los dos del sistema, tachados; el problema de R4 y el choque de R7, en sus filas; la fecha de 1969, omitida; +- con las rutas corregidas, el comentario y el autor, la cápsula midió los 2.084 bytes del plan y se escribió en 0,47 s sobre la compilación de producción; +- pasada la fecha, `datekeys decrypt` de Go la abrió con los relays públicos: los seis ficheros con sus rutas y fechas, el autor y el comentario. `/inspect` la abrió con el release pegado de drand, con un ZIP y la descarga de cada fichero; +- al crear, una ruta `CON.txt` lleva el foco a su fila, y después un autor con un espacio inicial, a su campo; +- en el servidor de desarrollo, con el panel del navegador oculto, cada actualización del progreso retrasaba la escritura casi 2 s; la compilación de producción no lo hace. Comprobado el 29-09-2026 en Chromium (el navegador de la app de escritorio), sobre la compilación de producción: - un fichero de 77 bytes se cifró en `time_and_key`, con clave portable, para dentro de cuatro minutos, sin ninguna petición fuera del origen, y el informe pasó los pasos 1 a 8 con el formato 2; @@ -177,8 +187,10 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una | `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 | -| `src/lib/inspector/creator.ts` | La escritura, cargada bajo demanda: `encryptFiles` hacia el fichero temporal o la memoria, con la cancelación y la cuota, y los pasos 1 a 8 de lo escrito | +| `src/lib/inspector/create-files.ts` | La lista de ficheros de `/create`, sin tablas: lo elegido y lo soltado, carpetas recorridas incluidas, los ficheros de un sistema fuera por defecto como en `collect.go`, las rutas editadas y lo que la cápsula guarda | +| `src/lib/inspector/create-check.ts` | Las reglas de las rutas y de los textos (§29.5, §29.6) como las explica `/create`: todos los problemas de todas las rutas, en español, con los de `pathrule.ts`. Se carga bajo demanda, con las tablas | +| `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, con los ficheros medidos una vez (`chooseFiles`); los destinatarios y los nombres de los ficheros | +| `src/lib/inspector/creator.ts` | La escritura, cargada bajo demanda: `encryptFiles` con los ficheros, el comentario y el autor hacia el fichero temporal o la memoria, con el progreso de las dos lecturas, la cancelación y la cuota, y los pasos 1 a 8 de lo escrito | | `src/lib/components/` | `InspectionReport`, `OpenPanel`, `StepList`, `ExtensionList`, `DataView`, `Mark` | | `src/routes/` | Layout, portada, inspector y crear | @@ -336,12 +348,14 @@ En el sitio, según `check-build.mjs` el 28-09-2026: | Primera carga, con la acción "abrir" | 187 700 B | 68 197 B | | Bajo demanda, al abrir: `opener.ts`, `open.ts`, noble y `age-encryption` | 183 747 B | 66 882 B | -Y según `check-build.mjs` el 29-09-2026, con la página de crear: +Y según `check-build.mjs` el 29-09-2026, con la página de crear, y el 30-09-2026, con el formato 3: | `/create` | JavaScript | gzip | |---|---|---| | Primera carga, con el formulario y el informe de los pasos 1 a 8 | 208 755 B | 76 942 B | | Bajo demanda, al crear: `creator.ts`, `encrypt.ts`, noble y `age-encryption` | 212 685 B | 76 617 B | +| Primera carga, con la lista de ficheros (formato 3) | 224 367 B | 82 345 B | +| Bajo demanda (formato 3): lo anterior, `create-check.ts` y las tablas de las rutas | 353 788 B | 125 503 B | Los 9,5 KB con gzip que crece la primera carga de `/inspect` son la interfaz de la apertura (`OpenPanel.svelte`) y sus módulos sin noble. Las cifras exactas cambian unos bytes en cada compilación, por la versión que SvelteKit incrusta. diff --git a/src/lib/dkc/lengths.ts b/src/lib/dkc/lengths.ts index 80e8c2e..1293f46 100644 --- a/src/lib/dkc/lengths.ts +++ b/src/lib/dkc/lengths.ts @@ -135,18 +135,50 @@ const extensionLength = (e: Extension): number => cborHead(e.version) + (e.data === undefined ? 0 : 1 + cborString(e.data.length)); +/** + * The files of a head measured once, so that a page can give the length of + * BODY for other texts without sorting the files again: see headLengthOf. + */ +export interface MeasuredFiles { + /** The number of files. */ + readonly count: number; + /** The bytes that key 5, the array of the files, takes in HEAD_CBOR; 0 without files. */ + readonly head: number; + /** C, the bytes of the files together. */ + readonly content: number; +} + +/** + * The files of a head measured as the writer of format 3 lays them out, in + * the byte order of their paths, which decides their start and end. + */ +export function measureFiles(files: HeadShape['files']): MeasuredFiles { + const sorted = files.map((f) => ({ ...f, key: utf8Bytes(f.path) })).sort((a, b) => compareBytes(a.key, b.key)); + if (sorted.length === 0) return { count: 0, head: 0, content: 0 }; + let n = 1 + cborHead(sorted.length); + let end = 0; + for (const f of sorted) { + const mtime = mtimeSeconds(f.mtime); + n += cborHead(mtime === undefined ? 5 : 6) + 1 + cborString(f.key.length) + 1 + cborHead(f.size) + 1 + cborHead(end); + end += f.size; + n += 1 + cborHead(end) + 1 + cborString(32) + (mtime === undefined ? 0 : 1 + cborHead(mtime)); + } + return { count: sorted.length, head: n, content: end }; +} + /** * The length of the HEAD_CBOR that the writer of format 3 writes for these * files and texts, from the sizes of its CBOR items (spec §29.4), without - * encoding it. The files go in the byte order of their paths, which decides - * their start and end. + * encoding it. */ export function headLength(h: HeadShape): number { + return headLengthOf(measureFiles(h.files), h); +} + +/** headLength for files already measured, and the texts and extensions of `h`. */ +export function headLengthOf(files: MeasuredFiles, h: Omit): number { const comment = headComment(h.comment ?? ''); const author = h.author ?? ''; - const files = h.files - .map((f) => ({ ...f, key: utf8Bytes(f.path) })) - .sort((a, b) => compareBytes(a.key, b.key)); let pairs = 3; // Keys 0, 1 and 2: "datekeys-head", 1 and the salt of 32 bytes. let n = 1 + cborString(13) + 1 + 1 + 1 + cborString(32); @@ -155,16 +187,9 @@ export function headLength(h: HeadShape): number { pairs++; n += 1 + cborString(utf8Length(text)); } - if (files.length > 0) { + if (files.count > 0) { pairs++; - n += 1 + cborHead(files.length); - let end = 0; - for (const f of files) { - const mtime = mtimeSeconds(f.mtime); - n += cborHead(mtime === undefined ? 5 : 6) + 1 + cborString(f.key.length) + 1 + cborHead(f.size) + 1 + cborHead(end); - end += f.size; - n += 1 + cborHead(end) + 1 + cborString(32) + (mtime === undefined ? 0 : 1 + cborHead(mtime)); - } + n += files.head; } for (const list of [h.critical ?? [], h.noncritical ?? []]) { if (list.length === 0) continue; @@ -181,5 +206,10 @@ export function headLength(h: HeadShape): number { * has the length of the control of format 2. */ export function bodyLength(h: HeadShape): number { - return FRAME_AND_AREA + headLength(h) + h.files.reduce((sum, f) => sum + f.size, 0); + return bodyLengthOf(measureFiles(h.files), h); +} + +/** bodyLength for files already measured, and the texts and extensions of `h`. */ +export function bodyLengthOf(files: MeasuredFiles, h: Omit): number { + return FRAME_AND_AREA + headLengthOf(files, h) + files.content; } diff --git a/src/lib/inspector/create-check.test.ts b/src/lib/inspector/create-check.test.ts new file mode 100644 index 0000000..fcd976f --- /dev/null +++ b/src/lib/inspector/create-check.test.ts @@ -0,0 +1,161 @@ +// Tests of create-check.ts: the words of each rule of the paths and the +// texts of a format 3 head, as the create page shows them, and the problems +// of a list of paths, which pass exactly when the writer accepts them. + +import { describe, expect, it } from 'vitest'; +import { compareBytes, utf8Bytes } from '../dkc/bytes.ts'; +import { checkPath, checkTree } from '../dkc/pathrule.ts'; +import { authorProblem, checkPaths, commentProblem, Memo, pathProblem } from './create-check.ts'; + +const R7 = 'se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre (R7).'; + +describe('pathProblem', () => { + it.each([ + ['', 'Escribe la ruta del fichero (R1).'], + ['\ud800', 'La ruta tiene un carácter mal formado (R1).'], + ['a'.repeat(1025), 'La ruta ocupa 1.025 bytes en UTF-8, más de 1.024 (R1).'], + [`${'a/'.repeat(32)}a`, 'La ruta tiene 33 niveles, más de 32 (R2).'], + ['a//b', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'], + ['/a', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'], + ['a'.repeat(256), `El nombre «${'a'.repeat(57)}…» ocupa 256 bytes en UTF-8, más de 255 (R3).`], + ['.', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'], + ['fotos/..', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'], + ['.\u200d', 'El nombre «.\\u200d», sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..» (R3).'], + ['\u0390'.repeat(127), `El nombre «${'\u0390'.repeat(57)}…», descompuesto en NFD como lo guarda macOS, mide 381 unidades UTF-16, más de 255 (R3).`], + ['a\u0001b', 'El nombre «a\\u0001b» lleva el carácter de control U+0001 (R4).'], + ['a:b', 'El nombre «a:b» lleva «:», que Windows no admite en un nombre (R4).'], + ['x:y/b', 'La carpeta «x:y» lleva «:», que Windows no admite en un nombre (R4).'], + ['a\u2028b', 'El nombre «a\\u2028b» lleva el separador U+2028 (R4).'], + ['a\u200bb', 'El nombre «a\\u200bb» lleva el carácter invisible U+200B, con el que dos nombres distintos se ven iguales (R4).'], + ['a\uf03ab', 'El nombre «a\\uf03ab» lleva U+F03A, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite (R4).'], + ['a\u0378', 'El nombre «a\\u0378» lleva U+0378, que Unicode 18.0.0 no asigna (R4).'], + ['a\ufe0f', 'El nombre «a\ufe0f» lleva el selector de variante U+FE0F sin un emoji delante que lo admita (R4b).'], + ['\u200da', 'El nombre «\\u200da» empieza por el invisible U+200D (R4b).'], + ['a\u200c/b', 'La carpeta «a\\u200c» acaba en el invisible U+200C (R4b).'], + ['a\u200d\u200cb', 'El nombre «a\\u200d\\u200cb» lleva dos invisibles seguidos, U+200D y U+200C (R4b).'], + [' a', 'El nombre « a» empieza por un espacio (R5).'], + ['a ', 'El nombre «a » acaba en un espacio, que Windows quita (R5).'], + ['a.', 'El nombre «a.» acaba en punto, que Windows quita (R5).'], + ['con.txt', 'El nombre «con.txt» usa CON, un nombre que Windows reserva para un dispositivo (R6).'], + ['COM\u00b9.txt', 'El nombre «COM\u00b9.txt» usa COM\u00b9, un nombre que Windows reserva para un dispositivo (R6).'], + ['nul .txt', 'El nombre «nul .txt» usa NUL, un nombre que Windows reserva para un dispositivo (R6).'], + ['ABCDEF~1.TXT', 'El nombre «ABCDEF~1.TXT» tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero (R6b).'], + ['a\u2236b', 'En la página de códigos 1250 de Windows, el nombre «a\u2236b» se convierte en un nombre con «:» (R6c).'], + ['x\u00a5y', 'En la página de códigos 932 de Windows, el nombre «x\u00a5y» se convierte en un nombre con «\\» (R6c).'], + ['\uff23\uff2f\uff2e.txt', 'En la página de códigos 874 de Windows, el nombre «\uff23\uff2f\uff2e.txt» se convierte en un nombre que incumple R6 (R6c).'], + ['\u3000a', 'En la página de códigos 1250 de Windows, el nombre «\\u3000a» se convierte en un nombre que incumple R5 (R6c).'], + ['fotos/\uff0e\uff0e', 'En la página de códigos 874 de Windows, el nombre «\uff0e\uff0e» se convierte en un nombre que incumple R3 (R6c).'], + ['.datekeys-x/a', 'La carpeta «.datekeys-x» empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).'], + ['.DATEKEYS-a', 'El nombre «.DATEKEYS-a» empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).'], + ])('%j', (path, want) => { + expect(pathProblem(path)).toBe(want); + }); + + it('accepts a path that every rule accepts, emoji sequences included', () => { + for (const p of ['fotos/2025/playa.jpg', '\u2764\ufe0f.txt', 'm\u00fasica/canci\u00f3n.txt', '.bashrc', 'a b/c d.txt', '\u0930\u094d\u200d\u092f']) { + expect(pathProblem(p), p).toBeUndefined(); + } + }); +}); + +describe('checkPaths', () => { + it('names each collision of R7 on both paths, and leaves out of the tree a path with a problem of its own', () => { + expect(checkPaths(['A.txt', 'a.txt'])).toEqual({ problems: [`Choca con «a.txt»: ${R7}`, `Choca con «A.txt»: ${R7}`] }); + expect(checkPaths(['\u00e9.txt', 'e\u0301.txt']).problems).toEqual([`Choca con «e\u0301.txt»: ${R7}`, `Choca con «\u00e9.txt»: ${R7}`]); + expect(checkPaths(['ab', 'a\u200cb']).problems).toEqual([`Choca con «a\\u200cb»: ${R7}`, `Choca con «ab»: ${R7}`]); + expect(checkPaths(['Fotos/a', 'fotos/b']).problems).toEqual([`Choca con «fotos/b»: ${R7}`, `Choca con «Fotos/a»: ${R7}`]); + expect(checkPaths(['a', 'a/b']).problems).toEqual(['Choca con «a/b»: «a» sería a la vez un fichero y una carpeta (R7).', 'Choca con «a»: «a» sería a la vez un fichero y una carpeta (R7).']); + expect(checkPaths(['x/y/z', 'x/y']).problems).toEqual(['Choca con «x/y»: «x/y» sería a la vez un fichero y una carpeta (R7).', 'Choca con «x/y/z»: «x/y» sería a la vez un fichero y una carpeta (R7).']); + expect(checkPaths(['x', 'x']).problems).toEqual(['Repite la ruta de otro fichero de la lista.', 'Repite la ruta de otro fichero de la lista.']); + // The first path keeps its first collision; the third repeats the first. + expect(checkPaths(['a', 'A', 'a']).problems).toEqual([`Choca con «A»: ${R7}`, `Choca con «a»: ${R7}`, 'Repite la ruta de otro fichero de la lista.']); + expect(checkPaths(['a:b', 'A:B', 'ok']).problems).toEqual([ + 'El nombre «a:b» lleva «:», que Windows no admite en un nombre (R4).', + 'El nombre «A:B» lleva «:», que Windows no admite en un nombre (R4).', + undefined, + ]); + expect(checkPaths(['fotos/a.jpg', 'fotos/b.jpg', 'fotos/2025/a.jpg', 'carta.txt'])).toEqual({ problems: [undefined, undefined, undefined, undefined] }); + expect(checkPaths([])).toEqual({ problems: [] }); + }); + + it('counts the folders of R9', () => { + const paths = Array.from({ length: 65535 }, (_, i) => `d${i}/f`); + expect(checkPaths(paths).overall).toBeUndefined(); + expect(checkPaths([...paths, 'e/f']).overall).toBe('Las rutas forman 65.536 carpetas, más de 65.535 (R9).'); + // A path with a problem does not count. + expect(checkPaths([...paths, 'e:/f']).overall).toBeUndefined(); + }); + + it('finds no problem exactly when checkPath and checkTree accept the paths, in the byte order of the writer', () => { + let x = 12345; + const next = (n: number): number => { + x = (x * 1664525 + 1013904223) >>> 0; + return x % n; + }; + const segments = ['a', 'A', 'b', '\u00e9', 'e\u0301', 'ab', 'a\u200cb', 'x.txt', 'X.TXT', 'k', '\u212a', 'stra\u00dfe', 'STRASSE', 'a:b', '.', 'con', '\u0131', 'I']; + for (let i = 0; i < 3000; i++) { + const paths = Array.from({ length: 1 + next(5) }, () => Array.from({ length: 1 + next(3) }, () => segments[next(segments.length)]).join('/')); + const mine = checkPaths(paths); + const ours = mine.overall === undefined && mine.problems.every((p) => p === undefined); + let theirs = true; + try { + paths.forEach((p) => checkPath(p)); + checkTree([...paths].sort((a, b) => compareBytes(utf8Bytes(a), utf8Bytes(b)))); + } catch { + theirs = false; + } + expect(ours, JSON.stringify(paths)).toBe(theirs); + } + }); +}); + +describe('Memo', () => { + it('computes each value once, undefined included, and forgets them all at its limit', () => { + const seen: string[] = []; + const memo = new Memo(2); + const get = (k: string) => memo.get(k, (key) => (seen.push(key), key === 'x' ? undefined : key.toUpperCase())); + expect([get('a'), get('x'), get('a'), get('x')]).toEqual(['A', undefined, 'A', undefined]); + expect(seen).toEqual(['a', 'x']); + expect(get('b')).toBe('B'); + expect(get('a')).toBe('A'); + expect(seen).toEqual(['a', 'x', 'b', 'a']); + }); +}); + +describe('the texts', () => { + it.each([ + ['hola\nqué tal\tadiós', undefined], + ['a\r\n'.repeat(5462), undefined], + ['\ud800', 'El comentario tiene un carácter mal formado (§29.6).'], + ['x'.repeat(16385), 'El comentario ocupa 16.385 bytes en UTF-8, más de 16.384 (§29.4).'], + ['a\u0007', 'El comentario lleva el carácter de control U+0007 (§29.6).'], + ['a\u202e', 'El comentario lleva el control bidireccional U+202E, que cambia el orden en que se ve el texto (§29.6).'], + ['a\u2029', 'El comentario lleva el separador U+2029 (§29.6).'], + ['\ufeffa', 'El comentario lleva la marca de orden de bytes U+FEFF (§29.6).'], + ['a\ufdd0', 'El comentario lleva U+FDD0, que Unicode reserva como no carácter (§29.6).'], + ['a\u200b', 'El comentario lleva el carácter invisible U+200B, con el que se puede esconder texto (§29.6).'], + ['hola\n\u200dadiós', 'La línea 2 del comentario empieza por el invisible U+200D (§29.6).'], + ['a\ufe0e', 'La línea 1 del comentario lleva el selector de variante U+FE0E sin un emoji delante que lo admita (§29.6).'], + ])('comment %j', (s, want) => { + expect(commentProblem(s)).toBe(want); + }); + + it('counts the comment as the head stores it, with LF for CR LF', () => { + expect(commentProblem(`${'x'.repeat(16383)}\r\n`)).toBeUndefined(); + expect(commentProblem('')).toBeUndefined(); + }); + + it.each([ + ['Ana García', undefined], + ['', undefined], + ['Ana\tB', 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'], + ['Ana\nB', 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'], + [' Ana', 'El autor no puede empezar ni acabar por un espacio (§29.6).'], + ['x'.repeat(257), 'El autor ocupa 257 bytes en UTF-8, más de 256 (§29.4).'], + ['Ana\u200d', 'El autor acaba en el invisible U+200D (§29.6).'], + ['A\u200fB', 'El autor lleva el control bidireccional U+200F, que cambia el orden en que se ve el texto (§29.6).'], + ['\ud800', 'El autor tiene un carácter mal formado (§29.6).'], + ])('author %j', (s, want) => { + expect(authorProblem(s)).toBe(want); + }); +}); diff --git a/src/lib/inspector/create-check.ts b/src/lib/inspector/create-check.ts new file mode 100644 index 0000000..97cabbb --- /dev/null +++ b/src/lib/inspector/create-check.ts @@ -0,0 +1,272 @@ +// The rules of the paths and the texts of a format 3 head (spec §29.5, +// §29.6) as the create page explains them while the person fills the form: +// every problem of every path, in Spanish, where the writer stops at the +// first one in the words of the reference. The rules themselves are those of +// pathrule.ts, which the writer applies again; this module only words their +// violations. It brings the Unicode tables of the paths, so the page loads +// it on demand. + +import { utf8Length } from '../dkc/bytes.ts'; +import { headComment } from '../dkc/lengths.ts'; +import { + checkAuthor, + checkComment, + checkPath, + MAX_AUTHOR_LEN, + MAX_COMMENT_LEN, + MAX_IMPLICIT_DIRS, + MAX_PATH_LEN, + MAX_SEGMENT_LEN, + MAX_SEGMENT_UTF16, + MAX_SEGMENTS, + pathKey, + PathRuleError, +} from '../dkc/pathrule.ts'; +import { escapeInvisible, formatInteger } from './format.ts'; + +/** The problems of a list of paths. */ +export interface PathsCheck { + /** The problem of each path, in the order given, or undefined. */ + readonly problems: readonly (string | undefined)[]; + /** The problem of all of them together: more folders than R9 allows. */ + readonly overall?: string; +} + +/** + * Every problem of `paths`: each path alone with R1 to R6c and R10, and + * then, among the paths that pass, the collisions of R7, named on both + * paths, and R9. The paths pass here exactly when checkPath and checkTree + * accept them all, whatever their order. + */ +export function checkPaths(paths: readonly string[]): PathsCheck { + const problems: (string | undefined)[] = paths.map((p) => PROBLEMS.get(p, pathProblem)); + // The nodes of the tree, by the keys of R7 of their segments, as + // checkTree builds them, with the path that reached each one first. + const nodes = new Map(); + paths.forEach((path, n) => { + if (problems[n] !== undefined) return; + const segs = path.split('/'); + let parent = ''; + for (const [i, s] of segs.entries()) { + const k = `${parent}/${KEYS.get(s, pathKey)}`; + const dir = i < segs.length - 1; + const old = nodes.get(k); + if (old === undefined) nodes.set(k, { name: s, dir, path: n }); + else if (old.name !== s) { + collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre (R7).`, paths); + return; + } else if (old.dir !== dir) { + const prefix = segs.slice(0, i + 1).join('/'); + collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: «${shown(prefix)}» sería a la vez un fichero y una carpeta (R7).`, paths); + return; + } else if (!dir) { + collide(problems, n, old.path, () => 'Repite la ruta de otro fichero de la lista.', paths); + return; + } + parent = k; + } + }); + const dirs = new Set(); + paths.forEach((path, n) => { + if (problems[n] !== undefined) return; + for (let i = path.indexOf('/'); i >= 0; i = path.indexOf('/', i + 1)) dirs.add(path.slice(0, i)); + }); + return { + problems, + ...(dirs.size > MAX_IMPLICIT_DIRS ? { overall: `Las rutas forman ${formatInteger(dirs.size)} carpetas, más de ${formatInteger(MAX_IMPLICIT_DIRS)} (R9).` } : {}), + }; +} + +// The problem of a collision on both paths, each naming the other, unless +// the earlier one has a problem already. +function collide(problems: (string | undefined)[], later: number, earlier: number, text: (other: string) => string, paths: readonly string[]): void { + problems[later] = text(paths[earlier]!); + problems[earlier] ??= text(paths[later]!); +} + +/** + * Values computed once for each key, and forgotten all together once there + * are `limit` of them: the page checks the whole list again at every change, + * and checkPath takes some microseconds a path. + */ +export class Memo { + readonly #values = new Map(); + readonly #limit: number; + + constructor(limit: number) { + this.#limit = limit; + } + + get(key: string, compute: (key: string) => V): V { + if (this.#values.has(key)) return this.#values.get(key)!; + if (this.#values.size >= this.#limit) this.#values.clear(); + const v = compute(key); + this.#values.set(key, v); + return v; + } +} + +// The problem of each path alone, and the key of R7 of each segment, which +// a list of thousands of files repeats in its folders. +const PROBLEMS = new Memo(1 << 18); +const KEYS = new Memo(1 << 18); + +/** The problem of one path alone, R1 to R6c and R10, or undefined. */ +export function pathProblem(path: string): string | undefined { + if (path === '') return 'Escribe la ruta del fichero (R1).'; + if (!path.isWellFormed()) return 'La ruta tiene un carácter mal formado (R1).'; + const n = utf8Length(path); + if (n > MAX_PATH_LEN) return `La ruta ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(MAX_PATH_LEN)} (R1).`; + try { + checkPath(path); + return undefined; + } catch (err) { + /* v8 ignore next -- @preserve: checkPath throws only PathRuleError */ + if (!(err instanceof PathRuleError)) throw err; + return explainPath(err, path.split('/')); + } +} + +// A text as the messages show it: its invisible characters escaped, and cut +// when it is long. +function shown(s: string): string { + const t = escapeInvisible(s); + return t.length > 60 ? `${t.slice(0, 57)}…` : t; +} + +// U+XXXX, as codePointName writes it. +const CODE_POINT = 'U\\+([0-9A-F]{4,6})'; +// The character of a U+XXXX: R4 names this way only '"', '*', ':', '<', '>', +// '?', '\' and '|', and R6c '/', '\', ':' and U+0000, which no best-fit +// table gives; escaped all the same. +const character = (hex: string): string => `«${escapeInvisible(String.fromCodePoint(Number.parseInt(hex, 16)))}»`; + +// The segment i of a path, as the messages name it. +function subject(segs: readonly string[], i: number): string { + return `${i === segs.length - 1 ? 'El nombre' : 'La carpeta'} «${shown(segs[i]!)}»`; +} + +type Rule = readonly [RegExp, (who: string, m: RegExpExecArray) => string]; + +// The details of each rule on a segment, from pathrule.ts, and their words. +const SEGMENT_RULES: readonly (readonly [string, ...Rule])[] = [ + ['R3', /^(\d+) bytes, more than \d+$/, (who, m) => `${who} ocupa ${formatInteger(Number(m[1]))} bytes en UTF-8, más de ${MAX_SEGMENT_LEN} (R3).`], + ['R3', /^the segment is (a dot|two dots)$/, () => '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'], + ['R3', /^the segment is .* without ZWNJ, ZWJ, VS15 and VS16$/, (who) => `${who}, sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..» (R3).`], + [ + 'R3', + /^its NFD is (\d+) UTF-16 code units, more than \d+$/, + (who, m) => `${who}, descompuesto en NFD como lo guarda macOS, mide ${formatInteger(Number(m[1]))} unidades UTF-16, más de ${MAX_SEGMENT_UTF16} (R3).`, + ], + ['R4', new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]} (R4).`], + ['R4', new RegExp(`^character ${CODE_POINT}$`), (who, m) => `${who} lleva ${character(m[1]!)}, que Windows no admite en un nombre (R4).`], + ['R4', new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]} (R4).`], + ['R4', new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que dos nombres distintos se ven iguales (R4).`], + [ + 'R4', + new RegExp(`^private use ${CODE_POINT}$`), + (who, m) => `${who} lleva U+${m[1]}, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite (R4).`, + ], + ['R4', new RegExp(`^unassigned ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode 18.0.0 no asigna (R4).`], + ...placementRules('R4b').map((r) => ['R4b', ...r] as const), + ['R5', /^the segment starts with U\+0020$/, (who) => `${who} empieza por un espacio (R5).`], + ['R5', /^the segment ends with U\+0020$/, (who) => `${who} acaba en un espacio, que Windows quita (R5).`], + ['R5', /^the segment ends with '\.'$/, (who) => `${who} acaba en punto, que Windows quita (R5).`], + ['R6', /^(.+) is a reserved device name$/, (who, m) => `${who} usa ${m[1]}, un nombre que Windows reserva para un dispositivo (R6).`], + ['R6b', /^the segment has the form of an 8\.3 alias$/, (who) => `${who} tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero (R6b).`], + [ + 'R6c', + new RegExp(`^code page (\\d+) maps the segment to one with ${CODE_POINT}$`), + (who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre con ${character(m[2]!)} (R6c).`, + ], + ['R6c', /^code page (\d+) maps the segment to one that breaks (R\w+): /, (who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre que incumple ${m[2]} (R6c).`], +]; + +const lower = (who: string): string => who.charAt(0).toLowerCase() + who.slice(1); + +// R4b on a segment, or on a line of a text: its details, with the rule +// named at the end. +function placementRules(rule: string): readonly Rule[] { + return [ + [new RegExp(`^${CODE_POINT} is not part of an emoji variation sequence$`), (who, m) => `${who} lleva el selector de variante U+${m[1]} sin un emoji delante que lo admita (${rule}).`], + [new RegExp(`^${CODE_POINT} at the start$`), (who, m) => `${who} empieza por el invisible U+${m[1]} (${rule}).`], + [new RegExp(`^${CODE_POINT} at the end$`), (who, m) => `${who} acaba en el invisible U+${m[1]} (${rule}).`], + [new RegExp(`^${CODE_POINT} right after ${CODE_POINT}$`), (who, m) => `${who} lleva dos invisibles seguidos, U+${m[2]} y U+${m[1]} (${rule}).`], + ]; +} + +// The words of a violation of checkPath. +function explainPath(err: PathRuleError, segs: readonly string[]): string { + const m = /^segment (\d+): (.*)$/s.exec(err.detail); + if (m !== null) { + const who = subject(segs, Number(m[1]) - 1); + for (const [rule, re, text] of SEGMENT_RULES) { + const d = rule === err.rule ? re.exec(m[2]!) : null; + if (d !== null) return text(who, d); + } + } + if (err.rule === 'R2') { + const count = /^(\d+) segments/.exec(err.detail); + return count === null + ? 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).' + : `La ruta tiene ${formatInteger(Number(count[1]))} niveles, más de ${MAX_SEGMENTS} (R2).`; + } + /* v8 ignore next -- @preserve: every detail of pathrule.ts has its words, and R10 is the last rule */ + if (err.rule !== 'R10') return `La ruta no vale: ${err.message}.`; + return `${subject(segs, 0)} empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).`; +} + +// The details of the rules of the texts, from pathrule.ts, and their words. +const TEXT_RULES: readonly Rule[] = [ + [new RegExp(`^control ${CODE_POINT} in the declared author$`), () => 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'], + [new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]} (§29.6).`], + [new RegExp(`^bidirectional control ${CODE_POINT}$`), (who, m) => `${who} lleva el control bidireccional U+${m[1]}, que cambia el orden en que se ve el texto (§29.6).`], + [new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]} (§29.6).`], + [new RegExp(`^byte order mark ${CODE_POINT}$`), (who) => `${who} lleva la marca de orden de bytes U+FEFF (§29.6).`], + [new RegExp(`^noncharacter ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode reserva como no carácter (§29.6).`], + [new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que se puede esconder texto (§29.6).`], + [/^the declared author starts or ends with U\+0020$/, () => 'El autor no puede empezar ni acabar por un espacio (§29.6).'], +]; + +/** The problem of the comment of the form, or undefined; '' has none. */ +export function commentProblem(s: string): string | undefined { + return textProblem(headComment(s), 'El comentario', MAX_COMMENT_LEN, checkComment); +} + +/** The problem of the declared author of the form, or undefined; '' has none. */ +export function authorProblem(s: string): string | undefined { + return textProblem(s, 'El autor', MAX_AUTHOR_LEN, checkAuthor); +} + +function textProblem(s: string, who: string, max: number, check: (s: string) => void): string | undefined { + if (s === '') return undefined; + if (!s.isWellFormed()) return `${who} tiene un carácter mal formado (§29.6).`; + const n = utf8Length(s); + if (n > max) return `${who} ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(max)} (§29.4).`; + try { + check(s); + return undefined; + } catch (err) { + /* v8 ignore next -- @preserve: the rules throw only PathRuleError */ + if (!(err instanceof PathRuleError)) throw err; + return explainText(err, who); + } +} + +// The words of a violation of checkComment or checkAuthor. +function explainText(err: PathRuleError, who: string): string { + const line = /^line (\d+): (.*)$/s.exec(err.detail); + if (line !== null) { + const where = who === 'El autor' ? who : `La línea ${line[1]} del comentario`; + for (const [re, text] of placementRules('§29.6')) { + const m = re.exec(line[2]!); + if (m !== null) return text(where, m); + } + } + for (const [re, text] of TEXT_RULES) { + const m = re.exec(err.detail); + if (m !== null) return text(who, m); + } + /* v8 ignore next -- @preserve: every detail of pathrule.ts has its words above */ + return `${who} no vale: ${err.message}.`; +} diff --git a/src/lib/inspector/create-files.test.ts b/src/lib/inspector/create-files.test.ts new file mode 100644 index 0000000..5c26ae8 --- /dev/null +++ b/src/lib/inspector/create-files.test.ts @@ -0,0 +1,224 @@ +// Tests of create-files.ts: the files of the create page as the person +// chose them, a folder walked as the CLI of the reference walks it, the +// files of a system left out of folders by default, and the list that the +// page keeps and plans from. + +import { describe, expect, it } from 'vitest'; +import { + addPicked, + droppedFiles, + type Entry, + headFiles, + includedEntries, + MAX_CAPSULE_FILES, + type Picked, + pickedFiles, + summarize, + systemFile, + systemOf, + TooManyFiles, + withIncluded, + withPath, +} from './create-files.ts'; + +// A File of `size` bytes, modified at `modified`, with the webkitRelativePath +// that a browser gives: '' for a file chosen on its own. +function file(name: string, size = 1, relative = '', modified = 1_790_769_600_500): File { + const f = new File([new Uint8Array(size)], name, { lastModified: modified }); + Object.defineProperty(f, 'webkitRelativePath', { value: relative }); + return f; +} + +const picked = (path: string, inFolder = true, size = 1): Picked => ({ path, file: file(path.split('/').at(-1)!, size), inFolder }); + +describe('systemFile', () => { + it('names the files of a system as the CLI does, compared as strings.EqualFold compares', () => { + // The results of strings.EqualFold for the same names, from Go. + const files: [string, string | undefined][] = [ + ['.DS_Store', '.DS_Store'], + ['.ds_store', '.DS_Store'], + ['.DS_STORE', '.DS_Store'], + ['.Dſ_Store', '.DS_Store'], + ['Thumbs.db', 'Thumbs.db'], + ['Thumbſ.db', 'Thumbs.db'], + ['THUMBS.DB', 'Thumbs.db'], + ['desktop.ini', 'desktop.ini'], + ['desKtop.ini', 'desktop.ini'], + ['DESKTOP.INI', 'desktop.ini'], + ['desktop.ini ', undefined], + ['desktop.ini', undefined], + ['desktop.İni', undefined], + ['desktop.ıni', undefined], + ['desk‍top.ini', undefined], + ['Thumbs.db́', undefined], + ['._foto.jpg', '._*'], + ['._', '._*'], + ['_.foto', undefined], + ['__MACOSX', undefined], + ['nota.txt', undefined], + ]; + for (const [name, want] of files) expect(systemFile(name, false), name).toBe(want); + expect([systemFile('__MACOSX', true), systemFile('__macosx', true), systemFile('.DS_Store', true), systemFile('._x', true)]).toEqual([ + '__MACOSX', + undefined, + undefined, + undefined, + ]); + }); + + it('leaves out, by default, the files of a system found in a folder, and never the chosen folder or a file chosen on its own', () => { + expect(systemOf(picked('fotos/.DS_Store'))).toBe('.DS_Store'); + expect(systemOf(picked('fotos/2025/Thumbs.db'))).toBe('Thumbs.db'); + expect(systemOf(picked('fotos/._playa.jpg'))).toBe('._*'); + expect(systemOf(picked('fotos/a/__MACOSX/b/playa.jpg'))).toBe('__MACOSX'); + expect(systemOf(picked('fotos/__MACOSX/.DS_Store'))).toBe('__MACOSX'); + expect(systemOf(picked('__MACOSX/playa.jpg'))).toBeUndefined(); + expect(systemOf(picked('fotos/__MACOSX'))).toBeUndefined(); + expect(systemOf(picked('.DS_Store', false))).toBeUndefined(); + expect(systemOf(picked('fotos/playa.jpg'))).toBeUndefined(); + }); +}); + +describe('pickedFiles', () => { + it('takes a file chosen on its own by its name, and one of a chosen folder by webkitRelativePath', () => { + const a = file('nota.txt'); + const b = file('playa.jpg', 1, 'fotos/2025/playa.jpg'); + expect(pickedFiles([a, b])).toEqual([ + { path: 'nota.txt', file: a, inFolder: false }, + { path: 'fotos/2025/playa.jpg', file: b, inFolder: true }, + ]); + }); +}); + +// The entries of a drop, as webkitGetAsEntry gives them: a folder reads its +// entries in batches of `batch`, and then an empty one. +type Fake = { name: string; children?: Fake[]; size?: number; fail?: 'file' | 'read'; kind?: 'other' }; +function entry(f: Fake, batch = 2): FileSystemEntry { + if (f.kind === 'other') return { name: f.name, isFile: false, isDirectory: false } as unknown as FileSystemEntry; + if (f.children === undefined) { + return { + name: f.name, + isFile: true, + isDirectory: false, + file: (ok: (f: File) => void, bad: (e: Error) => void) => (f.fail === 'file' ? bad(new Error('NotReadableError')) : ok(file(f.name, f.size ?? 1))), + } as unknown as FileSystemEntry; + } + const kids = f.children.map((c) => entry(c, batch)); + return { + name: f.name, + isFile: false, + isDirectory: true, + createReader: () => { + let at = 0; + return { + readEntries: (ok: (e: FileSystemEntry[]) => void, bad: (e: Error) => void) => { + if (f.fail === 'read') return bad(new Error('NotFoundError')); + at += batch; + ok(kids.slice(at - batch, at)); + }, + }; + }, + } as unknown as FileSystemEntry; +} + +describe('droppedFiles', () => { + it('takes a dropped file by its name, and walks a dropped folder to the end, its name first in each path, without its empty folders', async () => { + const roots = [ + entry({ name: 'nota.txt', size: 3 }), + entry({ + name: 'fotos', + children: [ + { name: 'playa.jpg' }, + { name: 'vacía', children: [] }, + { name: '2025', children: [{ name: 'a.jpg' }, { name: 'b.jpg' }, { name: 'c.jpg' }, { name: '.DS_Store' }] }, + { name: '__MACOSX', children: [{ name: '._playa.jpg' }] }, + { name: 'raro', kind: 'other' }, + ], + }), + entry({ name: 'vacía', children: [] }), + entry({ name: 'raro', kind: 'other' }), + ]; + const got = await droppedFiles(roots); + expect(got.map((p) => [p.path, p.inFolder, p.file.size])).toEqual([ + ['nota.txt', false, 3], + ['fotos/playa.jpg', true, 1], + ['fotos/2025/a.jpg', true, 1], + ['fotos/2025/b.jpg', true, 1], + ['fotos/2025/c.jpg', true, 1], + ['fotos/2025/.DS_Store', true, 1], + ['fotos/__MACOSX/._playa.jpg', true, 1], + ]); + expect(got.map(systemOf)).toEqual([undefined, undefined, undefined, undefined, undefined, '.DS_Store', '__MACOSX']); + }); + + it('fails as the browser fails to read a folder or a file', async () => { + await expect(droppedFiles([entry({ name: 'fotos', children: [], fail: 'read' })])).rejects.toThrow('NotFoundError'); + await expect(droppedFiles([entry({ name: 'fotos', children: [{ name: 'a.jpg', fail: 'file' }] })])).rejects.toThrow('NotReadableError'); + }); + + it('stops once it finds more files than a capsule holds, not counting the files of a system', async () => { + const children = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `f${i}` })); + const full = entry({ name: 'd', children: [...children(MAX_CAPSULE_FILES), { name: '.DS_Store' }] }, 5000); + expect((await droppedFiles([full])).length).toBe(MAX_CAPSULE_FILES + 1); + const over = entry({ name: 'd', children: children(MAX_CAPSULE_FILES + 1) }, 5000); + const err = await droppedFiles([over]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(TooManyFiles); + expect((err as Error).message).toBe('create: more than 65535 files'); + }); +}); + +describe('the list', () => { + let n = 0; + const id = () => ++n; + + it('adds the picked files in the byte order of their paths, a file of a system left out, and one with a path already there in its place', () => { + n = 0; + const first = addPicked([], [picked('fotos/b.jpg'), picked('fotos/á.jpg'), picked('fotos/B.jpg'), picked('fotos/.DS_Store')], id); + expect(first.list.map((e) => [e.id, e.path, e.original, e.system, e.included])).toEqual([ + [1, 'fotos/.DS_Store', 'fotos/.DS_Store', '.DS_Store', false], + [2, 'fotos/B.jpg', 'fotos/B.jpg', undefined, true], + [3, 'fotos/b.jpg', 'fotos/b.jpg', undefined, true], + [4, 'fotos/á.jpg', 'fotos/á.jpg', undefined, true], + ]); + expect([first.added, first.replaced, first.system]).toEqual([4, 0, 1]); + const again = picked('fotos/b.jpg', true, 9); + const second = addPicked(first.list, [picked('nota.txt', false), again], id); + expect(second.list.map((e) => [e.id, e.path])).toEqual([ + [1, 'fotos/.DS_Store'], + [2, 'fotos/B.jpg'], + [5, 'fotos/b.jpg'], + [4, 'fotos/á.jpg'], + [6, 'nota.txt'], + ]); + expect([second.list[2]!.file, second.added, second.replaced, second.system]).toEqual([again.file, 1, 1, 0]); + // Twice the same path in one choice: the later one stays. + const twice = addPicked([], [picked('a.txt', false, 1), picked('a.txt', false, 2)], id); + expect(twice.list.map((e) => e.file.size)).toEqual([2]); + expect([twice.added, twice.replaced]).toEqual([1, 1]); + }); + + it('edits a path, leaves a file out or takes it back, and gives what the capsule holds', () => { + n = 0; + // In the byte order of the paths: 1 is Thumbs.db, 2 is a.jpg and 3 is nota.txt. + let list: Entry[] = addPicked([], [picked('fotos/a.jpg', true, 10), picked('fotos/Thumbs.db', true, 5), picked('nota.txt', false, 3)], id).list; + expect(summarize(list)).toEqual({ files: 2, bytes: 13, left: 1, system: 1 }); + list = withPath(list, 2, 'fotos/playa.jpg'); + list = withIncluded(list, 1, true); + list = withIncluded(list, 3, false); + expect(list.map((e) => [e.path, e.original, e.included])).toEqual([ + ['fotos/Thumbs.db', 'fotos/Thumbs.db', true], + ['fotos/playa.jpg', 'fotos/a.jpg', true], + ['nota.txt', 'nota.txt', false], + ]); + expect(summarize(list)).toEqual({ files: 2, bytes: 15, left: 1, system: 0 }); + expect(includedEntries(list).map((e) => e.id)).toEqual([1, 2]); + expect(headFiles(list, true)).toEqual([ + { path: 'fotos/Thumbs.db', size: 5, mtime: 1_790_769_600_500 }, + { path: 'fotos/playa.jpg', size: 10, mtime: 1_790_769_600_500 }, + ]); + expect(headFiles(list, false)).toEqual([ + { path: 'fotos/Thumbs.db', size: 5 }, + { path: 'fotos/playa.jpg', size: 10 }, + ]); + }); +}); diff --git a/src/lib/inspector/create-files.ts b/src/lib/inspector/create-files.ts new file mode 100644 index 0000000..0d9a4c5 --- /dev/null +++ b/src/lib/inspector/create-files.ts @@ -0,0 +1,232 @@ +// The files that the create page puts in a capsule of format 3 (design of +// format 3, sections 3 and 5): a file by its name, and a folder by its name +// and the path of each file below it, as webkitRelativePath gives them; the +// files that systems create on their own left out of folders by default, as +// the CLI of the reference leaves them out (collect.go, spec §62.1 rule 15), +// and shown struck through; and the path of each, which the person may edit. +// No Unicode tables: the page loads it with its first load, and checks the +// paths with the rules of the reader on demand (create-check.ts). + +import { compareBytes, utf8Bytes } from '../dkc/bytes.ts'; + +/** The most files a capsule holds (MAX_FILES of head.ts, which brings the tables of the paths). */ +export const MAX_CAPSULE_FILES = 65535; + +/** A file or a folder that a system creates on its own, left out of folders by default. */ +export type SystemFile = '.DS_Store' | 'Thumbs.db' | 'desktop.ini' | '._*' | '__MACOSX'; + +const SYSTEM_NAMES = ['.DS_Store', 'Thumbs.db', 'desktop.ini'] as const; + +/** + * The file or folder of a system that `name` is, as systemFile of the CLI of + * the reference: __MACOSX for a folder; for a file, .DS_Store, Thumbs.db or + * desktop.ini compared as strings.EqualFold compares, or a name that starts + * with "._". + */ +export function systemFile(name: string, dir: boolean): SystemFile | undefined { + if (dir) return name === '__MACOSX' ? '__MACOSX' : undefined; + const same = SYSTEM_NAMES.find((n) => equalFold(name, n)); + if (same !== undefined) return same; + return name.startsWith('._') ? '._*' : undefined; +} + +const lowerASCII = (c: string): string => (c >= 'A' && c <= 'Z' ? String.fromCharCode(c.charCodeAt(0) + 32) : c); + +// strings.EqualFold of Go with a name in ASCII: each code point equal to the +// letter of the name in either case, or in the orbit of its simple case +// folding, which for the ASCII letters adds only U+212A KELVIN SIGN to k and +// U+017F LATIN SMALL LETTER LONG S to s. +function equalFold(s: string, ascii: string): boolean { + const cps = Array.from(s); + if (cps.length !== ascii.length) return false; + return cps.every((c, i) => { + const want = lowerASCII(ascii[i]!); + return lowerASCII(c) === want || (want === 'k' && c === 'K') || (want === 's' && c === 'ſ'); + }); +} + +/** A file as the person chose it. */ +export interface Picked { + /** Its name, or the name of the chosen folder and the path of the file below it, with '/'. */ + readonly path: string; + readonly file: File; + /** Whether it came from a folder, whose system files are left out by default. */ + readonly inFolder: boolean; +} + +/** + * The files of an , with multiple or webkitdirectory: each + * by its name, or by webkitRelativePath, which starts with the name of the + * chosen folder, when the browser gives one. + */ +export function pickedFiles(files: Iterable): Picked[] { + return Array.from(files, (file) => (file.webkitRelativePath === '' ? { path: file.name, file, inFolder: false } : { path: file.webkitRelativePath, file, inFolder: true })); +} + +/** The files of a drop are more than a capsule holds: the walk stopped. */ +export class TooManyFiles extends Error { + constructor() { + super(`create: more than ${MAX_CAPSULE_FILES} files`); + this.name = 'TooManyFiles'; + } +} + +/** + * The files of the items dropped on the page (DataTransferItem.webkitGetAsEntry, + * taken during the drop): a file by its name, and a folder walked to the end, + * with its name first in each path, as a folder chosen with webkitdirectory. + * A folder yields only its files: the empty folders are not kept. The walk + * throws TooManyFiles once it finds more files than a capsule holds, not + * counting the system files. + */ +export async function droppedFiles(roots: readonly FileSystemEntry[]): Promise { + const out: Picked[] = []; + let counted = 0; + const found = (p: Picked): void => { + out.push(p); + if (systemOf(p) === undefined && ++counted > MAX_CAPSULE_FILES) throw new TooManyFiles(); + }; + const walk = async (dir: FileSystemDirectoryEntry, path: string): Promise => { + for (const e of await entriesOf(dir)) { + const p = `${path}/${e.name}`; + if (e.isFile) found({ path: p, file: await fileOf(e as FileSystemFileEntry), inFolder: true }); + else if (e.isDirectory) await walk(e as FileSystemDirectoryEntry, p); + } + }; + for (const r of roots) { + if (r.isFile) found({ path: r.name, file: await fileOf(r as FileSystemFileEntry), inFolder: false }); + else if (r.isDirectory) await walk(r as FileSystemDirectoryEntry, r.name); + } + return out; +} + +// Every entry of a folder: readEntries gives them in batches, and an empty +// one at the end. +function entriesOf(dir: FileSystemDirectoryEntry): Promise { + const reader = dir.createReader(); + const all: FileSystemEntry[] = []; + return new Promise((resolve, reject) => { + const next = (): void => + reader.readEntries((batch) => { + if (batch.length === 0) resolve(all); + else { + all.push(...batch); + next(); + } + }, reject); + next(); + }); +} + +function fileOf(e: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => e.file(resolve, reject)); +} + +/** + * Why a picked file is left out by default, as the CLI walks a folder: a + * folder called __MACOSX on its way, or its own name. The chosen folder, + * the first segment, and a file chosen on its own are never left out. + */ +export function systemOf(p: Picked): SystemFile | undefined { + if (!p.inFolder) return undefined; + const segs = p.path.split('/'); + if (segs.slice(1, -1).some((s) => systemFile(s, true) !== undefined)) return '__MACOSX'; + return systemFile(segs.at(-1)!, false); +} + +/** A file of the list of the page. */ +export interface Entry { + /** Stable, for the keys of the list. */ + readonly id: number; + readonly file: File; + /** The path as the file came. */ + readonly original: string; + /** Its path in the capsule: the original one, until the person edits it. */ + readonly path: string; + /** Left out by default: a file that a system creates on its own, found in a folder. */ + readonly system?: SystemFile; + /** Whether the capsule holds it: every file but those of a system, until the person changes it. */ + readonly included: boolean; +} + +/** + * The list with the picked files added: a file whose path is already in the + * list takes the place of that entry, and the others go at the end, in the + * byte order of their paths, which is the order of the capsule (R8). `id` + * gives the id of each new entry. Also how many were added, how many took + * the place of another, and how many are files of a system, left out. + */ +export function addPicked( + list: readonly Entry[], + picked: readonly Picked[], + id: () => number, +): { list: Entry[]; added: number; replaced: number; system: number } { + const keys = new Map(picked.map((p) => [p, utf8Bytes(p.path)])); + const sorted = [...picked].sort((a, b) => compareBytes(keys.get(a)!, keys.get(b)!)); + const out = [...list]; + const at = new Map(out.map((e, i) => [e.path, i])); + let added = 0; + let replaced = 0; + let systems = 0; + for (const p of sorted) { + const system = systemOf(p); + if (system !== undefined) systems++; + const e: Entry = { id: id(), file: p.file, original: p.path, path: p.path, ...(system === undefined ? {} : { system }), included: system === undefined }; + const i = at.get(p.path); + if (i === undefined) { + at.set(p.path, out.length); + out.push(e); + added++; + } else { + out[i] = e; + replaced++; + } + } + return { list: out, added, replaced, system: systems }; +} + +/** The list with the path of entry `id` changed. */ +export function withPath(list: readonly Entry[], id: number, path: string): Entry[] { + return list.map((e) => (e.id === id ? { ...e, path } : e)); +} + +/** The list with entry `id` held by the capsule or left out. */ +export function withIncluded(list: readonly Entry[], id: number, included: boolean): Entry[] { + return list.map((e) => (e.id === id ? { ...e, included } : e)); +} + +/** The files that the capsule holds, in the order of the list. */ +export function includedEntries(list: readonly Entry[]): Entry[] { + return list.filter((e) => e.included); +} + +/** What the capsule holds of the list: how many files and bytes, and how many are left out, and of those, how many are of a system. */ +export interface ListSummary { + readonly files: number; + readonly bytes: number; + readonly left: number; + readonly system: number; +} + +export function summarize(list: readonly Entry[]): ListSummary { + let files = 0; + let bytes = 0; + let system = 0; + for (const e of list) { + if (e.included) { + files++; + bytes += e.file.size; + } else if (e.system !== undefined) system++; + } + return { files, bytes, left: list.length - files, system }; +} + +/** + * The files the capsule holds as encryptFiles and the lengths take them: + * their path, their size and, with `mtime`, File.lastModified, which the + * writer stores in seconds when it falls from 1970 to 9999 (spec §62.1 + * rule 16). + */ +export function headFiles(list: readonly Entry[], mtime: boolean): { path: string; size: number; mtime?: number }[] { + return includedEntries(list).map((e) => ({ path: e.path, size: e.file.size, ...(mtime ? { mtime: e.file.lastModified } : {}) })); +} diff --git a/src/lib/inspector/create-input.test.ts b/src/lib/inspector/create-input.test.ts index adce8d0..499a4f2 100644 --- a/src/lib/inspector/create-input.test.ts +++ b/src/lib/inspector/create-input.test.ts @@ -1,23 +1,26 @@ // Tests of create-input.ts: the form of the create page, checked field by -// field in its order, and what the page shows before encrypting. +// field in its order, and what the page shows before encrypting: the files, +// measured once, the comment and the declared author, and the size. import { describe, expect, it } from 'vitest'; import { parseDateKey } from '../dkc/datekey.ts'; import { TIME_AND_KEY, TIME_ONLY } from '../dkc/header.ts'; -import { bodyLength, capsuleLength } from '../dkc/lengths.ts'; +import { MAX_HEAD_LEN } from '../dkc/body.ts'; +import { bodyLength, capsuleLength, headLength } from '../dkc/lengths.ts'; import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts'; import { formatX25519Recipient } from '../dkc/recipient.ts'; import { recipientKeys, sampleIdentity } from '../dkc/testing/interop.ts'; import { x25519PublicKey } from '../dkc/x25519.ts'; -import { type CreateInput, defaultFileNames, downloadName, planCapsule, readRecipients, SOON_MS } from './create-input.ts'; +import { formatByteCount } from './format.ts'; +import { chooseFiles, type CreateInput, defaultFileNames, downloadName, planCapsule, readRecipients, SOON_MS } from './create-input.ts'; // Round 1000 of Quicknet opens at 2023-08-23T15:59:24Z. const ROUND_1000_MS = Date.UTC(2023, 7, 23, 15, 59, 24); const GENESIS_MS = Date.UTC(2023, 7, 23, 15, 9, 27); const input = (extra: Partial = {}): CreateInput => ({ - fileName: 'nota.txt', - fileSize: 1000, - fileModified: undefined, + files: chooseFiles([{ path: 'nota.txt', size: 1000 }]), + comment: '', + author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', @@ -49,15 +52,51 @@ describe('planCapsule', () => { // Format 3: BODY holds the frame, the area of 512 bytes, the head of 111 // bytes with the path and no mtime, and the file. const L = 12 + 512 + 111 + 1000; - expect([p.file, p.length, p.paddedLength]).toEqual([{ path: 'nota.txt', size: 1000 }, L, paddedLength(L, REFORZADO)]); - expect(p.length).toBe(bodyLength({ files: [p.file] })); + expect([p.files, p.comment, p.author, p.length, p.paddedLength]).toEqual([[{ path: 'nota.txt', size: 1000 }], '', '', L, paddedLength(L, REFORZADO)]); + expect(p.length).toBe(bodyLength({ files: p.files })); expect(p.size).toBe(capsuleLength({ profileId: 'datekeys:quicknet:v1', round: 1000, policy: TIME_ONLY, length: L })); // With the mtime of the file, which the head records in seconds. - const dated = planCapsule(input({ fileModified: 1_790_769_600_500 }), GENESIS_MS); - expect(dated.ok && [dated.plan.file, dated.plan.length]).toEqual([{ path: 'nota.txt', size: 1000, mtime: 1_790_769_600_500 }, L + 6]); + const dated = planCapsule(input({ files: chooseFiles([{ path: 'nota.txt', size: 1000, mtime: 1_790_769_600_500 }]) }), GENESIS_MS); + expect(dated.ok && [dated.plan.files, dated.plan.length]).toEqual([[{ path: 'nota.txt', size: 1000, mtime: 1_790_769_600_500 }], L + 6]); expect(p.names).toEqual({ dkc: 'capsula-20230823T155924Z.dkc', dkk: 'capsula-20230823T155924Z.dkk' }); }); + it('plans several files, a comment, which it stores with LF, and a declared author, measured as the writer lays them out', () => { + const files = [ + { path: 'fotos/b.jpg', size: 70_000, mtime: 1_790_769_600_000 }, + { path: 'carta.txt', size: 37 }, + { path: 'fotos/a.jpg', size: 0 }, + ]; + const r = planCapsule(input({ files: chooseFiles(files), comment: 'Feliz\r\ncumpleaños\r', author: 'Ana' }), GENESIS_MS); + const p = r.ok ? r.plan : undefined!; + expect([p.files, p.comment, p.author]).toEqual([files, 'Feliz\ncumpleaños\n', 'Ana']); + const shape = { files, comment: 'Feliz\r\ncumpleaños\r', author: 'Ana' }; + expect(p.length).toBe(bodyLength(shape)); + expect(p.length).toBe(12 + 512 + headLength(shape) + 70_037); + // A comment alone, with no files, is a capsule too (decision 9). + const alone = planCapsule(input({ files: chooseFiles([]), comment: 'Solo esto.' }), GENESIS_MS); + expect(alone.ok && [alone.plan.files, alone.plan.length]).toEqual([[], bodyLength({ files: [], comment: 'Solo esto.' })]); + }); + + it('refuses no files and no comment, more files than a capsule holds, a head over 16 MiB and an L over its maximum', () => { + expect(problem({ files: chooseFiles([]) })).toEqual(['files', 'Elige al menos un fichero, o escribe un mensaje en el comentario (§62.1).']); + const many = chooseFiles(Array.from({ length: 65536 }, (_, i) => ({ path: `f${i}`, size: 0 }))); + expect(problem({ files: many })).toEqual(['files', 'Hay 65.536 ficheros marcados, y una cápsula guarda como mucho 65.535.']); + expect(planCapsule(input({ files: chooseFiles(many.list.slice(1)) }), GENESIS_MS).ok).toBe(true); + // Files whose array takes n bytes of the head, besides its keys 0 to 2, + // which a head without files has too, in a map of one byte. + const rest = headLength({ files: [] }); + const head = (n: number) => ({ list: [{ path: 'a', size: 1 }], measured: { count: 1, head: n, content: 1 } }); + expect(planCapsule(input({ files: head(MAX_HEAD_LEN - rest) }), GENESIS_MS).ok).toBe(true); + expect(problem({ files: head(MAX_HEAD_LEN - rest + 1) })).toEqual([ + 'files', + 'Las rutas y los datos de los ficheros ocupan 16.777.217 bytes en la cabecera cifrada, más de 16.777.216 bytes: marca menos ficheros o acorta sus rutas (§29.4).', + ]); + const huge = chooseFiles([{ path: 'a', size: MAX_PAYLOAD_LENGTH }]); + const over = bodyLength({ files: huge.list }) - MAX_PAYLOAD_LENGTH; + expect(problem({ files: huge })).toEqual(['files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - over)}, el máximo de una cápsula (§29.1).`]); + }); + it('opens at the first round at or after the requested instant, to the millisecond', () => { const at = (time: string) => { const r = planCapsule(input({ time }), GENESIS_MS); @@ -91,8 +130,7 @@ describe('planCapsule', () => { }); it('checks the fields in the order of the form, the first problem only', () => { - expect(problem({ fileSize: undefined, date: '' })).toEqual(['file', 'Elige el fichero que guardará la cápsula.']); - expect(problem({ fileSize: MAX_PAYLOAD_LENGTH + 1 })[0]).toBe('file'); + expect(problem({ files: chooseFiles([]), date: '' })[0]).toBe('files'); expect(problem({ date: '', time: '' })).toEqual(['date', 'Elige el día de apertura.']); expect(problem({ time: '' })).toEqual(['time', 'Elige la hora de apertura.']); expect(problem({ date: '2023-02-30' })).toEqual(['date', 'La fecha o la hora no son válidas.']); diff --git a/src/lib/inspector/create-input.ts b/src/lib/inspector/create-input.ts index fc0fd45..24f3951 100644 --- a/src/lib/inspector/create-input.ts +++ b/src/lib/inspector/create-input.ts @@ -1,17 +1,20 @@ // The form of the create page (plan of phase 3, section 9, as decided in -// step 6), without DOM, clock or writer: what the person asked for, checked -// field by field in the order of the form, and everything the page shows -// before encrypting. Its imports carry no noble and no age-encryption, so the -// page loads it with its first load and checks the recipients as they are -// typed. +// step 6, and the design of format 3), without DOM, clock or writer: what +// the person asked for, checked field by field in the order of the form, +// and everything the page shows before encrypting. Its imports carry no +// noble, no age-encryption and no Unicode tables, so the page loads it with +// its first load and checks the recipients as they are typed; the rules of +// the paths and the texts come on demand, with create-check.ts. import { ACCESS_SLOTS } from '../dkc/age.ts'; import { compactDateKey, type DateKey, type Instant, isLongHorizon, resolveDateKey, roundTime } from '../dkc/datekey.ts'; import { type Policy, TIME_AND_KEY } from '../dkc/header.ts'; -import { bodyLength, capsuleLength } from '../dkc/lengths.ts'; +import { MAX_HEAD_LEN } from '../dkc/body.ts'; +import { bodyLengthOf, capsuleLength, headComment, headLengthOf, type MeasuredFiles, measureFiles } from '../dkc/lengths.ts'; import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts'; import { quicknet } from '../dkc/profile.ts'; import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts'; +import { MAX_CAPSULE_FILES } from './create-files.ts'; import { formatByteCount, formatInteger, safeFileName } from './format.ts'; import { localToEpochMs } from './localtime.ts'; @@ -19,18 +22,33 @@ import { localToEpochMs } from './localtime.ts'; export const SOON_MS = 3600_000; /** The inputs of the form. */ -export type CreateField = 'file' | 'date' | 'time' | 'zone' | 'recipients' | 'portable'; +export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable'; + +/** A file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds (File.lastModified). */ +export interface PlannedFile { + readonly path: string; + readonly size: number; + readonly mtime?: number; +} + +/** The files of the capsule, measured once (chooseFiles): the page plans again at every change of the form. */ +export interface ChosenFiles { + readonly list: readonly PlannedFile[]; + readonly measured: MeasuredFiles; +} + +/** The files of the capsule, in the order of the list of the page, measured. */ +export function chooseFiles(list: readonly PlannedFile[]): ChosenFiles { + return { list, measured: measureFiles(list) }; +} /** What the person entered. */ export interface CreateInput { - /** - * The chosen file: its name, which is its path in the capsule, its size - * and its modification time in milliseconds (File.lastModified), which - * the head records; undefined while there is none. - */ - readonly fileName: string | undefined; - readonly fileSize: number | undefined; - readonly fileModified: number | undefined; + /** The files that the capsule holds, none or many. */ + readonly files: ChosenFiles; + /** The comment and the declared author of the head, '' when none; the page checks their characters (create-check.ts). */ + readonly comment: string; + readonly author: string; /** The values of and . */ readonly date: string; readonly time: string; @@ -60,11 +78,13 @@ export interface CapsulePlan { /** The raw X25519 public keys of the recipients. */ readonly recipients: readonly Uint8Array[]; readonly portable: boolean; - /** The file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds. */ - readonly file: { readonly path: string; readonly size: number; readonly mtime?: number }; + /** The files of the capsule, in the order of the list, and the texts of its head. */ + readonly files: readonly PlannedFile[]; + readonly comment: string; + readonly author: string; /** * L, the length of BODY: the frame, the security area, the head and the - * file (§29.2), and P = reforzado(L), the rule the page always uses. + * files (§29.2), and P = reforzado(L), the rule the page always uses. */ readonly length: number; readonly paddedLength: number; @@ -110,12 +130,23 @@ export function readRecipients(text: string): { ok: true; keys: Uint8Array[] } | */ export function planCapsule(input: CreateInput, nowMs: number): Planned { const fail = (field: CreateField, problem: string): Planned => ({ ok: false, field, problem }); - if (input.fileSize === undefined || input.fileName === undefined) return fail('file', 'Elige el fichero que guardará la cápsula.'); - const file = { path: input.fileName, size: input.fileSize, ...(input.fileModified === undefined ? {} : { mtime: input.fileModified }) }; - // BODY holds the file with its frame, security area and head (§29.2). - const length = bodyLength({ files: [file] }); + const { list, measured } = input.files; + const texts = { comment: input.comment, author: input.author }; + if (measured.count === 0 && input.comment === '') return fail('files', 'Elige al menos un fichero, o escribe un mensaje en el comentario (§62.1).'); + if (measured.count > MAX_CAPSULE_FILES) { + return fail('files', `Hay ${formatInteger(measured.count)} ficheros marcados, y una cápsula guarda como mucho ${formatInteger(MAX_CAPSULE_FILES)}.`); + } + const head = headLengthOf(measured, texts); + if (head > MAX_HEAD_LEN) { + return fail( + 'files', + `Las rutas y los datos de los ficheros ocupan ${formatByteCount(head)} en la cabecera cifrada, más de ${formatByteCount(MAX_HEAD_LEN)}: marca menos ficheros o acorta sus rutas (§29.4).`, + ); + } + // BODY holds the files with its frame, security area and head (§29.2). + const length = bodyLengthOf(measured, texts); if (length > MAX_PAYLOAD_LENGTH) { - return fail('file', `El fichero ocupa más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - input.fileSize))}, el máximo de una cápsula (§29.1).`); + return fail('files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - measured.content))}, el máximo de una cápsula (§29.1).`); } if (input.date === '') return fail('date', 'Elige el día de apertura.'); if (input.time === '') return fail('time', 'Elige la hora de apertura.'); @@ -182,7 +213,9 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned { policy, recipients, portable, - file, + files: list, + comment: headComment(input.comment), + author: input.author, length, paddedLength: paddedLength(length, REFORZADO), size: capsuleLength({ profileId: dateKey.profileId, round: dateKey.round, policy, length }), diff --git a/src/lib/inspector/creator.test.ts b/src/lib/inspector/creator.test.ts index 5efa367..744e2f0 100644 --- a/src/lib/inspector/creator.test.ts +++ b/src/lib/inspector/creator.test.ts @@ -1,5 +1,6 @@ // Tests of creator.ts: the writing of the create page, in memory and into a -// temporary file, with the person's cancellation, the room of the browser and +// temporary file, with its files, comment and declared author, the progress +// of its two readings, the person's cancellation, the room of the browser and // the checks of what it wrote against what the page showed. What it writes // opens. encodeAccessKey is wrapped to keep the .dkk it encodes, so that its // wiping on a failure can be seen. @@ -12,7 +13,7 @@ import { open } from '../dkc/open.ts'; import { MemorySink } from '../dkc/sink.ts'; import { suppliedRelease } from '../dkc/release.ts'; import { h, readJSON } from '../dkc/testing/testdata.ts'; -import { type CapsulePlan, type CreateInput, planCapsule } from './create-input.ts'; +import { type CapsulePlan, chooseFiles, type CreateInput, type PlannedFile, planCapsule } from './create-input.ts'; import { createCapsule, CreateStopped } from './creator.ts'; import type { TempFile } from './tempfile.ts'; @@ -37,20 +38,25 @@ const RELEASE = (() => { return { round: r.round, signature: h(r.signature) }; })(); +// The one file nota.txt of `size` bytes. +const one = (size: number, mtime?: number) => chooseFiles([{ path: 'nota.txt', size, ...(mtime === undefined ? {} : { mtime }) }]); + // A plan for round 1000, whose release is published. function plan(extra: Partial = {}, nowMs = GENESIS_MS): CapsulePlan { const r = planCapsule( - { fileName: 'nota.txt', fileSize: 0, fileModified: undefined, date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, ...extra }, + { files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, ...extra }, nowMs, ); if (!r.ok) throw new Error(r.problem); return r.plan; } -function content(n: number): Uint8Array { - return Uint8Array.from({ length: n }, (_, i) => (i * 7 + 3) & 0xff); +function content(n: number, seed = 3): Uint8Array { + return Uint8Array.from({ length: n }, (_, i) => (i * 7 + seed) & 0xff); } +const blob = (b: Uint8Array): Blob => new Blob([b as Uint8Array]); + // A temporary file of the page, in memory; `read` makes what file() gives // from what was written. function temp(read = async (b: Uint8Array): Promise => new File([b as Uint8Array], 'capsule')): TempFile & { @@ -81,105 +87,174 @@ async function failure(p: Promise): Promise { throw new Error('expected a failure'); } -// The one file of a capsule of the page, which is of format 3, under the -// name of the plan. -async function opened(capsule: Blob, extra: { accessKeyFile?: Uint8Array } = {}): Promise { +// What open delivers of a capsule of the page, which is of format 3. +async function opened(capsule: Blob, extra: { accessKeyFile?: Uint8Array } = {}) { const sink = new MemorySink(); const r = await open(capsule, { source: suppliedRelease(RELEASE), now: () => ({ seconds: RELEASE.round * 3 + GENESIS_MS / 1000, nanos: 0 }), sink, ...extra }); - expect([r.error, r.format, r.head?.files.map((f) => f.path)]).toEqual([undefined, 3, ['nota.txt']]); - return sink.opened?.files[0]; + expect([r.error, r.format]).toEqual([undefined, 3]); + return { head: r.head!, files: sink.opened!.files }; } describe('createCapsule', () => { - it('writes a time_only capsule in memory, of the size planned, that inspect accepts and open opens', async () => { + it('writes a time_only capsule in memory, of the size planned, that inspect accepts and open opens, reporting both readings', async () => { const body = content(5000); - const p = plan({ fileSize: body.length }); - const progress: [number, number][] = []; - const c = await createCapsule({ file: new Blob([body as Uint8Array]), plan: p, cancelled: () => false, now: genesis, progress: (w, t) => void progress.push([w, t]) }); + const p = plan({ files: one(body.length) }); + const progress: [number, number, number][] = []; + const c = await createCapsule({ files: [blob(body)], plan: p, cancelled: () => false, now: genesis, progress: (pass, done, all) => void progress.push([pass, done, all]) }); expect([c.capsule.size, c.dkk, c.inspection.error]).toEqual([p.size, undefined, undefined]); expect(c.capsuleId).toBe(toHex(c.inspection.header!.capsuleId)); expect(c.inspection.header!.dateKey.round).toBe(1000); expect(c.bytes.length).toBeLessThanOrEqual(p.size); - expect([progress[0], progress.at(-1)]).toEqual([ - [0, p.size], - [p.size, p.size], + // The first reading, of the files, and then the writing of the .dkc. + const firsts = progress.filter(([pass]) => pass === 1); + const seconds = progress.filter(([pass]) => pass === 2); + expect([firsts[0], firsts.at(-1), seconds[0], seconds.at(-1)]).toEqual([ + [1, 0, 5000], + [1, 5000, 5000], + [2, 0, p.size], + [2, p.size, p.size], ]); + expect(progress.indexOf(seconds[0]!)).toBe(firsts.length); expect(c.ms).toBeGreaterThan(0); - expect(await opened(c.capsule)).toEqual(body); + const o = await opened(c.capsule); + expect([o.head.files.map((f) => f.path), o.files]).toEqual([['nota.txt'], [body]]); + }); + + it('writes several files in the byte order of their paths, with the comment and the declared author', async () => { + const files: PlannedFile[] = [ + { path: 'fotos/b.jpg', size: 70_000, mtime: 1_790_769_600_500 }, + { path: 'carta.txt', size: 37 }, + { path: 'Zeta.txt', size: 0 }, + ]; + const bodies = [content(70_000, 1), content(37, 2), new Uint8Array(0)]; + const p = plan({ files: chooseFiles(files), comment: 'Para ti.\r\n', author: 'Ana' }); + const c = await createCapsule({ files: bodies.map(blob), plan: p, cancelled: () => false, now: genesis }); + expect(c.capsule.size).toBe(p.size); + const o = await opened(c.capsule); + expect([o.head.comment, o.head.author]).toEqual(['Para ti.\n', 'Ana']); + expect(o.head.files.map((f) => [f.path, f.size, f.mtime])).toEqual([ + ['Zeta.txt', 0, undefined], + ['carta.txt', 37, undefined], + ['fotos/b.jpg', 70_000, 1_790_769_600], + ]); + expect(o.files).toEqual([bodies[2], bodies[1], bodies[0]]); + }); + + it('writes a capsule with a comment and no files', async () => { + const p = plan({ files: chooseFiles([]), comment: 'Solo un mensaje.' }); + const c = await createCapsule({ files: [], plan: p, cancelled: () => false, now: genesis }); + const o = await opened(c.capsule); + expect([o.head.files, o.head.comment, o.files]).toEqual([[], 'Solo un mensaje.', []]); }); it('writes a time_and_key capsule into the temporary file, closed, with a .dkk that opens it', async () => { const body = content(70_000); - const p = plan({ fileSize: body.length, policy: TIME_AND_KEY, portable: true }); + const p = plan({ files: one(body.length), policy: TIME_AND_KEY, portable: true }); const out = temp(); - const c = await createCapsule({ file: new Blob([body as Uint8Array]), plan: p, output: out, room: p.size, cancelled: () => false, now: genesis }); + const c = await createCapsule({ files: [blob(body)], plan: p, output: out, room: p.size, cancelled: () => false, now: genesis }); expect([out.state.closed, out.state.aborted, c.capsule.size]).toEqual([true, undefined, p.size]); expect(c.capsule).toBeInstanceOf(File); expect(decodeAccessKey(c.dkk!).capsuleId).toEqual(c.inspection.header!.capsuleId); expect([c.dkk, wiped(c.dkk)]).toEqual([encoded.at(-1), false]); - expect(await opened(c.capsule, { accessKeyFile: c.dkk!.slice() })).toEqual(body); + expect((await opened(c.capsule, { accessKeyFile: c.dkk!.slice() })).files).toEqual([body]); }); - it('writes the file under its name with its modification time, which the head records in seconds', async () => { - const body = content(100); - const p = plan({ fileSize: body.length, fileModified: 1_790_769_600_500 }); - expect(p.file).toEqual({ path: 'nota.txt', size: 100, mtime: 1_790_769_600_500 }); - const c = await createCapsule({ file: new Blob([body as Uint8Array]), plan: p, cancelled: () => false, now: genesis }); - const sink = new MemorySink(); - const r = await open(c.capsule, { source: suppliedRelease(RELEASE), now: () => ({ seconds: RELEASE.round * 3 + GENESIS_MS / 1000, nanos: 0 }), sink }); - expect([r.error, r.head?.files[0]?.mtime, c.capsule.size]).toEqual([undefined, 1_790_769_600, p.size]); + it('writes the modification time of a file, which the head records in seconds', async () => { + const p = plan({ files: one(100, 1_790_769_600_500) }); + const c = await createCapsule({ files: [blob(content(100))], plan: p, cancelled: () => false, now: genesis }); + expect([(await opened(c.capsule)).head.files[0]?.mtime, c.capsule.size]).toEqual([1_790_769_600, p.size]); }); it('writes with the clock of the system when given none, and without a progress callback', async () => { const at = new Date(Date.now() + 2 * 3600_000).toISOString(); - const p = plan({ fileSize: 3, date: at.slice(0, 10), time: at.slice(11, 19) }, Date.now()); - const c = await createCapsule({ file: new Blob([new Uint8Array(3)]), plan: p, cancelled: () => false }); + const p = plan({ files: one(3), date: at.slice(0, 10), time: at.slice(11, 19) }, Date.now()); + const c = await createCapsule({ files: [blob(new Uint8Array(3))], plan: p, cancelled: () => false }); expect([c.capsule.size, c.inspection.header!.dateKey.round]).toEqual([p.size, p.dateKey.round]); }); - it('stops when the person cancels: before writing, or after the piece in progress, the output aborted', async () => { - const p = plan({ fileSize: 300_000 }); + it('stops when the person cancels: in the first reading, before writing, or after the piece in progress, the output aborted', async () => { + const p = plan({ files: one(300_000) }); const out = temp(); - const err = await failure(createCapsule({ file: new Blob([content(300_000) as Uint8Array]), plan: p, output: out, cancelled: () => true, now: genesis })); + const err = await failure(createCapsule({ files: [blob(content(300_000))], plan: p, output: out, cancelled: () => true, now: genesis })); expect(err).toBeInstanceOf(CreateStopped); expect([(err as CreateStopped).reason, (err as CreateStopped).total, err.message]).toEqual(['cancelled', p.size, 'create: cancelled']); - expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, err]); + // The writer aborts the output with the error of the file, whose cause is the cancellation. + expect([out.chunks.length, out.state.closed, (out.state.aborted as Error).cause]).toEqual([0, false, err]); + + // A capsule of a comment alone has nothing to read: the writing stops before it starts. + const empty = temp(); + const words = plan({ files: chooseFiles([]), comment: 'hola' }); + const err2 = await failure(createCapsule({ files: [], plan: words, output: empty, cancelled: () => true, now: genesis })); + expect([(err2 as CreateStopped).reason, empty.chunks.length, empty.state.aborted]).toEqual(['cancelled', 0, err2]); - let calls = 0; - const out2 = temp(); - const err2 = await failure( - createCapsule({ file: new Blob([content(300_000) as Uint8Array]), plan: p, output: out2, cancelled: () => ++calls > 5, now: genesis }), + let writes = 0; + const out3 = temp(); + const err3 = await failure( + createCapsule({ + files: [blob(content(300_000))], + plan: p, + output: out3, + cancelled: () => writes > 2, + progress: (pass) => void (pass === 2 && writes++), + now: genesis, + }), ); - expect((err2 as CreateStopped).reason).toBe('cancelled'); - expect(out2.chunks.length).toBeGreaterThan(0); - expect([out2.state.closed, out2.state.aborted]).toEqual([false, err2]); + expect((err3 as CreateStopped).reason).toBe('cancelled'); + expect(out3.chunks.length).toBeGreaterThan(0); + expect([out3.state.closed, out3.state.aborted]).toEqual([false, err3]); }); - it('writes nothing when the .dkc does not fit in the room of the browser', async () => { - const p = plan({ fileSize: 10 }); + it('writes nothing, and reads nothing, when the .dkc does not fit in the room of the browser', async () => { + const p = plan({ files: one(10) }); const out = temp(); - const err = await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: p, output: out, room: p.size - 1, cancelled: () => false, now: genesis })); + let reads = 0; + const counted = { stream: () => (reads++, blob(new Uint8Array(10)).stream()) } as unknown as Blob; + const err = await failure(createCapsule({ files: [counted], plan: p, output: out, room: p.size - 1, cancelled: () => false, now: genesis })); expect([(err as CreateStopped).reason, (err as CreateStopped).total, err.message]).toEqual(['room', p.size, `create: the .dkc of ${p.size} bytes does not fit`]); - expect([out.chunks.length, out.state.aborted]).toEqual([0, err]); + expect([out.chunks.length, out.state.aborted, reads]).toEqual([0, err, 0]); + // In memory, without an output. + expect(((await failure(createCapsule({ files: [counted], plan: p, room: 0, cancelled: () => false, now: genesis }))) as CreateStopped).reason).toBe('room'); + }); + + it('refuses files that are not those of the plan, and a file that changes between its two readings', async () => { + const p = plan({ files: one(10) }); + const out = temp(); + const err = await failure(createCapsule({ files: [], plan: p, output: out, cancelled: () => false, now: genesis })); + expect([err.message, out.state.aborted]).toEqual(['create: internal error: 0 files for the 1 of the plan', err]); + // Longer than the plan says: the first reading stops, and its stream is cancelled. + let cancelled: unknown; + const longer = { + stream: () => { + const r = blob(new Uint8Array(11)).stream().getReader(); + return new ReadableStream({ pull: async (c) => void (await r.read().then((x) => (x.done ? c.close() : c.enqueue(x.value)))), cancel: (why) => void (cancelled = why) }); + }, + } as unknown as Blob; + const err2 = await failure(createCapsule({ files: [longer], plan: p, cancelled: () => false, now: genesis })); + expect([err2.message, (cancelled as Error | undefined)?.message]).toEqual(['capsule: file "nota.txt": more than its size of 10 bytes', err2.message]); + let reading = 0; + const changing = { stream: () => blob(content(10, ++reading)).stream() } as unknown as Blob; + expect((await failure(createCapsule({ files: [changing], plan: p, cancelled: () => false, now: genesis }))).message).toBe( + 'capsule: file "nota.txt" changed after its first reading: its SHA-256 is another', + ); }); it('refuses a capsule of another size or round than the page showed, and wipes its .dkk', async () => { - const p = plan({ fileSize: 10 }); + const p = plan({ files: one(10) }); const bigger = { ...p, size: p.size + 1 }; - expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: bigger, cancelled: () => false, now: genesis }))).message).toBe( + expect((await failure(createCapsule({ files: [blob(new Uint8Array(10))], plan: bigger, cancelled: () => false, now: genesis }))).message).toBe( `create: internal error: wrote ${p.size} bytes for round 1000, planned ${p.size + 1} for round 1000`, ); - const keyed = plan({ fileSize: 10, policy: TIME_AND_KEY }); + const keyed = plan({ files: one(10), policy: TIME_AND_KEY }); const other = { ...keyed, dateKey: { ...keyed.dateKey, round: 999 } }; - expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: other, cancelled: () => false, now: genesis }))).message).toBe( + expect((await failure(createCapsule({ files: [blob(new Uint8Array(10))], plan: other, cancelled: () => false, now: genesis }))).message).toBe( `create: internal error: wrote ${keyed.size} bytes for round 1000, planned ${keyed.size} for round 999`, ); expect(wiped(encoded.at(-1))).toBe(true); }); it('does not offer a .dkc whose file is not what was written or that inspect rejects, and wipes its .dkk', async () => { - const keyed = plan({ fileSize: 10, policy: TIME_AND_KEY }); - const make = (out: TempFile) => createCapsule({ file: new Blob([new Uint8Array(10)]), plan: keyed, output: out, cancelled: () => false, now: genesis }); + const keyed = plan({ files: one(10), policy: TIME_AND_KEY }); + const make = (out: TempFile) => createCapsule({ files: [blob(new Uint8Array(10))], plan: keyed, output: out, cancelled: () => false, now: genesis }); const shorter = temp(async (b) => new File([b.subarray(1) as Uint8Array], 'capsule')); expect((await failure(make(shorter))).message).toBe(`create: internal error: the .dkc written is ${keyed.size - 1} bytes, planned ${keyed.size}`); @@ -200,9 +275,9 @@ describe('createCapsule', () => { }); it('rethrows the errors of encrypt as they are', async () => { - const p = plan({ fileSize: 10 }); + const p = plan({ files: one(10) }); const late = () => ({ seconds: p.requested.seconds, nanos: 0 }); - expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: p, cancelled: () => false, now: late }))).message).toBe( + expect((await failure(createCapsule({ files: [blob(new Uint8Array(10))], plan: p, cancelled: () => false, now: late }))).message).toBe( 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ); }); diff --git a/src/lib/inspector/creator.ts b/src/lib/inspector/creator.ts index 81680b0..94c7d51 100644 --- a/src/lib/inspector/creator.ts +++ b/src/lib/inspector/creator.ts @@ -1,10 +1,11 @@ // The writing of a capsule, which the create page loads on demand with a // dynamic import: it carries encrypt.ts and with it noble, age-encryption // and the Unicode tables of the paths, which the first load of the page does -// not need (plan of phase 3, section 9). The capsule is of format 3, with -// the chosen file under its name (spec §62.1 rule 1). Nothing goes to the -// network: the round is resolved in the browser, and tlock uses only the -// pinned public key (§35). +// not need (plan of phase 3, section 9). The capsule is of format 3 (spec +// §62.1 rule 1), with the chosen files under their paths, and the comment +// and the declared author of the form. Nothing goes to the network: the +// round is resolved in the browser, and tlock uses only the pinned public +// key (§35). import { encodeAccessKey, type Inspection, type Instant, inspect, quicknet, readCapsule, toHex, wipeAccessKey } from '../dkc/index.ts'; import { encryptFiles } from '../dkc/encrypt.ts'; @@ -13,18 +14,22 @@ import { systemClock } from './opener.ts'; import type { TempFile } from './tempfile.ts'; export interface CreateRequest { - /** The person's file, which the capsule holds as plan.file says, read twice. */ - readonly file: Blob; + /** The person's files, one for each of plan.files and in its order, each read twice. */ + readonly files: readonly Blob[]; /** What the form asked for, checked by planCapsule. */ readonly plan: CapsulePlan; /** Where the .dkc goes; memory when omitted. */ readonly output?: TempFile; - /** The bytes the .dkc may take, the free space that the browser reports; no limit when omitted. */ + /** The bytes the .dkc may take, the free space that the browser reports, compared with plan.size before anything; no limit when omitted. */ readonly room?: number; /** Whether the person cancelled: the writing stops after the piece in progress. */ readonly cancelled: () => boolean; - /** Called with (0, total) before the first write, then after each piece. */ - readonly progress?: (written: number, total: number) => void; + /** + * The progress of the two readings of the files: in the first, the bytes + * of the files read for their SHA-256, out of all of them; in the second, + * the bytes of the .dkc written, with (0, total) before the first write. + */ + readonly progress?: (pass: 1 | 2, done: number, total: number) => void; /** The clock; the system clock when omitted. It must still be before the requested instant. */ readonly now?: () => Instant; } @@ -58,30 +63,81 @@ export class CreateStopped extends Error { } /** - * Writes the capsule of `req.plan` with the file `req.file`, and inspects - * what it wrote. The output is closed only once the capsule is complete and - * checked, and aborted on any failure, a cancellation included; the errors - * of encryptFiles are rethrown as they are. + * Writes the capsule of `req.plan` with the files `req.files`, and inspects + * what it wrote. It writes nothing when the .dkc does not fit in `req.room`. + * The output is closed only once the capsule is complete and checked, and + * aborted on any failure, a cancellation included; the errors of + * encryptFiles are rethrown as they are. */ export async function createCapsule(req: CreateRequest): Promise { const { plan } = req; + // The room is known before the first reading: the plan has the exact size. + const early = + req.files.length !== plan.files.length + ? new Error(`create: internal error: ${req.files.length} files for the ${plan.files.length} of the plan`) + : req.room !== undefined && plan.size > req.room + ? new CreateStopped('room', plan.size) + : undefined; + if (early !== undefined) { + await req.output?.writable.abort(early); + throw early; + } const start = performance.now(); - const { file } = plan; - const source = { path: file.path, size: file.size, ...(file.mtime === undefined ? {} : { mtime: file.mtime }), open: () => req.file.stream() }; - const res = await encryptFiles([source], { - profile: quicknet(), - unlockAt: plan.requested, - policy: plan.policy, - recipients: plan.recipients, - newPortableKey: plan.portable, - now: req.now ?? systemClock, - ...(req.output === undefined ? {} : { output: req.output.writable }), - progress: (written, total) => { - if (req.cancelled()) throw new CreateStopped('cancelled', total); - if (written === 0 && req.room !== undefined && total > req.room) throw new CreateStopped('room', total); - req.progress?.(written, total); - }, - }); + // The first reading of each file, counted and stopped when the person + // cancels; the writer reports the second one as it writes the .dkc. + const all = plan.files.reduce((sum, f) => sum + f.size, 0); + let read = 0; + const opened = new Set(); + // A stream of its own around the reader of the file, which reads only when + // the writer asks for a piece: the count is what the writer has read. + const first = (i: number): ReadableStream => { + const stream = req.files[i]!.stream(); + if (opened.has(i)) return stream; + opened.add(i); + const reader = stream.getReader(); + return new ReadableStream( + { + async pull(out) { + if (req.cancelled()) throw new CreateStopped('cancelled', plan.size); + const r = await reader.read(); + if (r.done) { + out.close(); + return; + } + read += r.value.length; + req.progress?.(1, read, all); + out.enqueue(r.value); + }, + cancel: (reason) => reader.cancel(reason), + }, + { highWaterMark: 0 }, + ); + }; + const sources = plan.files.map((f, i) => ({ path: f.path, size: f.size, ...(f.mtime === undefined ? {} : { mtime: f.mtime }), open: () => first(i) })); + let res: Awaited>; + try { + req.progress?.(1, 0, all); + res = await encryptFiles(sources, { + profile: quicknet(), + unlockAt: plan.requested, + policy: plan.policy, + recipients: plan.recipients, + newPortableKey: plan.portable, + ...(plan.comment === '' ? {} : { comment: plan.comment }), + ...(plan.author === '' ? {} : { author: plan.author }), + now: req.now ?? systemClock, + ...(req.output === undefined ? {} : { output: req.output.writable }), + progress: (written, total) => { + if (req.cancelled()) throw new CreateStopped('cancelled', total); + req.progress?.(2, written, total); + }, + }); + } catch (err) { + // A cancellation during the first reading comes back as the cause of + // the error of the file. + for (let e: unknown = err; e instanceof Error; e = e.cause) if (e instanceof CreateStopped) throw e; + throw err; + } const ms = performance.now() - start; let dkk: Uint8Array | undefined; if (res.portableKey !== undefined) { diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index 9967f1b..4f991ba 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -1,20 +1,38 @@