single-file-cli 2.9.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ /* global URL */
2
+
3
+ // --max-appended-data-length caps the bytes a self-extracting page leaves after the ZIP end of
4
+ // central directory record. A recovery payload over the cap is relocated ahead of the compressed
5
+ // data instead, which is what makes the file readable by a reader whose end-of-archive scan window
6
+ // is narrow (16383 bytes for libarchive, against 65557 for Python zipfile).
7
+ //
8
+ // The assertion that matters is that the switch REACHES core at all. --declare-appended-data once
9
+ // shipped inert because core filtered the options it forwarded through a hand-written whitelist,
10
+ // and the CLI has an explicit list of its own on the multi-page path, so an option can be declared,
11
+ // parsed, documented and still do nothing. A capture at each end of the range is the cheap proof.
12
+
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { createServer } from "node:http";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import process from "node:process";
22
+ const { configure, ZipReader, Uint8ArrayReader } = await importLibModule("single-file-archive.js");
23
+ import { cliDirectory, importLibModule } from "../target.js";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const TEST_TIMEOUT = 120000;
27
+ const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
28
+ const END_OF_CENTRAL_DIRECTORY_LENGTH = 22;
29
+ const COMMENT_LENGTH_OFFSET = 20;
30
+
31
+ const capturePromises = new Map();
32
+
33
+ test("a payload over --max-appended-data-length is not appended", { timeout: TEST_TIMEOUT }, async () => {
34
+ const { data, stderr } = await getCaptureResult(0);
35
+ assert.equal(trailingBytes(data), 0, "nothing may follow the record when the budget is zero, stderr: " + stderr);
36
+ });
37
+
38
+ test("a payload within --max-appended-data-length is appended", { timeout: TEST_TIMEOUT }, async () => {
39
+ const { data, stderr } = await getCaptureResult(1000000);
40
+ assert.ok(trailingBytes(data) > 0, "a generous budget must leave the payload appended, stderr: " + stderr);
41
+ });
42
+
43
+ test("the archive stays readable at either end of the range", { timeout: TEST_TIMEOUT }, async () => {
44
+ for (const maxAppendedDataLength of [0, 1000000]) {
45
+ const { data } = await getCaptureResult(maxAppendedDataLength);
46
+ configure({ useWebWorkers: false });
47
+ const zipReader = new ZipReader(new Uint8ArrayReader(data));
48
+ const entryNames = (await zipReader.getEntries()).map(entry => entry.filename);
49
+ assert.ok(entryNames.includes("index.html"), "maxAppendedDataLength: " + maxAppendedDataLength);
50
+ }
51
+ });
52
+
53
+ function trailingBytes(data) {
54
+ const { offset, commentLength } = findEndOfCentralDirectory(data);
55
+ return data.length - (offset + END_OF_CENTRAL_DIRECTORY_LENGTH + commentLength);
56
+ }
57
+
58
+ function getCaptureResult(maxAppendedDataLength) {
59
+ if (!capturePromises.has(maxAppendedDataLength)) {
60
+ capturePromises.set(maxAppendedDataLength, runCapture(maxAppendedDataLength));
61
+ }
62
+ return capturePromises.get(maxAppendedDataLength);
63
+ }
64
+
65
+ async function runCapture(maxAppendedDataLength) {
66
+ const server = createServer((request, response) => {
67
+ const { pathname } = new URL(request.url, "http://localhost");
68
+ if (pathname === "/") {
69
+ response.writeHead(200, { "content-type": "text/html" })
70
+ .end("<html><head><title>Budget Page</title></head><body>content</body></html>");
71
+ } else {
72
+ response.writeHead(404).end();
73
+ }
74
+ });
75
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
76
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
77
+ try {
78
+ const origin = "http://localhost:" + server.address().port;
79
+ const { stderr } = await execFileAsync(process.execPath, [
80
+ "single-file-node.js", origin + "/", join(directory, "page.html"),
81
+ "--compress-content",
82
+ "--max-appended-data-length=" + maxAppendedDataLength
83
+ ], { cwd: cliDirectory });
84
+ const data = new Uint8Array(await readFile(join(directory, "page.html")));
85
+ return { data, stderr };
86
+ } finally {
87
+ await rm(directory, { recursive: true });
88
+ server.close();
89
+ }
90
+ }
91
+
92
+ function findEndOfCentralDirectory(data) {
93
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
94
+ for (let offset = data.length - END_OF_CENTRAL_DIRECTORY_LENGTH; offset >= 0; offset--) {
95
+ if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
96
+ return { offset, commentLength: view.getUint16(offset + COMMENT_LENGTH_OFFSET, true) };
97
+ }
98
+ }
99
+ throw new Error("end of central directory record not found");
100
+ }
@@ -0,0 +1,51 @@
1
+ // The multi-page archive path used to hand-list the 19 options it forwarded to createPagesArchive.
2
+ // The single-page path passes the whole options object through, so a compression option added to
3
+ // core reached every ordinary capture and was silently ignored by --crawl-save-archive alone —
4
+ // which is the worst shape for this kind of gap, because the option looks wired. Core removed the
5
+ // same trap on its side by exporting PROCESS_OPTION_NAMES instead of copying the names; the
6
+ // forwarding here now derives from that list, and what is deliberately withheld is named.
7
+ //
8
+ // --max-appended-data-length is what found this: it needed a line here as well as in options.js,
9
+ // and would otherwise have shipped working everywhere except archives.
10
+
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { importLibModule } from "../target.js";
14
+ import { getArchiveOptions, ARCHIVE_EXCLUDED_OPTION_NAMES } from "../../single-file-cli-api.js";
15
+ const { PROCESS_OPTION_NAMES } = await importLibModule("single-file-archive.js");
16
+
17
+ // the keys forwarded before the refactor, pinned so the derivation cannot quietly change what the
18
+ // archive path receives
19
+ const FORWARDED = [
20
+ "zipScript", "dedupPages", "markUnarchivedLinks", "tocPage", "pageList", "pageTransitions",
21
+ "insertSingleFileComment", "removeSavedDate", "declareAppendedData", "embeddedImage",
22
+ "embeddedPdf", "extractDataFromPage", "includeBOM", "insertCanonicalLink", "insertMetaCSP",
23
+ "insertMetaNoIndex", "maxAppendedDataLength", "preventAppendedData", "selfExtractingArchive"
24
+ ];
25
+
26
+ test("every compression option is either forwarded to the archive or deliberately withheld", () => {
27
+ const forwarded = Object.keys(getArchiveOptions({}));
28
+ const unaccounted = PROCESS_OPTION_NAMES
29
+ .filter(name => !forwarded.includes(name) && !ARCHIVE_EXCLUDED_OPTION_NAMES.includes(name));
30
+ assert.deepEqual(unaccounted, [],
31
+ "core gained a compression option the archive path neither forwards nor names as withheld, so it is silently ignored by --crawl-save-archive; forward it, or add it to ARCHIVE_EXCLUDED_OPTION_NAMES");
32
+ });
33
+
34
+ test("the archive receives exactly the options it received before the derivation", () => {
35
+ assert.deepEqual(Object.keys(getArchiveOptions({})).sort(), FORWARDED.slice().sort());
36
+ });
37
+
38
+ test("the withheld options are real compression options, not typos", () => {
39
+ const unknown = ARCHIVE_EXCLUDED_OPTION_NAMES.filter(name => !PROCESS_OPTION_NAMES.includes(name));
40
+ assert.deepEqual(unknown, [], "a withheld name that core does not define hides a real gap behind a dead entry");
41
+ });
42
+
43
+ test("a crawl option is renamed on its way to the archive", () => {
44
+ const forwarded = getArchiveOptions({ crawlSaveArchiveDedup: true, crawlSaveArchiveToc: true });
45
+ assert.equal(forwarded.dedupPages, true);
46
+ assert.equal(forwarded.tocPage, true);
47
+ });
48
+
49
+ test("a compression option reaches the archive under its own name", () => {
50
+ assert.equal(getArchiveOptions({ maxAppendedDataLength: 4096 }).maxAppendedDataLength, 4096);
51
+ });