single-file-core 1.6.0 → 1.6.2

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.
Files changed (77) hide show
  1. package/core/helper.js +5 -2
  2. package/modules/css-fonts-minifier.js +34 -13
  3. package/modules/template-formatter.js +8 -2
  4. package/package.json +10 -2
  5. package/vendor/zip/zip.js +4 -0
  6. package/.github/workflows/publish.yml +0 -88
  7. package/.github/workflows/test.yml +0 -31
  8. package/.github/workflows/vendor.yml +0 -39
  9. package/css-tree-build/README.md +0 -39
  10. package/css-tree-build/build.js +0 -66
  11. package/css-tree-build/package-lock.json +0 -511
  12. package/css-tree-build/package.json +0 -13
  13. package/deno.lock +0 -19
  14. package/doc/assets/singlefile-archive-byte-map.svg +0 -276
  15. package/doc/assets/singlefile-archive-lenses.svg +0 -245
  16. package/doc/singlefile-archive.md +0 -1968
  17. package/eslint.config.mjs +0 -74
  18. package/test/capture/README.md +0 -74
  19. package/test/capture/canonical-link.js +0 -50
  20. package/test/capture/common.js +0 -73
  21. package/test/capture/deferred-content-options.js +0 -71
  22. package/test/capture/dom.js +0 -26
  23. package/test/capture/maff-metadata.js +0 -205
  24. package/test/capture/resource-cap.js +0 -79
  25. package/test/capture/script-uri-sanitization.js +0 -82
  26. package/test/fixtures/README.md +0 -18
  27. package/test/fixtures/classic-digest.html +0 -5
  28. package/test/fixtures/generate-fixtures.sh +0 -31
  29. package/test/fixtures/multi-page-dedup.zip.html +0 -70
  30. package/test/fixtures/multi-page.zip.html +0 -26
  31. package/test/fixtures/single-page.zip.html +0 -11
  32. package/test/fixtures/site/multi-page/alpha.html +0 -2
  33. package/test/fixtures/site/multi-page/beta.html +0 -2
  34. package/test/fixtures/site/multi-page/index.html +0 -2
  35. package/test/fixtures/site/multi-page/sub/gamma.html +0 -2
  36. package/test/fixtures/site/multi-page-dedup/index.html +0 -2
  37. package/test/fixtures/site/multi-page-dedup/logo.png +0 -0
  38. package/test/fixtures/site/multi-page-dedup/one.html +0 -2
  39. package/test/fixtures/site/multi-page-dedup/shared.css +0 -3
  40. package/test/fixtures/site/multi-page-dedup/two.html +0 -2
  41. package/test/run.js +0 -109
  42. package/test/sfz-harness/README.md +0 -87
  43. package/test/sfz-harness/adopted-stylesheets-hook.js +0 -240
  44. package/test/sfz-harness/byte-map.js +0 -137
  45. package/test/sfz-harness/charset-round-trip.js +0 -161
  46. package/test/sfz-harness/check-determinism.js +0 -57
  47. package/test/sfz-harness/common.js +0 -154
  48. package/test/sfz-harness/content-type-sniffing.js +0 -83
  49. package/test/sfz-harness/css-fonts-minifier.js +0 -235
  50. package/test/sfz-harness/css-property-filter.js +0 -113
  51. package/test/sfz-harness/dom-stub.js +0 -13
  52. package/test/sfz-harness/entry-compression.js +0 -77
  53. package/test/sfz-harness/filename-characters.js +0 -55
  54. package/test/sfz-harness/filename-max-length.js +0 -69
  55. package/test/sfz-harness/font-face-composite.js +0 -135
  56. package/test/sfz-harness/format-rules.js +0 -617
  57. package/test/sfz-harness/gen-e2e-page.js +0 -14
  58. package/test/sfz-harness/inlined-functions.js +0 -82
  59. package/test/sfz-harness/option-wiring.js +0 -60
  60. package/test/sfz-harness/pages-archive.js +0 -318
  61. package/test/sfz-harness/pages-router.js +0 -143
  62. package/test/sfz-harness/relocation-cost.js +0 -94
  63. package/test/sfz-harness/search-triggers.js +0 -35
  64. package/test/sfz-harness/smoke.js +0 -10
  65. package/test/sfz-harness/stored-trigger.js +0 -95
  66. package/test/sfz-harness/trigger-seeds.json +0 -120
  67. package/test/sfz-harness/zip64.js +0 -77
  68. package/zip-build/README.md +0 -56
  69. package/zip-build/lib/fflate-streams.js +0 -47
  70. package/zip-build/lib/fflate.js +0 -2692
  71. package/zip-build/lib/zip-vendor-worker.js +0 -3
  72. package/zip-build/lib/zip-vendor.js +0 -3
  73. package/zip-build/lib/zip.js +0 -25
  74. package/zip-build/package-lock.json +0 -665
  75. package/zip-build/package.json +0 -15
  76. package/zip-build/reserved-property-names.json +0 -10630
  77. package/zip-build/rollup.config.js +0 -61
