The page opens capsules of format 3. The files go to the temporary file through a ZipSink, a lone file of one segment as it is and a ZIP otherwise, or to memory; the page shows the verdicts first, then the declared author and the comment as unchecked text of the creator, and then each path as text in a bdi, with its size, its mtime and the warnings of the CLI of the reference, compared by their key of R7. - files.ts: pathWarnings, fileFacts, and the names and order of the downloads: the file itself when it is the only one, with the ZIP of its folder second (decision 8), or the ZIP and each file. - opener.ts: OpenedFiles, and noRoom when the ZIP does not fit. - zipsink.ts: NoRoom, thrown by begin before writing anything. - check-build.mjs: the tables of pathrule-tables.ts never come with the first load of a page, and do come with the code on demand of /inspect and /create. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
ee82f4382f
commit
651178a740
@ -0,0 +1,65 @@
|
||||
// Tests of files.ts: the warnings of the paths, compared by their key of R7
|
||||
// in the words of the CLI of the reference, the files as the page shows
|
||||
// them, and the names and the order of the downloads.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Head } from '../dkc/head.ts';
|
||||
import { downloadFileName, downloads, fileFacts, pathWarnings, zipFileName } from './files.ts';
|
||||
|
||||
const GIT_INSIDE = 'está dentro de una carpeta .git, cuyos ganchos pueden ejecutar órdenes';
|
||||
const GIT = 'se llama .git y puede apuntar a otro repositorio';
|
||||
const INI = 'es la configuración de una carpeta de Windows';
|
||||
const DASH = "un nombre que empieza por '-' puede tomarse por una opción en una orden";
|
||||
const SHORTCUT = 'es un acceso directo de Windows: puede abrir otro programa o una dirección';
|
||||
const PROGRAM = 'es un programa o un script: no lo ejecutes sin saber qué hace';
|
||||
|
||||
const file = (path: string, size = 1, mtime?: number) => ({ path, size, start: 0, end: size, sha256: new Uint8Array(32), ...(mtime === undefined ? {} : { mtime }) });
|
||||
const head = (...paths: string[]): Head => ({ salt: new Uint8Array(32), comment: '', author: '', files: paths.map((p) => file(p)), critical: [], noncritical: [] });
|
||||
|
||||
describe('pathWarnings', () => {
|
||||
it.each([
|
||||
['nota.txt', []],
|
||||
['.bashrc', []],
|
||||
['.git/config', [GIT_INSIDE]],
|
||||
['repo/.GIT', [GIT]],
|
||||
['repo/.g\u200dit', [GIT]],
|
||||
['Desktop.INI', [INI]],
|
||||
['fotos/-rf', [DASH]],
|
||||
['Informe.LNK', [SHORTCUT]],
|
||||
['enlaces/web.url', [SHORTCUT]],
|
||||
['b\u00fasqueda.searchConnector-ms', [SHORTCUT]],
|
||||
['x/instalar.EXE', [PROGRAM]],
|
||||
['a.b.ps1', [PROGRAM]],
|
||||
['Programa.app', [PROGRAM]],
|
||||
['-x/.git/y.bat', [DASH, GIT_INSIDE, PROGRAM]],
|
||||
] as const)('%s', (path, want) => {
|
||||
expect(pathWarnings(path)).toEqual(want);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileFacts', () => {
|
||||
it('shows each path as text, escaped, with its name, size, mtime in milliseconds and warnings', () => {
|
||||
const h: Head = { ...head(), files: [file('docs/a\u202eb.txt', 3, 1_790_769_600), file('Informe.lnk', 0)] };
|
||||
expect(fileFacts(h)).toEqual([
|
||||
{ path: 'docs/a\u202eb.txt', shown: 'docs/a\\u202eb.txt', name: 'a_b.txt', size: 3, mtimeMs: 1_790_769_600_000, warnings: [] },
|
||||
{ path: 'Informe.lnk', shown: 'Informe.lnk', name: 'Informe.lnk', size: 0, warnings: [SHORTCUT] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the downloads', () => {
|
||||
it('names a file by its last segment, made safe, and the ZIP by the common folder or by the .dkc', () => {
|
||||
expect(downloadFileName('a/b/c.txt')).toBe('c.txt');
|
||||
expect(zipFileName(head('fotos/a.jpg', 'fotos/2025/b.jpg'), 'capsula-20260930T120000Z.dkc')).toBe('fotos.zip');
|
||||
expect(zipFileName(head('fotos/a.jpg', 'docs/b.txt'), 'capsula-20260930T120000Z.dkc')).toBe('capsula-20260930T120000Z.zip');
|
||||
expect(zipFileName(head('a.txt', 'b.txt'), 'mia.DKC')).toBe('mia.zip');
|
||||
expect(zipFileName(head('fotos/a.jpg', 'b.txt'), 'sin extension')).toBe('sin extension.zip');
|
||||
});
|
||||
|
||||
it('offers nothing without files, the file itself when it is the only one, with the ZIP of its folder second, and the ZIP of several with each file', () => {
|
||||
expect(downloads(head(), 'none', 'c.dkc')).toEqual({ each: false });
|
||||
expect(downloads(head('nota.txt'), 'file', 'c.dkc')).toEqual({ primary: { kind: 'file', index: 0, name: 'nota.txt' }, each: false });
|
||||
expect(downloads(head('informes/2026.pdf'), 'zip', 'c.dkc')).toEqual({ primary: { kind: 'file', index: 0, name: '2026.pdf' }, zip: 'informes.zip', each: false });
|
||||
expect(downloads(head('a.txt', 'b/c.txt'), 'zip', 'c.dkc')).toEqual({ primary: { kind: 'zip', name: 'c.zip' }, each: true });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,133 @@
|
||||
// What the page shows of the files of a format 3 capsule once it opened
|
||||
// (design of format 3, sections 3 to 5): each path as text, its warnings,
|
||||
// and the downloads. It compares keys of R7, so it brings the Unicode tables
|
||||
// of pathrule.ts, which the first load of the page does not carry: the page
|
||||
// loads it on demand, with the opening. The warnings are those of the CLI of
|
||||
// the reference (risks of cmd/datekeys), in its words.
|
||||
|
||||
import type { Head } from '../dkc/head.ts';
|
||||
import { pathKey } from '../dkc/pathrule.ts';
|
||||
import { escapeInvisible, safeFileName } from './format.ts';
|
||||
import type { ZipMode } from './zipsink.ts';
|
||||
|
||||
const keys = (...names: string[]): Set<string> => new Set(names.map(pathKey));
|
||||
const SHORTCUTS = keys('.lnk', '.url', '.library-ms', '.searchConnector-ms');
|
||||
const PROGRAMS = keys(
|
||||
'.exe',
|
||||
'.com',
|
||||
'.bat',
|
||||
'.cmd',
|
||||
'.scr',
|
||||
'.pif',
|
||||
'.msi',
|
||||
'.msp',
|
||||
'.cpl',
|
||||
'.hta',
|
||||
'.jar',
|
||||
'.js',
|
||||
'.jse',
|
||||
'.vbs',
|
||||
'.vbe',
|
||||
'.wsf',
|
||||
'.wsh',
|
||||
'.ps1',
|
||||
'.psm1',
|
||||
'.reg',
|
||||
'.sh',
|
||||
'.command',
|
||||
'.app',
|
||||
);
|
||||
const DESKTOP_INI = pathKey('desktop.ini');
|
||||
const GIT = pathKey('.git');
|
||||
|
||||
/**
|
||||
* The warnings of a path: a Windows shortcut or folder setting, a .git
|
||||
* folder, a program, or a segment that starts with '-', compared by their
|
||||
* key of R7, so that ".GIT" or "Informe.LNK" warn too (spec §29.7).
|
||||
*/
|
||||
export function pathWarnings(path: string): string[] {
|
||||
const out: string[] = [];
|
||||
const segs = path.split('/');
|
||||
segs.forEach((s, i) => {
|
||||
const k = pathKey(s);
|
||||
if (k === GIT && i < segs.length - 1) out.push('está dentro de una carpeta .git, cuyos ganchos pueden ejecutar órdenes');
|
||||
else if (k === GIT) out.push('se llama .git y puede apuntar a otro repositorio');
|
||||
else if (k === DESKTOP_INI) out.push('es la configuración de una carpeta de Windows');
|
||||
if (s.startsWith('-')) out.push("un nombre que empieza por '-' puede tomarse por una opción en una orden");
|
||||
});
|
||||
const last = segs.at(-1)!;
|
||||
const dot = last.lastIndexOf('.');
|
||||
if (dot > 0) {
|
||||
const ext = pathKey(last.slice(dot));
|
||||
if (SHORTCUTS.has(ext)) out.push('es un acceso directo de Windows: puede abrir otro programa o una dirección');
|
||||
else if (PROGRAMS.has(ext)) out.push('es un programa o un script: no lo ejecutes sin saber qué hace');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A file of the head as the page shows it. */
|
||||
export interface FileFacts {
|
||||
/** The path as the head stores it, for the downloads. */
|
||||
readonly path: string;
|
||||
/** The path to show: its invisible characters escaped. */
|
||||
readonly shown: string;
|
||||
/** The name under which it is saved on its own (downloadFileName). */
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
/** The mtime of the head, in milliseconds, when it has one. It proves nothing. */
|
||||
readonly mtimeMs?: number;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
/** The files of a head as the page shows them, in its order. */
|
||||
export function fileFacts(head: Head): FileFacts[] {
|
||||
return head.files.map((f) => ({
|
||||
path: f.path,
|
||||
shown: escapeInvisible(f.path),
|
||||
name: downloadFileName(f.path),
|
||||
size: f.size,
|
||||
...(f.mtime === undefined ? {} : { mtimeMs: f.mtime * 1000 }),
|
||||
warnings: pathWarnings(f.path),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The name under which a file of the capsule is saved: its last segment, made safe (safeFileName). */
|
||||
export function downloadFileName(path: string): string {
|
||||
return safeFileName(path.split('/').at(-1)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the ZIP of the files: `<first segment>.zip` when every path
|
||||
* starts with the same folder, and otherwise the name of the .dkc with .zip
|
||||
* instead of its extension (design of format 3, section 3).
|
||||
*/
|
||||
export function zipFileName(head: Head, dkcName: string): string {
|
||||
const firsts = new Set(head.files.map((f) => (f.path.includes('/') ? f.path.split('/')[0]! : undefined)));
|
||||
const [only] = firsts;
|
||||
const stem = firsts.size === 1 && only !== undefined ? only : dkcName.replace(/\.dkc$/i, '');
|
||||
return `${safeFileName(stem)}.zip`;
|
||||
}
|
||||
|
||||
/** What the page offers to download, and in which order. */
|
||||
export interface Downloads {
|
||||
/** The main download: the file itself, index of the head, or the ZIP. */
|
||||
readonly primary?: { readonly kind: 'file'; readonly index: number; readonly name: string } | { readonly kind: 'zip'; readonly name: string };
|
||||
/** The ZIP, second, when the main download is the one file inside a folder (decision 8). */
|
||||
readonly zip?: string;
|
||||
/** Whether each file is offered on its own too, besides the ZIP. */
|
||||
readonly each: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The downloads of the files of a head laid out in `mode`: nothing without
|
||||
* files; the file itself when it is the only one, even inside a folder, with
|
||||
* the ZIP of the folder second; and the ZIP of all of them otherwise, with
|
||||
* each file on its own.
|
||||
*/
|
||||
export function downloads(head: Head, mode: ZipMode, dkcName: string): Downloads {
|
||||
if (mode === 'none') return { each: false };
|
||||
const file = { kind: 'file', index: 0, name: downloadFileName(head.files[0]!.path) } as const;
|
||||
if (mode === 'file') return { primary: file, each: false };
|
||||
if (head.files.length === 1) return { primary: file, zip: zipFileName(head, dkcName), each: false };
|
||||
return { primary: { kind: 'zip', name: zipFileName(head, dkcName) }, each: true };
|
||||
}
|
||||
Loading…
Reference in new issue