From e6cca0b20f8f9ddcd9f21abb83f2de10efa97f95 Mon Sep 17 00:00:00 2001
From: dev
Date: Thu, 1 Oct 2026 01:49:06 +0200
Subject: [PATCH] Format 3, step 8: /create redesigned, and always light
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The author found the pages too technical and did not want a dark
background. With the frontend design guidance:
- The site is always light, whatever the system asks.
- A capsule is a letter posted to the future: white paper, blue ink,
an old-style serif for the voice of the page (Palatino, Iowan) and a
DIN-like sans for the form (Bahnschrift), all system fonts, as the
CSP allows no other origin. Blue is what you act on.
- /create asks three questions, with short sentences: what it keeps,
when it opens, who can open it. The date it opens is set large on an
airmail envelope with the button that creates the capsule; quick
dates of 1, 5 and 10 years; sizes in KB and MB; everything technical
folded in "Detalles técnicos". The messages of the rules no longer
name R4 or §29.6.
Fixes of the review of the pages:
- /inspect labels the start of a single file as content of the
creator, unchecked, so that it cannot pass for a verdict; keeps ZWNJ
and ZWJ in the comment and the author, which R4b allows; and says
the right thing without files and without a comment.
- /create pins at most 500 more rows with problems and counts the
rest; shows what happened with a drop, not only to screen readers;
a comment or an author over its limit is its own problem, not that
of the files; a writing cannot start after the page is destroyed.
Co-Authored-By: Claude Opus 5.5
---
src/app.css | 90 ++-
src/app.html | 2 +-
src/lib/components/OpenPanel.svelte | 15 +-
src/lib/inspector/create-check.test.ts | 118 ++--
src/lib/inspector/create-check.ts | 89 ++-
src/lib/inspector/create-input.test.ts | 12 +-
src/lib/inspector/create-input.ts | 25 +-
src/lib/inspector/format.test.ts | 9 +
src/lib/inspector/format.ts | 8 +-
src/routes/create/+page.svelte | 887 +++++++++++++------------
10 files changed, 672 insertions(+), 583 deletions(-)
diff --git a/src/app.css b/src/app.css
index 1ff8711..27e69cc 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,22 +1,31 @@
-/* DateKeys inspector: the whole stylesheet.
-
- The palette is datekeys.com's: white paper, near-black ink and the brand
- orange. Here colour always means something: green is a passed step, red a
- rejection, orange a trust caveat (public data, informative views). Human
- words are set in the system sans; values read from a capsule (hex,
- identifiers, codes, CLI details) in the system monospace, never labels.
- No web fonts: the Content-Security-Policy allows nothing but this origin. */
+/* DateKeys: the whole stylesheet.
+ A capsule is a letter posted to the future, and the pages are set like
+ one: white paper, blue ink, the voice of the page in an old-style serif
+ and the form in a DIN-like sans, as a postal form; the stripes of an
+ airmail envelope appear once, around the date a capsule opens. Colour
+ always means something: blue is what you act on, green a passed step, red
+ a rejection, orange a trust caveat. Values read from a capsule (hex,
+ identifiers, codes) are in the system monospace, never labels. Every face
+ is a system font: the Content-Security-Policy allows nothing but this
+ origin. The page is always light. */
:root {
- color-scheme: light dark;
+ /* Always light, whatever the system asks: the author does not want a
+ dark page. */
+ color-scheme: light;
--paper: #ffffff;
- --paper-2: #f4f4f3;
- --ink: #0e0e0e;
- --ink-muted: #545454;
- --ink-faint: #6b6b6b;
- --rule: #e2e2e0;
- --rule-strong: #8f8f8c;
+ --paper-2: #f3f5f9;
+ --ink: #1c2b4b;
+ --ink-muted: #55627a;
+ --ink-faint: #6b7890;
+ --rule: #d8dde6;
+ --rule-strong: #7d889d;
+ /* The blue of what you act on, and the stripes of the airmail envelope. */
+ --accent: #1f4fa3;
+ --accent-bg: #eef3fb;
+ --airmail-blue: #1f4fa3;
+ --airmail-red: #c8323c;
--orange: #f18225;
/* The logo orange is a fill; this deeper orange carries text, at least
4.5:1 on the paper and on the caution tint. */
@@ -27,41 +36,24 @@
--fail: #b3261e;
--fail-bg: #fbecea;
- --sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif;
+ --sans: Bahnschrift, 'DIN Alternate', 'DIN 2014', 'Franklin Gothic Medium', 'Nimbus Sans Narrow', 'Segoe UI', system-ui, sans-serif;
+ --serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'URW Palladio L', P052, 'Book Antiqua', Georgia, serif;
--mono: ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
/* Type ramp, x1.2 from 16 px. */
--t-small: 0.875rem;
--t-body: 1rem;
--t-lead: 1.2rem;
- --t-h3: 1.2rem;
- --t-h2: 1.44rem;
- --t-date: clamp(1.6rem, 1.2rem + 1.6vw, 2.2rem);
- --t-h1: clamp(2rem, 1.4rem + 2.4vw, 3rem);
+ --t-h3: 1.3rem;
+ --t-h2: 1.7rem;
+ --t-date: clamp(2rem, 1.3rem + 2.6vw, 3rem);
+ --t-h1: clamp(2.3rem, 1.5rem + 3vw, 3.4rem);
--gut: 16px;
--measure: 68ch;
--radius: 4px;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --paper: #0c0c0b;
- --paper-2: #161615;
- --ink: #f2f1ef;
- --ink-muted: #b3b0ab;
- --ink-faint: #9a9791;
- --rule: #2a2a28;
- --rule-strong: #6d6a66;
- --orange-text: #f39a4e;
- --caution-bg: #26190c;
- --pass: #74d49c;
- --pass-bg: #0f2418;
- --fail: #ff8f85;
- --fail-bg: #2c1311;
- }
-}
-
@media (min-width: 720px) {
:root {
--gut: 32px;
@@ -99,13 +91,13 @@ h3,
h4 {
line-height: 1.2;
margin: 0;
- font-weight: 650;
- letter-spacing: -0.01em;
+ font-family: var(--serif);
+ font-weight: 400;
}
h1 {
font-size: var(--t-h1);
- letter-spacing: -0.025em;
+ line-height: 1.08;
}
h2 {
@@ -151,7 +143,7 @@ pre,
}
:focus-visible {
- outline: 2px solid var(--ink);
+ outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
@@ -221,17 +213,17 @@ button {
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.55rem 1.1rem;
- border: 1px solid var(--ink);
- border-radius: var(--radius);
- background: var(--ink);
+ border: 1px solid var(--accent);
+ border-radius: 8px;
+ background: var(--accent);
color: var(--paper);
- font-weight: 600;
+ font-weight: 500;
text-decoration: none;
cursor: pointer;
}
.button:hover {
- background: color-mix(in srgb, var(--ink) 86%, var(--paper));
+ background: color-mix(in srgb, var(--accent) 82%, var(--ink));
}
.button.quiet {
@@ -241,8 +233,8 @@ button {
}
.button.quiet:hover {
- border-color: var(--ink);
- background: var(--paper-2);
+ border-color: var(--accent);
+ background: var(--accent-bg);
}
/* The step rail, shared by the landing and the report: the steps of §63
diff --git a/src/app.html b/src/app.html
index cd840b7..634b56b 100644
--- a/src/app.html
+++ b/src/app.html
@@ -4,7 +4,7 @@
%sveltekit.head%
-
+
diff --git a/src/lib/components/OpenPanel.svelte b/src/lib/components/OpenPanel.svelte
index 277c2d7..9ebb4b7 100644
--- a/src/lib/components/OpenPanel.svelte
+++ b/src/lib/components/OpenPanel.svelte
@@ -548,16 +548,19 @@
{#if f.head.author !== ''}
autor declarado (texto del creador, sin comprobar):
Se muestra el principio del fichero, como mucho {formatInteger(SHOWN_TEXT)} caracteres.
{/if}
@@ -584,14 +587,14 @@
Están en un fichero temporal privado de este navegador. Se borra cuando lo pides, al abrir o cargar otra
cápsula y al salir de la página.
{:else}
- Están en la memoria de esta página, porque el navegador no le deja un fichero temporal privado. Se liberan
- al abrir o cargar otra cápsula y al salir de la página.
+ Están en la memoria de esta página: el navegador no le deja un fichero temporal privado, o no tiene sitio
+ para ellos. Se liberan al abrir o cargar otra cápsula y al salir de la página.
{/if}
{/if}
{/if}
{#if f.facts.length === 0}
-
La cápsula no guarda ficheros, solo el comentario.
+
{f.head.comment === '' ? 'La cápsula no guarda ficheros ni comentario.' : 'La cápsula no guarda ficheros, solo el comentario.'}
{:else}
Ficheros ({formatInteger(f.facts.length)})
diff --git a/src/lib/inspector/create-check.test.ts b/src/lib/inspector/create-check.test.ts
index fcd976f..8ce14a6 100644
--- a/src/lib/inspector/create-check.test.ts
+++ b/src/lib/inspector/create-check.test.ts
@@ -7,46 +7,46 @@ 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).';
+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.';
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).'],
+ ['', 'Escribe la ruta del fichero.'],
+ ['\ud800', 'La ruta tiene un carácter mal formado.'],
+ ['a'.repeat(1025), 'La ruta ocupa 1.025 bytes en UTF-8, más de 1.024.'],
+ [`${'a/'.repeat(32)}a`, 'La ruta tiene 33 niveles, más de 32.'],
+ ['a//b', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//».'],
+ ['/a', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//».'],
+ ['a'.repeat(256), `El nombre «${'a'.repeat(57)}…» ocupa 256 bytes en UTF-8, más de 255.`],
+ ['.', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba.'],
+ ['fotos/..', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba.'],
+ ['.\u200d', 'El nombre «.\\u200d», sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..».'],
+ ['\u0390'.repeat(127), `El nombre «${'\u0390'.repeat(57)}…», descompuesto en NFD como lo guarda macOS, mide 381 unidades UTF-16, más de 255.`],
+ ['a\u0001b', 'El nombre «a\\u0001b» lleva el carácter de control U+0001.'],
+ ['a:b', 'El nombre «a:b» lleva «:», que Windows no admite en un nombre.'],
+ ['x:y/b', 'La carpeta «x:y» lleva «:», que Windows no admite en un nombre.'],
+ ['a\u2028b', 'El nombre «a\\u2028b» lleva el separador U+2028.'],
+ ['a\u200bb', 'El nombre «a\\u200bb» lleva el carácter invisible U+200B, con el que dos nombres distintos se ven iguales.'],
+ ['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.'],
+ ['a\u0378', 'El nombre «a\\u0378» lleva U+0378, que Unicode 18.0.0 no asigna.'],
+ ['a\ufe0f', 'El nombre «a\ufe0f» lleva el selector de variante U+FE0F sin un emoji delante que lo admita.'],
+ ['\u200da', 'El nombre «\\u200da» empieza por el invisible U+200D.'],
+ ['a\u200c/b', 'La carpeta «a\\u200c» acaba en el invisible U+200C.'],
+ ['a\u200d\u200cb', 'El nombre «a\\u200d\\u200cb» lleva dos invisibles seguidos, U+200D y U+200C.'],
+ [' a', 'El nombre « a» empieza por un espacio.'],
+ ['a ', 'El nombre «a » acaba en un espacio, que Windows quita.'],
+ ['a.', 'El nombre «a.» acaba en punto, que Windows quita.'],
+ ['con.txt', 'El nombre «con.txt» usa CON, un nombre que Windows reserva para un dispositivo.'],
+ ['COM\u00b9.txt', 'El nombre «COM\u00b9.txt» usa COM\u00b9, un nombre que Windows reserva para un dispositivo.'],
+ ['nul .txt', 'El nombre «nul .txt» usa NUL, un nombre que Windows reserva para un dispositivo.'],
+ ['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.'],
+ ['a\u2236b', 'En la página de códigos 1250 de Windows, el nombre «a\u2236b» se convierte en un nombre con «:».'],
+ ['x\u00a5y', 'En la página de códigos 932 de Windows, el nombre «x\u00a5y» se convierte en un nombre con «\\».'],
+ ['\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.'],
+ ['\u3000a', 'En la página de códigos 1250 de Windows, el nombre «\\u3000a» se convierte en un nombre que incumple R5.'],
+ ['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.'],
+ ['.datekeys-x/a', 'La carpeta «.datekeys-x» empieza por «.datekeys-», que la CLI usa al abrir una cápsula.'],
+ ['.DATEKEYS-a', 'El nombre «.DATEKEYS-a» empieza por «.datekeys-», que la CLI usa al abrir una cápsula.'],
])('%j', (path, want) => {
expect(pathProblem(path)).toBe(want);
});
@@ -64,14 +64,14 @@ describe('checkPaths', () => {
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(['a', 'a/b']).problems).toEqual(['Choca con «a/b»: «a» sería a la vez un fichero y una carpeta.', 'Choca con «a»: «a» sería a la vez un fichero y una carpeta.']);
+ 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.', 'Choca con «x/y/z»: «x/y» sería a la vez un fichero y una carpeta.']);
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).',
+ 'El nombre «a:b» lleva «:», que Windows no admite en un nombre.',
+ 'El nombre «A:B» lleva «:», que Windows no admite en un nombre.',
undefined,
]);
expect(checkPaths(['fotos/a.jpg', 'fotos/b.jpg', 'fotos/2025/a.jpg', 'carta.txt'])).toEqual({ problems: [undefined, undefined, undefined, undefined] });
@@ -81,7 +81,7 @@ describe('checkPaths', () => {
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).');
+ expect(checkPaths([...paths, 'e/f']).overall).toBe('Las rutas forman 65.536 carpetas, más de 65.535.');
// A path with a problem does not count.
expect(checkPaths([...paths, 'e:/f']).overall).toBeUndefined();
});
@@ -126,16 +126,16 @@ 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).'],
+ ['\ud800', 'El comentario tiene un carácter mal formado.'],
+ ['x'.repeat(16385), 'El comentario ocupa 16.385 bytes en UTF-8, más de 16.384.'],
+ ['a\u0007', 'El comentario lleva el carácter de control U+0007.'],
+ ['a\u202e', 'El comentario lleva el control bidireccional U+202E, que cambia el orden en que se ve el texto.'],
+ ['a\u2029', 'El comentario lleva el separador U+2029.'],
+ ['\ufeffa', 'El comentario lleva la marca de orden de bytes U+FEFF.'],
+ ['a\ufdd0', 'El comentario lleva U+FDD0, que Unicode reserva como no carácter.'],
+ ['a\u200b', 'El comentario lleva el carácter invisible U+200B, con el que se puede esconder texto.'],
+ ['hola\n\u200dadiós', 'La línea 2 del comentario empieza por el invisible U+200D.'],
+ ['a\ufe0e', 'La línea 1 del comentario lleva el selector de variante U+FE0E sin un emoji delante que lo admita.'],
])('comment %j', (s, want) => {
expect(commentProblem(s)).toBe(want);
});
@@ -148,13 +148,13 @@ describe('the texts', () => {
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).'],
+ ['Ana\tB', 'El autor no puede llevar tabuladores ni saltos de línea.'],
+ ['Ana\nB', 'El autor no puede llevar tabuladores ni saltos de línea.'],
+ [' Ana', 'El autor no puede empezar ni acabar por un espacio.'],
+ ['x'.repeat(257), 'El autor ocupa 257 bytes en UTF-8, más de 256.'],
+ ['Ana\u200d', 'El autor acaba en el invisible U+200D.'],
+ ['A\u200fB', 'El autor lleva el control bidireccional U+200F, que cambia el orden en que se ve el texto.'],
+ ['\ud800', 'El autor tiene un carácter mal formado.'],
])('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
index 97cabbb..f82c633 100644
--- a/src/lib/inspector/create-check.ts
+++ b/src/lib/inspector/create-check.ts
@@ -53,11 +53,11 @@ export function checkPaths(paths: readonly string[]): PathsCheck {
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);
+ 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.`, 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);
+ collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: «${shown(prefix)}» sería a la vez un fichero y una carpeta.`, paths);
return;
} else if (!dir) {
collide(problems, n, old.path, () => 'Repite la ruta de otro fichero de la lista.', paths);
@@ -73,7 +73,7 @@ export function checkPaths(paths: readonly string[]): PathsCheck {
});
return {
problems,
- ...(dirs.size > MAX_IMPLICIT_DIRS ? { overall: `Las rutas forman ${formatInteger(dirs.size)} carpetas, más de ${formatInteger(MAX_IMPLICIT_DIRS)} (R9).` } : {}),
+ ...(dirs.size > MAX_IMPLICIT_DIRS ? { overall: `Las rutas forman ${formatInteger(dirs.size)} carpetas, más de ${formatInteger(MAX_IMPLICIT_DIRS)}.` } : {}),
};
}
@@ -113,10 +113,10 @@ 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).';
+ if (path === '') return 'Escribe la ruta del fichero.';
+ if (!path.isWellFormed()) return 'La ruta tiene un carácter mal formado.';
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).`;
+ if (n > MAX_PATH_LEN) return `La ruta ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(MAX_PATH_LEN)}.`;
try {
checkPath(path);
return undefined;
@@ -150,48 +150,47 @@ 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', /^(\d+) bytes, more than \d+$/, (who, m) => `${who} ocupa ${formatInteger(Number(m[1]))} bytes en UTF-8, más de ${MAX_SEGMENT_LEN}.`],
+ ['R3', /^the segment is (a dot|two dots)$/, () => '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba.'],
+ ['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',
/^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).`,
+ (who, m) => `${who}, descompuesto en NFD como lo guarda macOS, mide ${formatInteger(Number(m[1]))} unidades UTF-16, más de ${MAX_SEGMENT_UTF16}.`,
],
- ['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(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]}.`],
+ ['R4', new RegExp(`^character ${CODE_POINT}$`), (who, m) => `${who} lleva ${character(m[1]!)}, que Windows no admite en un nombre.`],
+ ['R4', new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]}.`],
+ ['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',
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).`,
+ (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', 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).`],
+ ['R4', new RegExp(`^unassigned ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode 18.0.0 no asigna.`],
+ ...placementRules().map((r) => ['R4b', ...r] as const),
+ ['R5', /^the segment starts with U\+0020$/, (who) => `${who} empieza por un espacio.`],
+ ['R5', /^the segment ends with U\+0020$/, (who) => `${who} acaba en un espacio, que Windows quita.`],
+ ['R5', /^the segment ends with '\.'$/, (who) => `${who} acaba en punto, que Windows quita.`],
+ ['R6', /^(.+) is a reserved device name$/, (who, m) => `${who} usa ${m[1]}, un nombre que Windows reserva para un dispositivo.`],
+ ['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.`],
[
'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).`,
+ (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', /^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).`],
+ ['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]}.`],
];
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[] {
+// R4b on a segment, or on a line of a text: its details.
+function placementRules(): 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}).`],
+ [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.`],
+ [new RegExp(`^${CODE_POINT} at the start$`), (who, m) => `${who} empieza por el invisible U+${m[1]}.`],
+ [new RegExp(`^${CODE_POINT} at the end$`), (who, m) => `${who} acaba en el invisible U+${m[1]}.`],
+ [new RegExp(`^${CODE_POINT} right after ${CODE_POINT}$`), (who, m) => `${who} lleva dos invisibles seguidos, U+${m[2]} y U+${m[1]}.`],
];
}
@@ -208,24 +207,24 @@ function explainPath(err: PathRuleError, segs: readonly string[]): string {
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).`;
+ ? 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//».'
+ : `La ruta tiene ${formatInteger(Number(count[1]))} niveles, más de ${MAX_SEGMENTS}.`;
}
/* 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).`;
+ return `${subject(segs, 0)} empieza por «.datekeys-», que la CLI usa al abrir una cápsula.`;
}
// 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).'],
+ [new RegExp(`^control ${CODE_POINT} in the declared author$`), () => 'El autor no puede llevar tabuladores ni saltos de línea.'],
+ [new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]}.`],
+ [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.`],
+ [new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]}.`],
+ [new RegExp(`^byte order mark ${CODE_POINT}$`), (who) => `${who} lleva la marca de orden de bytes U+FEFF.`],
+ [new RegExp(`^noncharacter ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode reserva como no carácter.`],
+ [new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que se puede esconder texto.`],
+ [/^the declared author starts or ends with U\+0020$/, () => 'El autor no puede empezar ni acabar por un espacio.'],
];
/** The problem of the comment of the form, or undefined; '' has none. */
@@ -240,9 +239,9 @@ export function authorProblem(s: string): string | undefined {
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).`;
+ if (!s.isWellFormed()) return `${who} tiene un carácter mal formado.`;
const n = utf8Length(s);
- if (n > max) return `${who} ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(max)} (§29.4).`;
+ if (n > max) return `${who} ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(max)}.`;
try {
check(s);
return undefined;
@@ -258,7 +257,7 @@ 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')) {
+ for (const [re, text] of placementRules()) {
const m = re.exec(line[2]!);
if (m !== null) return text(where, m);
}
diff --git a/src/lib/inspector/create-input.test.ts b/src/lib/inspector/create-input.test.ts
index b200a2d..a9fa2bd 100644
--- a/src/lib/inspector/create-input.test.ts
+++ b/src/lib/inspector/create-input.test.ts
@@ -79,7 +79,7 @@ describe('planCapsule', () => {
});
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).']);
+ expect(problem({ files: chooseFiles([]) })).toEqual(['files', 'Elige al menos un fichero, o escribe un mensaje en el comentario.']);
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);
@@ -90,11 +90,17 @@ describe('planCapsule', () => {
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).',
+ '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.',
]);
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).`]);
+ expect(problem({ files: huge })).toEqual(['files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - over)}, el máximo de una cápsula.`]);
+ });
+
+ it('leaves a comment or an author over its limit to its own field, not to the files', () => {
+ expect(planCapsule(input({ comment: 'x'.repeat(17 << 20) }), GENESIS_MS).ok).toBe(true);
+ const long = planCapsule(input({ author: 'y'.repeat(300) }), GENESIS_MS);
+ expect(long.ok && long.plan.length).toBe(bodyLength({ files: [{ path: 'nota.txt', size: 1000 }] }));
});
it('opens at the first round at or after the requested instant, to the millisecond', () => {
diff --git a/src/lib/inspector/create-input.ts b/src/lib/inspector/create-input.ts
index 24f3951..d5818fd 100644
--- a/src/lib/inspector/create-input.ts
+++ b/src/lib/inspector/create-input.ts
@@ -10,6 +10,7 @@ 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 { MAX_HEAD_LEN } from '../dkc/body.ts';
+import { utf8Length } from '../dkc/bytes.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';
@@ -18,6 +19,11 @@ import { MAX_CAPSULE_FILES } from './create-files.ts';
import { formatByteCount, formatInteger, safeFileName } from './format.ts';
import { localToEpochMs } from './localtime.ts';
+// The limits of the texts of a head, as pathrule.ts has them: that module
+// brings the Unicode tables, which the first load of the page does not.
+const MAX_COMMENT_BYTES = 16384;
+const MAX_AUTHOR_BYTES = 256;
+
/** An effective unlock time closer than this gets a notice: the capsule opens almost at once. */
export const SOON_MS = 3600_000;
@@ -69,9 +75,9 @@ export interface CapsulePlan {
/** The zone repeats the local time, and the later of its two instants was taken. */
readonly ambiguous: boolean;
readonly dateKey: DateKey;
- /** The DateKey as the dk1_ string (§17). */
+ /** The DateKey as the dk1_ string. */
readonly dk1: string;
- /** The effective unlock time: the time of the round, at or after the requested instant (§15). */
+ /** The effective unlock time: the time of the round, at or after the requested instant. */
readonly effective: Instant;
readonly effectiveMs: number;
readonly policy: Policy;
@@ -131,8 +137,13 @@ 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 });
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).');
+ // A comment or an author over its limit is the problem of its own field
+ // (create-check.ts), not of the files: the head is measured without it.
+ const texts = {
+ comment: utf8Length(headComment(input.comment)) <= MAX_COMMENT_BYTES ? input.comment : '',
+ author: utf8Length(input.author) <= MAX_AUTHOR_BYTES ? input.author : '',
+ };
+ if (measured.count === 0 && input.comment === '') return fail('files', 'Elige al menos un fichero, o escribe un mensaje en el comentario.');
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)}.`);
}
@@ -140,13 +151,13 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
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).`,
+ `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.`,
);
}
- // BODY holds the files with its frame, security area and head (§29.2).
+ // BODY holds the files with its frame, security area and head.
const length = bodyLengthOf(measured, texts);
if (length > MAX_PAYLOAD_LENGTH) {
- 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).`);
+ return fail('files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - measured.content))}, el máximo de una cápsula.`);
}
if (input.date === '') return fail('date', 'Elige el día de apertura.');
if (input.time === '') return fail('time', 'Elige la hora de apertura.');
diff --git a/src/lib/inspector/format.test.ts b/src/lib/inspector/format.test.ts
index 41b1c55..7fc9c9a 100644
--- a/src/lib/inspector/format.test.ts
+++ b/src/lib/inspector/format.test.ts
@@ -185,3 +185,12 @@ describe('cliJSON', () => {
expect(out).not.toMatch(/[<>&\u2028\u2029]/);
});
});
+
+describe('escapeInvisible of a checked text', () => {
+ it('keeps ZWNJ and ZWJ when asked, and escapes them otherwise', () => {
+ const zwj = String.fromCharCode(0x200d);
+ const zwnj = String.fromCharCode(0x200c);
+ expect(escapeInvisible(`a${zwj}b${zwnj}c`, true)).toBe(`a${zwj}b${zwnj}c`);
+ expect(escapeInvisible(`a${zwj}b`)).toBe(`a${String.fromCharCode(92)}u200db`);
+ });
+});
diff --git a/src/lib/inspector/format.ts b/src/lib/inspector/format.ts
index 89da5c4..c03b357 100644
--- a/src/lib/inspector/format.ts
+++ b/src/lib/inspector/format.ts
@@ -225,18 +225,20 @@ export function safeFileName(s: string): string {
/**
* A text for display in which every character that is not printable, except
- * line feeds and tabs, is written as a JSON escape (\uXXXX, a surrogate pair
+ * line feeds and tabs, and with `joiners` ZWNJ and ZWJ, which a text of a
+ * head already checked may hold where R4b allows them (emoji, Persian,
+ * Indic scripts), is written as a JSON escape (\uXXXX, a surrogate pair
* above U+FFFF). The library's messages quote most input with Go's %q, but
* some carry it raw (an extension_id in "extension v1"), and a bidi
* override there would reorder the rest of the line on screen. The escapes
* keep JSON valid and equivalent, so the CLI JSON can be shown this way too;
* copies always take the exact text.
*/
-export function escapeInvisible(s: string): string {
+export function escapeInvisible(s: string, joiners = false): string {
let out = '';
for (const ch of s) {
const r = ch.codePointAt(0)!;
- if (r === 0x09 || r === 0x0a || isPrintableRune(r)) out += ch;
+ if (r === 0x09 || r === 0x0a || isPrintableRune(r) || (joiners && (r === 0x200c || r === 0x200d))) out += ch;
else for (let i = 0; i < ch.length; i++) out += `\\u${ch.charCodeAt(i).toString(16).padStart(4, '0')}`;
}
return out;
diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte
index e28762d..1322246 100644
--- a/src/routes/create/+page.svelte
+++ b/src/routes/create/+page.svelte
@@ -15,7 +15,6 @@
import { beforeNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import { type Policy, SPEC_VERSION, TIME_AND_KEY, TIME_ONLY } from '$lib/dkc/index.ts';
- import { mtimeSeconds } from '$lib/dkc/lengths.ts';
import {
addPicked,
droppedFiles,
@@ -143,6 +142,15 @@
const chosen = $derived(chooseFiles(headFiles(entries, withMtime)));
const summary = $derived(summarize(entries));
const shown = $derived(entries.filter((e, i) => i < LISTED || pinned.has(e.id)));
+ // The problems of the rows that the list does not show.
+ const unseen = $derived.by(() => {
+ if (pathCheck === undefined) return 0;
+ const ids = new Set(shown.map((e) => e.id));
+ return [...pathCheck.problems.keys()].filter((id) => !ids.has(id)).length;
+ });
+ // What happened with the last files chosen or dropped, shown by the list:
+ // the status line only reaches screen readers.
+ let notice = $state('');
const planned = $derived(planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable }, nowMs));
const plan = $derived(planned.ok ? planned.plan : undefined);
const commentCheck = $derived(checker?.commentProblem(comment));
@@ -178,9 +186,12 @@
const run = (): void => {
pathCheck = checkList(c, list);
const index = new Map(untrack(() => entries).map((e, i) => [e.id, i]));
- const beyond = [...pathCheck.problems.keys()].filter((id) => index.get(id)! >= LISTED);
const shownBefore = untrack(() => pinned);
- if (beyond.some((id) => !shownBefore.has(id))) pinned = new Set([...shownBefore, ...beyond]);
+ // At most LISTED more rows: a folder whose name breaks a rule gives a
+ // problem to every file below it.
+ const room = Math.max(0, LISTED - shownBefore.size);
+ const beyond = [...pathCheck.problems.keys()].filter((id) => index.get(id)! >= LISTED && !shownBefore.has(id)).slice(0, room);
+ if (beyond.length > 0) pinned = new Set([...shownBefore, ...beyond]);
};
if (list.length <= LONG_LIST) {
run();
@@ -341,7 +352,7 @@
// Adds what the person chose to the list, and says what happened.
function add(picked: readonly Picked[], what: string): void {
if (picked.length === 0) {
- announcement = `${what} no tiene ficheros: una cápsula no guarda carpetas vacías.`;
+ notice = announcement = `${what} no tiene ficheros: una cápsula no guarda carpetas vacías.`;
return;
}
const r = addPicked(entries, picked, () => ++nextId);
@@ -355,6 +366,7 @@
]
.filter((x) => x !== '')
.join(' ');
+ notice = r.replaced === 0 && system === 0 ? '' : announcement;
}
// The files of an input, which the browser has already listed: all of
@@ -384,6 +396,7 @@
function clearFiles(): void {
entries = [];
+ notice = '';
pinned = new Set();
pathCheck = undefined;
announcement = 'Lista vaciada.';
@@ -432,7 +445,7 @@
try {
add(roots.length > 0 ? await droppedFiles(roots) : pickedFiles(plain), 'Lo que has soltado');
} catch (err) {
- announcement =
+ notice = announcement =
err instanceof TooManyFiles
? tooMany()
: `No se pudo leer lo que has soltado: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`;
@@ -446,6 +459,8 @@
if (busy || reading) return;
problem = undefined;
problemField = undefined;
+ // The page destroyed while the rules load must not start a writing.
+ const epoch = createId;
let c: Checker;
try {
c = await loadChecker();
@@ -453,7 +468,7 @@
await fail(`No se pudieron cargar las reglas de las rutas: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
return;
}
- if (busy || reading) return;
+ if (epoch !== createId || busy || reading) return;
// The clock is read again: the date must still be ahead of it. The
// fields are checked in the order of the form, with the rules of the
// reader for the paths and the texts.
@@ -628,14 +643,41 @@
function contents(p: CapsulePlan): string {
const n = p.files.length;
const bytes = p.files.reduce((sum, f) => sum + f.size, 0);
- const files = n === 0 ? 'ningún fichero' : `${n === 1 ? 'un fichero' : `${formatInteger(n)} ficheros`} (${formatByteCount(bytes)})`;
- const parts = [files, ...(p.comment === '' ? [] : ['un comentario']), ...(p.author === '' ? [] : ['el autor declarado'])];
+ const files = n === 0 ? 'Ningún fichero' : `${n === 1 ? 'Un fichero' : `${formatInteger(n)} ficheros`} (${size(bytes)})`;
+ const parts = [files, ...(p.comment === '' ? [] : ['un mensaje']), ...(p.author === '' ? [] : ['el autor'])];
return parts.length === 1 ? files : `${parts.slice(0, -1).join(', ')} y ${parts.at(-1)}`;
}
- // The date of a file, as the list shows it when the capsule keeps it.
- function fileDate(f: File): string {
- return mtimeSeconds(f.lastModified) === undefined ? 'sin fecha: la del sistema cae fuera de 1970 a 9999' : formatDateTime(f.lastModified);
+ // A size as people read it, in powers of 1000; the exact bytes are in the
+ // technical details.
+ const decimal = new Intl.NumberFormat('es-ES', { maximumFractionDigits: 1 });
+ function size(n: number): string {
+ if (n < 1000) return n === 1 ? '1 byte' : `${n} bytes`;
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let v = n / 1000;
+ let u = 0;
+ for (; v >= 1000 && u < units.length - 1; u++) v /= 1000;
+ return `${decimal.format(v)} ${units[u]}`;
+ }
+
+ // The quick dates of the form: the same day, `years` later, at noon in
+ // the zone of the form; 29 February becomes the 28th in a common year.
+ function inYears(years: number): void {
+ const [y, m, d] = localParts(nowMs === 0 ? Date.now() : nowMs, timeZone).date.split('-').map(Number) as [number, number, number];
+ const year = y + years;
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+ const day = m === 2 && d === 29 && !leap ? 28 : d;
+ date = `${String(year).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ time = '12:00';
+ }
+
+ // The date on the envelope, in the zone of the form: the day, and the hour
+ // with the name of the zone.
+ function envelopeDate(ms: number, zone: string): { day: string; hour: string } {
+ return {
+ day: new Intl.DateTimeFormat('es-ES', { day: 'numeric', month: 'long', year: 'numeric', timeZone: zone }).format(ms),
+ hour: new Intl.DateTimeFormat('es-ES', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone, timeZoneName: 'short' }).format(ms),
+ };
}
// The credentials of a time_and_key capsule, in words.
@@ -659,28 +701,27 @@
- Crear una cápsula DateKeys
+ Crear una cápsula del tiempo con DateKeys
-
-
Crear una cápsula
+
+
Crea una cápsula del tiempo
- Cifra ficheros, carpetas y un mensaje en una cápsula .dkc que nadie puede abrir antes de la fecha que elijas:
- su clave depende de la firma que la red drand publicará en esa fecha. Todo se cifra en este navegador y no sale de él; la
- página no se conecta a ningún otro sitio.
+ Guarda ficheros y un mensaje que nadie podrá abrir antes de la fecha que elijas, ni siquiera tú. Todo ocurre en este
+ navegador: nada sale de tu equipo.
-
+
+ {size(e.file.size)}
{/each}
{#if shown.length < entries.length}
- La lista muestra {formatInteger(shown.length)} de {formatInteger(entries.length)} ficheros: los {formatInteger(LISTED)} primeros
- y los que han tenido un problema. La cápsula guarda todos los marcados.
+ Se ven {formatInteger(shown.length)} de {formatInteger(entries.length)} ficheros: los {formatInteger(LISTED)} primeros y
+ los que han tenido algún problema. La cápsula guarda todos los marcados.
+
+ {/if}
+ {#if unseen > 0}
+
+ {unseen === 1 ? 'Hay 1 nombre más' : `Hay ${formatInteger(unseen)} nombres más`} con problemas que no caben en la lista.
+ Cámbialos en tu equipo y vuelve a añadir la carpeta.
- Cada fichero se guarda con su ruta, que puedes cambiar: las carpetas elegidas ponen su nombre delante. Las rutas, las
- fechas y el comentario van cifrados con los ficheros, y nadie los ve antes de la fecha. Las carpetas vacías no se
- guardan, y los ficheros que crean los sistemas (.DS_Store, Thumbs.db, desktop.ini,
- ._* y __MACOSX) quedan fuera, tachados, salvo que los marques.
+ Cada fichero conserva su nombre y su carpeta. Si un nombre no vale en Windows o en macOS, lo verás marcado y podrás
+ cambiarlo. Los ficheros que crea el sistema, como .DS_Store, quedan fuera salvo que los marques.
-
+
-
-
+
+
{authorCheck}
{/if}
- Un nombre cualquiera, de hasta 256 bytes, que la cápsula no comprueba: quien la abra lo verá como texto del creador, sin
- comprobar.
+ Es solo un nombre: la cápsula no comprueba quién la escribió.
-
2. La fecha de apertura
+
¿Cuándo se abre?
+
+ {#each [1, 5, 10] as years (years)}
+
+ {/each}
+
@@ -877,241 +925,176 @@
-
- La cápsula no guarda la zona: fija el instante UTC que corresponde a esa hora con las reglas de la zona que conoce hoy
- este navegador.
-
+
La hora se entiende en esa zona. La cápsula solo guarda el instante.
-
-
4. Antes de cifrar
+
+
Antes de crear
+ {#if plan}
+ {@const when = envelopeDate(plan.effectiveMs, timeZone)}
+
Se abrirá el
+
{when.day}
+
a las {when.hour}{plan.soon ? `, ${formatRelative(plan.effectiveMs, nowMs)}` : ''}
+
{contents(plan)}. La cápsula ocupará {size(plan.size)}.
+ {:else}
+
Todavía falta algo
+
{planned.ok ? '' : planned.problem}
+ {/if}
+
+
+ {#if busy}
+
+ {/if}
+
+ {#if busy}
+
+
+
+ {pass === 1 ? 'Leyendo los ficheros' : 'Cifrando y guardando'}: {percent(written, total)}, {size(written)} de {size(total)}.
+
+
+ {/if}
+
+
+ {#if problem}
+
{problem}
+ {/if}
+
+
+
DateKeys {SPEC_VERSION} es una versión de prueba: una versión futura podría no abrir esta cápsula.
+ {#if plan?.longHorizon}
+
+ Falta más de un año. Para abrirla hará falta la firma que la red drand publique ese día, y si nadie la conserva, la
+ cápsula no se abrirá. Su cifrado tampoco resiste un ordenador cuántico futuro.
+
+ {/if}
+
+
+
+ Detalles técnicos
{#if plan}
-
Se abrirá
+
Instante de apertura
- {formatDateTime(plan.effectiveMs, timeZone)}
- ({formatDateTime(plan.effectiveMs, UTC)})
+ {formatDateTime(plan.effectiveMs, UTC)}, el de la ronda {plan.dateKey.round} de Quicknet, la red de drand.
{#if plan.effectiveMs !== plan.requestedMs}
-
- Es la hora de la ronda: {seconds(plan.effectiveMs - plan.requestedMs)} s después de la pedida, porque drand
- firma una ronda cada 3 s (§15).
-
+ Cae {seconds(plan.effectiveMs - plan.requestedMs)} s después de la hora pedida, porque drand firma una ronda cada 3 s
+ (§15).
{/if}
{#if plan.ambiguous}
- Esa hora se repite ese día en {timeZone}, al atrasar los relojes: vale la segunda.
+ Esa hora se repite ese día en {timeZone}, al atrasar los relojes: vale la segunda.
{/if}
-
-
Ronda
-
{plan.dateKey.round} de Quicknet, la red de drand
-
DateKey
{plan.dk1}
-
Ahora, en este dispositivo
+
Reloj de este dispositivo
- {formatDateTime(nowMs, timeZone)} ({formatDateTime(nowMs, UTC)})
-
- Si este reloj va atrasado, la cápsula podría sellarse para una ronda ya publicada, que cualquiera con el
- .dkc abriría enseguida. La página no pregunta la hora a ningún servidor.
-
+ {formatDateTime(nowMs, UTC)}. Si va atrasado, la cápsula podría sellarse para una ronda ya publicada, y se abriría
+ enseguida: la página no pregunta la hora a ningún servidor.
-
Contenido
-
{contents(plan)}
-
-
-
Tamaño del .dkc
-
{formatByteCount(plan.size)}
+
Tamaño
+
+ {formatByteCount(plan.size)}: formato 3, con el contenido, sus nombres y sus fechas ({formatByteCount(plan.length)})
+ rellenados con ceros hasta {formatByteCount(plan.paddedLength)} (relleno reforzado, §29.1).
+
-
Qué deja ver hasta la fecha
+
Qué se ve antes de la fecha
- La fecha de apertura, la política, el capsule_id y ese tamaño, que da el del contenido de forma
- aproximada: los ficheros, sus rutas y el comentario, con un margen de 256 bytes hasta 8 KiB, y de menos de un 6,25 %
- por encima. No deja ver el tamaño exacto, cuántos ficheros guarda ni cuántas credenciales la abren, que no es lo
- mismo que cuántas personas; esto último se apoya en Diffie–Hellman, que no resiste un adversario cuántico futuro,
- como el resto del cifrado (§55.2, §53).
+ La fecha de apertura, el tipo de cápsula, su capsule_id y ese tamaño, que da el del contenido de forma
+ aproximada. No se ve cuántos ficheros guarda, salvo un máximo que se deduce de ese tamaño, ni cuántas llaves la abren
+ (§55.2, §53).
- {:else if !planned.ok}
-
{planned.problem}
{/if}
-
-
-
- Protocolo preliminar: DateKeys {SPEC_VERSION} todavía no es la versión 1.0, y hasta entonces pueden cambiar los esquemas
- de la cápsula (§74), así que una versión futura podría no abrir esta cápsula.
-
- {#if plan?.longHorizon}
-
- Aviso: el cifrado por tiempo de Quicknet V1 no es poscuántico. El texto cifrado puede seguir guardado durante años, y
- su confidencialidad futura depende del proveedor y de la criptografía en que se basa.
-
-
- Para abrirla hará falta el release de su ronda, que publica la red drand. Si en esa fecha ningún relay ni ninguna copia
- conservada lo ofrece, la cápsula no podrá abrirse.
-
- {/if}
- {#if plan?.soon}
-
- La cápsula se podrá abrir {formatRelative(plan.effectiveMs, nowMs)}: desde entonces la abrirá quien tenga el
- .dkc{plan.policy === TIME_AND_KEY ? ' y una de sus credenciales' : ''}.
-
- {/if}
-
-
-
- {#if problem}
-
{problem}
- {/if}
-
-
-
- {#if busy}
-
- {/if}
-
- {#if busy}
-
-
-
- {pass === 1 ? 'Paso 1 de 2, leer los ficheros para su SHA-256' : 'Paso 2 de 2, cifrar los ficheros y escribir el .dkc'}:
- {percent(written, total)}, {formatByteCount(written)} de {formatByteCount(total)}.
-
-
- {:else}
- Los ficheros se leen dos veces: primero para calcular el SHA-256 de cada uno, y después para cifrarlos. Si uno cambia
- entre las dos lecturas, no se crea la cápsula. El .dkc se escribe en un fichero temporal privado de este
- navegador, que solo se da por bueno cuando la cápsula está completa y comprobada. Se borra cuando lo pides, al crear otra y
- al salir de la página; sin ese fichero, se escribe en la memoria de la página hasta 64 MiB. Todo corre en esta pestaña:
- con ficheros grandes, la página puede ir lenta mientras cifra.
+ Los ficheros se leen dos veces: primero para calcular el SHA-256 de cada uno, y después para cifrarlos. Si uno cambia entre
+ las dos lecturas, no se crea la cápsula. El .dkc se escribe en un fichero temporal privado de este navegador,
+ que solo se da por bueno cuando la cápsula está completa y comprobada, y se borra al crear otra o al salir de la página.
+ Sin ese fichero, se escribe en la memoria de la página, hasta 64 MiB.
- {/if}
+
{announcement}
{#if result}
{@const r = result}
+ {@const when = envelopeDate(r.plan.effectiveMs, timeZone)}
-
-
Cápsula creada
-
- Se abrirá el {formatDateTime(r.plan.effectiveMs, timeZone)} ({formatDateTime(r.plan.effectiveMs, UTC)}), con la
- ronda {r.plan.dateKey.round}. Cifrada y comprobada en {seconds(r.ms)} s. A partir de esa fecha se abre en el
- inspector o con datekeys decrypt.
+
+
Cápsula cerrada. Se abrirá el
+
{when.day}
+
a las {when.hour}
+
+ {contents(r.plan)}. Desde esa fecha se abre en el inspector o con
+ datekeys decrypt.
-
-
-
capsule_id
-
{r.capsuleId}
-
-
-
Política
-
{r.plan.policy === TIME_AND_KEY ? `fecha y clave, con ${credentials(r.plan)}` : 'solo fecha'}
-
-
-
Contenido
-
{contents(r.plan)}
-
-
-
Tamaño
-
- {formatByteCount(r.capsule.size)}: formato 3, con el contenido, sus rutas y sus fechas ({formatByteCount(r.plan.length)})
- rellenados con ceros hasta {formatByteCount(r.plan.paddedLength)} (relleno reforzado, §29.1)
-
-
-
-
La cápsula (.dkc)
+
La cápsula
{#if capsuleGone}
{r.temporary
@@ -1122,13 +1105,10 @@
-
- El nombre se ve junto al fichero: el propuesto solo repite la fecha de apertura, que la cápsula ya muestra, y no
- dice cuándo se creó.
-
+
El nombre propuesto solo repite la fecha de apertura, que la cápsula ya muestra.
- Guarda esta clave en secreto: quien la tenga podrá abrir la cápsula desde la fecha. Solo sirve para esta cápsula.
+ Guárdala en secreto: quien la tenga podrá abrir la cápsula desde la fecha. Solo sirve para esta cápsula.
{#if r.keyOnly}
- Es la única credencial: sin ella, nadie podrá abrir la cápsula, tampoco tú.
+ Es la única llave: sin ella nadie podrá abrir la cápsula, tampoco tú.
{/if}
@@ -1158,22 +1138,39 @@
-
-
+
+
- Está solo en la memoria de esta página, nunca en el almacenamiento del navegador. Se borra cuando pulsas «Olvidar la
- clave», al crear otra cápsula y al salir de la página. Suele convenir enviarla por otro camino que el .dkc.
+ Está solo en la memoria de esta página. Se borra al pulsar «Olvidar la llave», al crear otra cápsula y al salir de la
+ página. Conviene enviarla por otro camino que la cápsula.
{:else}
- La clave ya no está en la página.{keySaved ? '' : ' No se descargó: si no hay otra credencial, esta cápsula no podrá abrirse.'}
+ La llave ya no está en la página.{keySaved ? '' : ' No se descargó: si no hay otra, esta cápsula no podrá abrirse.'}