@@ -1,617 +0,0 @@
1
- import "./dom-stub.js";
2
- import { makePageData, makeOptions, runProcess, mulberry32, freezeDate } from "./common.js";
3
- import { ZipReader, ZipWriter, BlobReader } from "../../vendor/zip/zip.js";
4
-
5
- // the quote is there because the escaper the title shares with the table of contents encodes
6
- // it for an attribute value, where it matters, and a title has to round-trip through that too
7
- const TITLE = "日本語 — café & <b> \"quoted\"";
8
- const PDF = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n");
9
-
10
- let failed = false;
11
-
12
- function check(label, actual, expected) {
13
- const ok = actual === expected;
14
- console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
15
- failed ||= !ok;
16
- }
17
-
18
- function decodeText(bytes) {
19
- return new TextDecoder("windows-1252").decode(bytes);
20
- }
21
-
22
- function decodeReferences(text) {
23
- return text.replace(/&#(\d+);/g, (match, codePoint) => String.fromCodePoint(Number(codePoint)));
24
- }
25
-
26
- function imageResource(url) {
27
- return { name: "images/0.png", extension: ".png", content: new Uint8Array(1024).fill(0x21), url };
28
- }
29
-
30
- {
31
- const options = makeOptions();
32
- const pageData = makePageData(1, 4 * 1024);
33
- pageData.title = TITLE;
34
- const { bytes } = await runProcess(pageData, options);
35
- const title = decodeText(bytes).match(/<title>(.*?)<\/title>/)[1];
36
- check("title survives universal mode", title.length > 0, true);
37
- check("title is pure ASCII", /^[\x20-\x7e]*$/.test(title), true);
38
- check("title decodes to the original", decodeReferences(title), TITLE);
39
- }
40
-
41
- {
42
- const options = makeOptions({ password: "secret", insertCanonicalLink: true, url: "https://example.com/secret-page" });
43
- const pageData = makePageData(2, 4 * 1024);
44
- pageData.title = TITLE;
45
- pageData.comment = "\n url: https://example.com/secret-page \n";
46
- pageData.resources.images.push(imageResource("https://example.com/secret-image.png"));
47
- const { bytes } = await runProcess(pageData, options);
48
- const text = decodeText(bytes);
49
- check("title withheld from a protected archive", text.includes("<title></title>"), true);
50
- check("resource url absent from a protected archive", text.includes("secret-image.png"), false);
51
- // the wrapper ladder hides the zip data in a comment of its own, so only the text
52
- // of the SingleFile comment tells whether it was written
53
- check("comment withheld from a protected archive", text.includes("<!--\n url:"), false);
54
- check("canonical link withheld from a protected archive", text.includes("<link rel=canonical"), false);
55
- check("page url absent from a protected archive", text.includes("secret-page"), false);
56
- const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
57
- const entries = await zipReader.getEntries();
58
- await zipReader.close();
59
- check("entry comments empty in a protected archive", entries.every(entry => !entry.comment), true);
60
- }
61
-
62
- {
63
- const options = makeOptions({ insertCanonicalLink: true, url: "https://example.com/page" });
64
- const pageData = makePageData(9, 4 * 1024);
65
- pageData.comment = "\n url: https://example.com/page \n";
66
- const { bytes } = await runProcess(pageData, options);
67
- const text = decodeText(bytes);
68
- check("comment written without a password", text.includes("<!--\n url: https://example.com/page \n-->"), true);
69
- check("canonical link written without a password", text.includes("<link rel=canonical href=\"https://example.com/page\">"), true);
70
- }
71
-
72
- {
73
- const options = makeOptions({ insertTextBody: true });
74
- const pageData = makePageData(7, 4 * 1024);
75
- const { bytes } = await runProcess(pageData, options);
76
- check("text body written without a password", decodeText(bytes).includes("<main hidden>"), true);
77
- }
78
-
79
- {
80
- const options = makeOptions({ insertTextBody: true, password: "secret" });
81
- const pageData = makePageData(8, 4 * 1024);
82
- const { bytes } = await runProcess(pageData, options);
83
- const text = decodeText(bytes);
84
- check("text body withheld from a protected archive", text.includes("<main hidden>"), false);
85
- check("page text absent from a protected archive", text.includes("seed 8"), false);
86
- }
87
-
88
- {
89
- const options = makeOptions();
90
- const pageData = makePageData(3, 4 * 1024);
91
- pageData.resources.images.push(imageResource("https://example.com/image.png"));
92
- const { bytes } = await runProcess(pageData, options);
93
- const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
94
- const entries = await zipReader.getEntries();
95
- await zipReader.close();
96
- const imageEntry = entries.find(entry => entry.filename.endsWith("images/0.png"));
97
- check("entry comment carries the url without a password", imageEntry.comment, "https://example.com/image.png");
98
- }
99
-
100
- {
101
- const options = makeOptions({ embeddedPdf: PDF });
102
- const pageData = makePageData(4, 4 * 1024);
103
- const { bytes } = await runProcess(pageData, options);
104
- const text = decodeText(bytes);
105
- check("short doctype is preserved", text.startsWith("<!DOCTYPE html>"), true);
106
- check("pdf header inside the scan window", text.indexOf("%PDF-") <= 1024, true);
107
- }
108
-
109
- {
110
- const options = makeOptions({ embeddedPdf: PDF });
111
- const pageData = makePageData(5, 4 * 1024);
112
- pageData.doctype = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"" + "x".repeat(2000) + ".dtd\">";
113
- const { bytes } = await runProcess(pageData, options);
114
- const text = decodeText(bytes);
115
- check("oversized doctype is replaced", text.startsWith("<!DOCTYPE html><html data-sfz>"), true);
116
- check("pdf header stays inside the scan window", text.indexOf("%PDF-") <= 1024, true);
117
- }
118
-
119
- {
120
- const options = makeOptions();
121
- const pageData = makePageData(6, 4 * 1024);
122
- pageData.doctype = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"" + "x".repeat(2000) + ".dtd\">";
123
- const { bytes } = await runProcess(pageData, options);
124
- const text = decodeText(bytes);
125
- check("oversized doctype is replaced without the pdf face", text.startsWith("<!DOCTYPE html><html data-sfz>"), true);
126
- check("charset declaration stays inside the scan window", charsetDeclarationEnd(text) <= 1024, true);
127
- }
128
-
129
- {
130
- const options = makeOptions();
131
- const pageData = makePageData(6, 4 * 1024);
132
- pageData.doctype = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
133
- const { bytes } = await runProcess(pageData, options);
134
- check("ordinary doctype is kept verbatim", decodeText(bytes).startsWith(pageData.doctype), true);
135
- }
136
-
137
- function charsetDeclarationEnd(text) {
138
- const index = text.indexOf("<meta charset=");
139
- return index == -1 ? -1 : index + text.substring(index).indexOf(">") + 1;
140
- }
141
-
142
- function triggerResource(literals) {
143
- const bytes = new Uint8Array(2048).fill(0x21);
144
- const encoder = new TextEncoder();
145
- let offset = 128;
146
- for (const literal of literals) {
147
- bytes.set(encoder.encode(literal), offset);
148
- offset += 256;
149
- }
150
- return { name: "images/trigger.png", extension: ".png", content: bytes };
151
- }
152
-
153
- function countIdentifiers(text) {
154
- return (text.match(/id=sfz-data|<!--sfz-data/g) || []).length;
155
- }
156
-
157
- {
158
- const options = makeOptions();
159
- const pageData = makePageData(12, 4 * 1024);
160
- const { bytes } = await runProcess(pageData, options);
161
- check("comment wrapper carries the identifier", decodeText(bytes).includes("<!--sfz-dataPK"), true);
162
- }
163
-
164
- {
165
- const options = makeOptions();
166
- const pageData = makePageData(13, 4 * 1024);
167
- pageData.resources.images.push(triggerResource(["-->"]));
168
- const { bytes } = await runProcess(pageData, options);
169
- check("element wrapper carries the identifier", decodeText(bytes).includes("<script type=sfz-data id=sfz-data>PK"), true);
170
- }
171
-
172
- {
173
- const options = makeOptions({ preventAppendedData: true });
174
- const pageData = makePageData(14, 4 * 1024);
175
- const { bytes } = await runProcess(pageData, options);
176
- const text = decodeText(bytes);
177
- check("relocated placement carries the identifier", text.includes("<!--sfz-dataPK"), true);
178
- check("relocated payload needs no separator node", /<\/sfz-extra-data> +<!--sfz-dataPK/.test(text), true);
179
- }
180
-
181
- // relocating the payload shifts the central directory offsets, which moves the payload length by
182
- // a few base64 quanta; a reservation margin below that shift costs a third layout in about one
183
- // build out of four, so a dozen relocations must all settle in two. The image bytes never contain
184
- // a hyphen, so a wrapper collision cannot add a layout of its own
185
- {
186
- const { appendZip } = ZipWriter.prototype;
187
- let layouts = 0;
188
- ZipWriter.prototype.appendZip = function (reader) {
189
- layouts++;
190
- return appendZip.call(this, reader);
191
- };
192
- let retried = 0;
193
- for (let seed = 30; seed < 42; seed++) {
194
- const options = makeOptions({ preventAppendedData: true });
195
- const pageData = makePageData(seed, 64 * 1024);
196
- const rand = mulberry32(seed);
197
- for (let index = 0; index < 40; index++) {
198
- const content = new Uint8Array(8 * 1024).map(() => {
199
- const byte = (rand() * 255) | 0;
200
- return byte < 0x2D ? byte : byte + 1;
201
- });
202
- pageData.resources.images.push({ name: "images/" + index + ".jpg", extension: ".jpg", content, url: "https://example.com/" + index + ".jpg" });
203
- }
204
- layouts = 0;
205
- await runProcess(pageData, options);
206
- if (layouts > 2) {
207
- retried++;
208
- }
209
- }
210
- ZipWriter.prototype.appendZip = appendZip;
211
- check("the reservation margin absorbs the offset shift", retried, 0);
212
- }
213
-
214
- // a payload too large for the appended budget is relocated, and relocation is final: the writer
215
- // then emits no appended run at all, so the file ends at the EOCD like a plain zip. A stored
216
- // resource made of random newline bytes gives a payload past the budget from a few hundred KB
217
- {
218
- const { appendZip } = ZipWriter.prototype;
219
- let layouts = 0;
220
- ZipWriter.prototype.appendZip = function (reader) {
221
- layouts++;
222
- return appendZip.call(this, reader);
223
- };
224
- const options = makeOptions({ disableCompression: true });
225
- const pageData = makePageData(42, 4 * 1024);
226
- const rand = mulberry32(42);
227
- const newlines = ["\n", "\r", "\r\n"];
228
- let content = "";
229
- while (content.length < 512 * 1024) {
230
- content += newlines[(rand() * 3) | 0];
231
- }
232
- pageData.resources.stylesheets.push({ name: "newlines.txt", extension: ".txt", content, url: "https://example.com/newlines.txt" });
233
- const { bytes } = await runProcess(pageData, options);
234
- ZipWriter.prototype.appendZip = appendZip;
235
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
236
- check("an oversized payload is relocated", options.extraDataSize > 0, true);
237
- check("relocation needs a single extra layout", layouts, 2);
238
- check("a relocated archive ends at the EOCD", view.getUint32(bytes.length - 22, true), 0x06054b50);
239
- check("a relocated archive declares no comment", view.getUint16(bytes.length - 2, true), 0);
240
- }
241
-
242
- function makeNewlinePageData(seed, newlineCount) {
243
- const pageData = makePageData(seed, 4 * 1024);
244
- const rand = mulberry32(seed);
245
- const newlines = ["\n", "\r", "\r\n"];
246
- let content = "";
247
- for (let index = 0; index < newlineCount; index++) {
248
- content += newlines[(rand() * 3) | 0];
249
- }
250
- pageData.resources.stylesheets.push({ name: "newlines.txt", extension: ".txt", content, url: "https://example.com/newlines.txt" });
251
- return pageData;
252
- }
253
-
254
- // libarchive gives up looking for the end of central directory record 16383 bytes from the end of
255
- // the file, so the default budget is 16361 appended bytes: that window minus the 22-byte record.
256
- // This fixture's payload lands between the default and the 65535-byte comment ceiling, which is
257
- // the range the old budget kept appended and bsdtar could not open.
258
- // The Date must be frozen: `archiveTime` (compression.js:155) puts an ISO timestamp in the
259
- // archive, whose milliseconds move a few newline bytes in and out of the recovered range and
260
- // therefore change the payload length build to build. The boundary checks below compare a budget
261
- // against a run measured in an EARLIER build, so without freezing they are off by a few bytes one
262
- // run in three. check-determinism.js asserts both halves of that
263
- {
264
- const restoreDate = freezeDate();
265
- try {
266
- const wide = makeOptions({ disableCompression: true, maxAppendedDataLength: 65535 });
267
- const { bytes: wideBytes } = await runProcess(makeNewlinePageData(43, 80 * 1000), wide);
268
- const wideTail = readAppendedData(wideBytes).trailing;
269
- check("a wider budget keeps the payload appended", wide.extraDataSize, undefined);
270
- check("the fixture overflows the default budget", wideTail > 16361, true);
271
- check("the fixture fits the comment ceiling", wideTail <= 65535, true);
272
-
273
- const byDefault = makeOptions({ disableCompression: true });
274
- const { bytes } = await runProcess(makeNewlinePageData(43, 80 * 1000), byDefault);
275
- check("the default budget relocates the payload", byDefault.extraDataSize > 0, true);
276
- check("the default budget keeps the record in libarchive's window", readAppendedData(bytes).trailing <= 16361, true);
277
-
278
- const fitting = makeOptions({ disableCompression: true, maxAppendedDataLength: wideTail });
279
- await runProcess(makeNewlinePageData(43, 80 * 1000), fitting);
280
- check("a budget matching the run to the byte keeps it appended", fitting.extraDataSize, undefined);
281
-
282
- const tight = makeOptions({ disableCompression: true, maxAppendedDataLength: wideTail - 1 });
283
- await runProcess(makeNewlinePageData(43, 80 * 1000), tight);
284
- check("one byte below the run relocates it", tight.extraDataSize > 0, true);
285
- } finally {
286
- restoreDate();
287
- }
288
- }
289
-
290
- // the budget and the comment ceiling are two different limits, and only the second is a property
291
- // of the format. A budget raised past 65535 produces a run the comment-length field cannot hold,
292
- // which setUint16 would write back modulo 65536: the writer leaves it undeclared instead
293
- {
294
- const options = makeOptions({ disableCompression: true, declareAppendedData: true, maxAppendedDataLength: Number.MAX_SAFE_INTEGER });
295
- const { bytes } = await runProcess(makeNewlinePageData(44, 320 * 1000), options);
296
- const { declared, trailing } = readAppendedData(bytes);
297
- check("a run past the comment ceiling stays appended", trailing > 65535, true);
298
- check("a run past the comment ceiling is left undeclared", declared, 0);
299
- }
300
-
301
- {
302
- const options = makeOptions({ embeddedPdf: PDF });
303
- const pageData = makePageData(15, 4 * 1024);
304
- const { bytes } = await runProcess(pageData, options);
305
- check("the pdf face is not identified as the zip data", countIdentifiers(decodeText(bytes)), 1);
306
- }
307
-
308
- {
309
- const embeddedImage = new Uint8Array(8 + 25 + 512 + 12);
310
- embeddedImage.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
311
- embeddedImage.fill(0x41, 33, 33 + 512);
312
- const options = makeOptions({ embeddedImage });
313
- const pageData = makePageData(16, 4 * 1024);
314
- const { bytes } = await runProcess(pageData, options);
315
- check("the png face is not identified as the zip data", countIdentifiers(decodeText(bytes)), 1);
316
- }
317
-
318
- {
319
- // the last rung is exempt from both tests: it has no terminator and no tokenizer
320
- // states, so a payload naming every rung still fits inside it
321
- const content = new Uint8Array(4096).fill(0x21);
322
- content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp<![CDATA[<plaintext"), 128);
323
- const options = makeOptions();
324
- const pageData = makePageData(19, 4 * 1024);
325
- pageData.resources.images.push({ name: "images/all-starts.png", extension: ".png", content });
326
- const { bytes } = await runProcess(pageData, options);
327
- check("every rung's start pattern exhausts to", options.extractDataFromPageTags[0], "<plaintext>");
328
- check("every rung's start pattern keeps extraction", options.extractDataFromPage !== false, true);
329
- check("the last rung still carries the identifier", decodeText(bytes).includes("<plaintext id=sfz-data>PK"), true);
330
- }
331
-
332
- // the CDATA rung is the last one that can be closed, so it is what stands between a payload
333
- // naming every element rung and <plaintext>, whose selection costs the appended-data placement.
334
- // Its identifier goes on the svg, not on the markup declaration, which takes no attributes
335
- {
336
- const content = new Uint8Array(4096).fill(0x21);
337
- content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp"), 128);
338
- const options = makeOptions();
339
- const pageData = makePageData(24, 4 * 1024);
340
- pageData.resources.images.push({ name: "images/no-cdata.png", extension: ".png", content });
341
- const { bytes } = await runProcess(pageData, options);
342
- check("a payload naming every element rung stops at the cdata rung", options.extractDataFromPageTags[0], "<svg><![CDATA[");
343
- check("the cdata rung identifies the svg, not the declaration", decodeText(bytes).includes("<svg id=sfz-data><![CDATA[PK"), true);
344
- check("the cdata rung leaves the appended data placement alone", options.preventAppendedData !== true, true);
345
- }
346
-
347
- // the rung a caller names is resolved by its start tag, not by the identity of the array holding
348
- // it. The option is internal and is normally set from the module's own EXTRA_DATA_TAGS, so an
349
- // identity match worked by accident; a caller passing an equal pair of its own indexed the regexp
350
- // table with -1 and threw a bare TypeError out of library internals
351
- {
352
- const options = makeOptions({ extractDataFromPageTags: ["<noframes>", "</noframes>"] });
353
- const pageData = makePageData(31, 4 * 1024);
354
- let error;
355
- try {
356
- await runProcess(pageData, options);
357
- } catch (caught) {
358
- error = caught;
359
- }
360
- check("a rung named by an equal pair is resolved", error, undefined);
361
- }
362
-
363
- {
364
- const options = makeOptions({ extractDataFromPageTags: ["<marquee>", "</marquee>"] });
365
- const pageData = makePageData(32, 4 * 1024);
366
- let message;
367
- try {
368
- await runProcess(pageData, options);
369
- } catch (error) {
370
- message = error.message;
371
- }
372
- check("a rung that is not in the ladder names itself", message, "Unknown data tags: <marquee>");
373
- }
374
-
375
- // the terminator is the whole test for this rung, so a payload holding it must step past
376
- {
377
- const content = new Uint8Array(4096).fill(0x21);
378
- content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp]]>"), 128);
379
- const options = makeOptions();
380
- const pageData = makePageData(25, 4 * 1024);
381
- pageData.resources.images.push({ name: "images/cdata-end.png", extension: ".png", content });
382
- const { bytes } = await runProcess(pageData, options);
383
- check("a payload holding \"]]>\" defeats the cdata rung", options.extractDataFromPageTags[0], "<plaintext>");
384
- check("the rung below takes it", decodeText(bytes).includes("<plaintext id=sfz-data>PK"), true);
385
- }
386
-
387
- // script data has escape states the raw text rungs do not: "<!--" then "<script" in a
388
- // payload leaves "</script>" unable to close the element, so a face carrying both must
389
- // step past the script rung even though it holds no closer at all
390
- const DOUBLE_ESCAPE = "--> <!-- <script ";
391
-
392
- {
393
- const embeddedPdf = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<< /X (" + DOUBLE_ESCAPE + ") >>\nendobj\ntrailer\n<<>>\n%%EOF\n");
394
- const options = makeOptions({ embeddedPdf });
395
- const pageData = makePageData(17, 4 * 1024);
396
- const { bytes } = await runProcess(pageData, options);
397
- const text = decodeText(bytes);
398
- check("the pdf face steps past the script rung", text.includes("<style type=sfz-data>%PDF-") || text.includes("<style type=sfz-data>PK"), true);
399
- check("the pdf face takes no script rung", text.includes("<script type=sfz-data>"), false);
400
- }
401
-
402
- {
403
- const embeddedImage = new Uint8Array(8 + 25 + 512 + 12);
404
- embeddedImage.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
405
- embeddedImage.fill(0x41, 33, 33 + 512);
406
- embeddedImage.set(new TextEncoder().encode(DOUBLE_ESCAPE), 100);
407
- const options = makeOptions({ embeddedImage });
408
- const pageData = makePageData(18, 4 * 1024);
409
- const { bytes } = await runProcess(pageData, options);
410
- check("the png face takes no script rung", decodeText(bytes).includes("<script type=sfz-data>"), false);
411
- }
412
-
413
- // a face is hidden by the same ladder as the zip data, minus the rung that cannot be closed:
414
- // a payload naming all seven of the rest leaves nowhere to put it. Writing it unwrapped was the
415
- // older behaviour, and it is the dangerous one, because the payload's own markup then joins the
416
- // document. These payloads carry the identifier the way a nested archive does: a reader looking
417
- // for one node finds two, takes the first, and extracts an archive that checksums
418
- const ALL_FACE_RUNGS = "<!--sfz-data<script<style<noframes<noembed<iframe<xmp<![CDATA[";
419
-
420
- {
421
- const embeddedPdf = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<< /X (" + ALL_FACE_RUNGS + ") >>\nendobj\ntrailer\n<<>>\n%%EOF\n");
422
- const options = makeOptions({ embeddedPdf });
423
- const pageData = makePageData(22, 4 * 1024);
424
- const { bytes } = await runProcess(pageData, options);
425
- const text = decodeText(bytes);
426
- check("an unhidable pdf face is dropped", text.includes("%PDF-"), false);
427
- check("the dropped pdf face leaves one identifier", countIdentifiers(text), 1);
428
- const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
429
- const entries = await zipReader.getEntries();
430
- await zipReader.close();
431
- check("the archive survives the dropped pdf face", entries.length > 0, true);
432
- check("no page.pdf entry is left behind", entries.some(entry => entry.filename.endsWith("page.pdf")), false);
433
- }
434
-
435
- // page.pdf is the only record the writer builds by hand, so it is the only place the language
436
- // encoding flag can disagree with the rest of the archive, and a reader would then decode that one
437
- // name through a different path than every other name in the same file. Which way the flag goes is
438
- // the ZIP writer's business, not this format's: it sets bit 11 only when a name or a comment holds
439
- // a byte outside printable ASCII, so every name in this fixture is flagless. What is asserted here
440
- // is the agreement, so this still fails if the hand-built record diverges and still passes if the
441
- // writer changes its rule again
442
- {
443
- const options = makeOptions({ embeddedPdf: PDF });
444
- const pageData = makePageData(23, 4 * 1024);
445
- pageData.resources.images.push(imageResource("https://example.com/image.png"));
446
- const { bytes } = await runProcess(pageData, options);
447
- const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
448
- const entries = await zipReader.getEntries();
449
- await zipReader.close();
450
- const pdfEntry = entries.find(entry => entry.filename == "page.pdf");
451
- check("the pdf entry is listed", Boolean(pdfEntry), true);
452
- check("the hand-built central record declares its name like the records the writer produces",
453
- entries.every(entry => Boolean(entry.filenameUTF8) == Boolean(pdfEntry.filenameUTF8)), true);
454
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
455
- const localFlags = new Map(entries.map(entry => [entry.filename, view.getUint16(entry.offset + 6, true) & 0x0800]));
456
- check("the hand-built local header declares its name like the headers the writer produces",
457
- [...localFlags.values()].every(flag => flag == localFlags.get("page.pdf")), true);
458
- }
459
-
460
- {
461
- const embeddedImage = new Uint8Array(8 + 25 + 512 + 12);
462
- embeddedImage.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
463
- embeddedImage.fill(0x41, 33, 33 + 512);
464
- embeddedImage.set(new TextEncoder().encode(ALL_FACE_RUNGS), 100);
465
- const options = makeOptions({ embeddedImage });
466
- const pageData = makePageData(23, 4 * 1024);
467
- const { bytes } = await runProcess(pageData, options);
468
- const text = decodeText(bytes);
469
- check("an unhidable png face is dropped", bytes[0], 0x3c);
470
- check("the dropped png face leaves one identifier", countIdentifiers(text), 1);
471
- check("the page keeps its doctype without the png face", text.startsWith("<!DOCTYPE html>"), true);
472
- }
473
-
474
- // every rung pattern is matched case-insensitively, because the HTML tokenizer closes an
475
- // element on any case of its end tag. Dropping that would pick a rung the payload itself
476
- // terminates, and no other test in this file would notice
477
- {
478
- const options = makeOptions();
479
- const pageData = makePageData(20, 4 * 1024);
480
- pageData.resources.images.push(triggerResource(["-->", "</SCRIPT>"]));
481
- const { bytes } = await runProcess(pageData, options);
482
- const text = decodeText(bytes);
483
- check("an upper-case end tag defeats its rung", text.includes("<script type=sfz-data"), false);
484
- check("the rung below takes the payload", text.includes("<style type=sfz-data id=sfz-data>PK"), true);
485
- }
486
-
487
- {
488
- const options = makeOptions();
489
- const pageData = makePageData(21, 4 * 1024);
490
- pageData.resources.images.push(triggerResource(["-->", "<SCRIPT "]));
491
- const { bytes } = await runProcess(pageData, options);
492
- const text = decodeText(bytes);
493
- check("an upper-case start pattern defeats its rung", text.includes("<script type=sfz-data"), false);
494
- check("the rung below takes the start-pattern payload", text.includes("<style type=sfz-data id=sfz-data>PK"), true);
495
- }
496
-
497
- function randomPng(seed) {
498
- const rand = mulberry32(seed);
499
- const bytes = new Uint8Array(8 + 25 + 2048 + 12);
500
- bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
501
- for (let index = 33; index < bytes.length - 12; index++) {
502
- bytes[index] = (rand() * 256) | 0;
503
- }
504
- return bytes;
505
- }
506
-
507
- // the checksum of the chunk carrying the start tag begins the wrapper's content: for seeds 148
508
- // and 616 it starts with ">", which closes a comment wrapper the moment the parser reaches it,
509
- // and seed 147 is a control whose checksum does not
510
- for (const seed of [147, 148, 616]) {
511
- const options = makeOptions({ embeddedImage: randomPng(seed) });
512
- const pageData = makePageData(seed, 2 * 1024);
513
- const { bytes } = await runProcess(pageData, options);
514
- check("no wrapper comment is closed abruptly, seed " + seed, /<!--(>|->)/.test(decodeText(bytes)), false);
515
- }
516
-
517
- // leaving the comment rung is a search, not an increment. The checksum decides only that the
518
- // comment is unusable; which rung is usable is still the payload's to say, and these payloads
519
- // terminate the rung immediately below. Taking it on trust put the image data, the chunk framing
520
- // and the whole ZIP region outside the wrapper, 19KB of it, read by the parser as markup
521
- for (const seed of [148, 616]) {
522
- const embeddedImage = randomPng(seed);
523
- embeddedImage.set(new TextEncoder().encode("</script>"), 100);
524
- const options = makeOptions({ embeddedImage });
525
- const pageData = makePageData(seed, 2 * 1024);
526
- const { bytes } = await runProcess(pageData, options);
527
- const text = decodeText(bytes);
528
- check("the step off the comment rung skips what the payload terminates, seed " + seed, text.includes("<script type=sfz-data>"), false);
529
- check("the step off the comment rung lands on a rung that qualifies, seed " + seed, text.includes("<style type=sfz-data>"), true);
530
- }
531
-
532
- // the control for the pair above: the same seeds, the same step, a payload that leaves the rung
533
- // below usable — the writer must still take it rather than skip past it
534
- {
535
- const options = makeOptions({ embeddedImage: randomPng(148) });
536
- const { bytes } = await runProcess(makePageData(148, 2 * 1024), options);
537
- check("the step off the comment rung takes a usable rung", decodeText(bytes).includes("<script type=sfz-data>"), true);
538
- }
539
-
540
- function readAppendedData(bytes) {
541
- const view = new DataView(bytes.buffer, bytes.byteOffset);
542
- for (let index = bytes.length - 22; index >= 0; index--) {
543
- if (view.getUint32(index, true) == 0x06054b50) {
544
- return { declared: view.getUint16(index + 20, true), trailing: bytes.length - index - 22 };
545
- }
546
- }
547
- return {};
548
- }
549
-
550
- {
551
- const options = makeOptions();
552
- const pageData = makePageData(9, 4 * 1024);
553
- const { bytes } = await runProcess(pageData, options);
554
- const { declared, trailing } = readAppendedData(bytes);
555
- check("appended data left undeclared by default", declared, 0);
556
- check("appended data is there to declare", trailing > 0, true);
557
- }
558
-
559
- {
560
- const options = makeOptions({ declareAppendedData: true });
561
- const pageData = makePageData(10, 4 * 1024);
562
- const { bytes } = await runProcess(pageData, options);
563
- const { declared, trailing } = readAppendedData(bytes);
564
- check("appended data declared as the archive comment", declared, trailing);
565
- check("the comment covers the rest of the file", trailing > 0, true);
566
- }
567
-
568
- {
569
- const options = makeOptions({ declareAppendedData: true, preventAppendedData: true });
570
- const pageData = makePageData(11, 4 * 1024);
571
- const { bytes } = await runProcess(pageData, options);
572
- const { declared, trailing } = readAppendedData(bytes);
573
- check("nothing to declare when appended data is prevented", declared, 0);
574
- check("no trailing bytes when appended data is prevented", trailing, 0);
575
- }
576
-
577
- // a comment cannot escape its own delimiters, so text reaching it from the captured page —
578
- // the infobar template resolves {page-title} and its kind against the document — would close
579
- // it early and turn the rest into markup. The page the archive restores carries no scripts by
580
- // default, and this was the one way to put one back
581
- {
582
- for (const [label, comment] of [
583
- ["-->", "\n info: --><script>INJECTED</script><!-- \n"],
584
- ["--!>", "\n info: --!><script>INJECTED</script><!-- \n"],
585
- ["--->", "\n info: ---><script>INJECTED</script><!-- \n"]
586
- ]) {
587
- const pageData = makePageData(12, 1024);
588
- pageData.comment = comment;
589
- const { bytes } = await runProcess(pageData, makeOptions());
590
- const text = decodeText(bytes);
591
- const start = text.indexOf("<!--\n info:");
592
- check(`the comment survives ${label} in its content`, start != -1, true);
593
- // "--!>" closes a comment too, so the end is the first of either form, not the first "-->"
594
- const end = start + text.substring(start).search(/--!?>/);
595
- check(`${label} in the comment does not close it early`,
596
- text.indexOf("INJECTED") < end, true);
597
- }
598
- const pageData = makePageData(12, 1024);
599
- pageData.comment = "\n info: a-->b \n";
600
- const { bytes } = await runProcess(pageData, makeOptions());
601
- check("the characters either side of the run are kept",
602
- decodeText(bytes).includes("info: a-- >b"), true);
603
- }
604
-
605
- // neither directive falls back to default-src, so each has to be named to have any effect
606
- {
607
- const { bytes } = await runProcess(makePageData(13, 1024), makeOptions({ insertMetaCSP: true }));
608
- const policy = decodeText(bytes).match(/content-security-policy content="([^"]*)"/)[1];
609
- check("the policy forbids form submission", policy.includes("form-action 'none'"), true);
610
- check("the policy forbids a base element", policy.includes("base-uri 'none'"), true);
611
- }
612
-
613
- if (failed) {
614
- console.log("FAILED");
615
- Deno.exit(1);
616
- }
617
- console.log("OK");
@@ -1,14 +0,0 @@
1
- import { makePageData, makeOptions, runProcess } from "./common.js";
2
-
3
- const outPath = Deno.args[0];
4
- const seed = Number(Deno.args[1] ?? 1);
5
- const contentLength = Number(Deno.args[2] ?? 256 * 1024);
6
- const zipScript = await Deno.readTextFile(new URL("../../vendor/zip/zip.min.js", import.meta.url));
7
- if (zipScript.includes("</script>")) {
8
- throw new Error("zip.min.js contains </script>, cannot embed raw");
9
- }
10
- const options = makeOptions({ zipScript });
11
- const pageData = makePageData(seed, contentLength);
12
- const result = await runProcess(pageData, options);
13
- await Deno.writeFile(outPath, result.bytes);
14
- console.log(`wrote ${outPath} (${result.bytes.length} bytes), fallback tag: ${result.fallbackTag}, extraction disabled: ${result.extractionDisabled}`);