single-file-core 1.5.108 → 1.5.110
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.
- package/doc/singlefile-archive.md +67 -14
- package/modules/css-fonts-minifier.js +22 -5
- package/package.json +2 -2
- package/processors/compression/compression-packager.js +5 -0
- package/processors/compression/compression.js +125 -44
- package/single-file.js +5 -16
- package/test/sfz-harness/README.md +1 -0
- package/test/sfz-harness/format-rules.js +87 -1
- package/test/sfz-harness/option-wiring.js +63 -0
- package/test/sfz-harness/stored-trigger.js +2 -2
|
@@ -314,9 +314,9 @@ default wrapper is an HTML comment, and the HTML standard defines exactly which
|
|
|
314
314
|
character sequences terminate one (`-->`, and the recovery form `--!>`); the writer
|
|
315
315
|
MUST select a wrapper only after checking the bytes it must hide against that
|
|
316
316
|
wrapper's patterns (the exact rules differ between the ZIP region and the PDF and
|
|
317
|
-
PNG payloads, §5.1), so hiding relies on normative parsing behavior.
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
PNG payloads, §5.1), so hiding relies on normative parsing behavior. When no
|
|
318
|
+
wrapper fits a PDF or PNG payload, the face is dropped rather than emitted bare
|
|
319
|
+
(§5.1). Some binary content always sits *outside* a
|
|
320
320
|
wrapper: in the PNG variants, the signature, IHDR and chunk framing bytes that
|
|
321
321
|
precede the root element start tag decode to a short run of text that HTML error recovery
|
|
322
322
|
places in the (hidden) body. The backstop for all these cases is the prologue: it
|
|
@@ -678,7 +678,8 @@ this ladder, in order:
|
|
|
678
678
|
| 5 | `<noembed>` | raw text | `</noembed` + delimiter |
|
|
679
679
|
| 6 | `<iframe>` | raw text | `</iframe` + delimiter |
|
|
680
680
|
| 7 | `<xmp>` | raw text | `</xmp` + delimiter |
|
|
681
|
-
| 8 | `<
|
|
681
|
+
| 8 | `<svg><![CDATA[` … `]]></svg>` | CDATA section | `]]>` |
|
|
682
|
+
| 9 | `<plaintext>` | everything to end of file | nothing — the element cannot be closed |
|
|
682
683
|
|
|
683
684
|
The terminators are written lower case above, but HTML matches end tag names ASCII
|
|
684
685
|
case-insensitively: `</XMP>` and `</Script ` close their elements just as `</xmp>` and
|
|
@@ -711,6 +712,31 @@ or stops reading `<xmp>`, changes only which archives end up in a local search i
|
|
|
711
712
|
every rung still hides its content from the HTML parser, and a writer whose ladder is
|
|
712
713
|
ordered differently produces files that are just as correct.
|
|
713
714
|
|
|
715
|
+
The CDATA rung sits where it does for the same reason, and it is the only rung whose
|
|
716
|
+
placement understates it. A CDATA section is a CDATA section only in foreign content,
|
|
717
|
+
which is what the `<svg>` element is there for — in HTML content `<![CDATA[` is a
|
|
718
|
+
bogus comment, and the payload would be markup. Given the `<svg>`, the construct is the
|
|
719
|
+
strongest on the ladder: `]]>` is the whole of its terminator, and it is a sequence real
|
|
720
|
+
payloads carry far less often than `-->` or `</script>`. That matters most for the one
|
|
721
|
+
thing the ladder cannot otherwise avoid — an archive nested inside another as a face
|
|
722
|
+
carries the terminator of every rung it climbed, so each rung is spent once and only
|
|
723
|
+
once (§5.1, ladder depth). A writer MUST place the identifier on the `<svg>` element and
|
|
724
|
+
not on the markup declaration, which takes no attributes: `<svg id=sfz-data><![CDATA[`.
|
|
725
|
+
|
|
726
|
+
Two properties of the CDATA section state are worth stating because a writer is tempted
|
|
727
|
+
to guard against both and needs neither. Sections do not nest, so a `<![CDATA[` inside
|
|
728
|
+
the payload is text like any other — the start-pattern test on this rung is the same
|
|
729
|
+
conservatism the raw-text rungs get, not a necessity. And trailing brackets are safe: a
|
|
730
|
+
payload ending `]]` against the writer's `]]>` produces `]]]]>`, and the tokenizer's
|
|
731
|
+
CDATA section end state emits the payload's own two brackets before closing, so the
|
|
732
|
+
recovered bytes are exact.
|
|
733
|
+
|
|
734
|
+
Verified in Blink, Gecko and WebKit, and against html5lib: a universal-mode archive on
|
|
735
|
+
this rung recovers from the parsed document byte for byte, with the checksum of §4.5
|
|
736
|
+
matching, indistinguishably from the same archive on the comment rung. The same probe
|
|
737
|
+
covered a payload holding every byte value, every rung's patterns, and the near-misses
|
|
738
|
+
`]]x>`, `] ]>`, `]>` and `]]`, in both the prologue position and mid-document.
|
|
739
|
+
|
|
714
740
|
Two things about the ladder *are* required. Whatever order a writer gives the seven
|
|
715
741
|
closable rungs, it MUST apply the selection test below to every rung it considers, and
|
|
716
742
|
MUST keep `<plaintext>` available as the rung of last resort: §6.2's termination
|
|
@@ -730,14 +756,15 @@ what differs is how far the ladder goes:
|
|
|
730
756
|
- **The ZIP region** rejects a rung whose *end* pattern the payload contains, and also
|
|
731
757
|
a rung whose *start* pattern it contains. A rung's start pattern is the tag's opening
|
|
732
758
|
delimiter and name, without attributes: `<!--` for the comment, then `<script`,
|
|
733
|
-
`<style`, `<noframes`, `<noembed`, `<iframe`, `<xmp` for the elements
|
|
759
|
+
`<style`, `<noframes`, `<noembed`, `<iframe`, `<xmp` for the elements, and
|
|
760
|
+
`<![CDATA[` for the CDATA rung.
|
|
734
761
|
|
|
735
762
|
`<plaintext>` is exempt from **both** tests. It has no terminator to occur and no
|
|
736
763
|
tokenizer states to escape into — a `<plaintext` inside a `<plaintext>` is inert
|
|
737
764
|
text like everything else — so no payload can defeat it. §6.2's termination argument
|
|
738
765
|
rests on that exemption.
|
|
739
766
|
|
|
740
|
-
The other
|
|
767
|
+
The other eight are all tested, and a writer MUST test all eight rather than the one
|
|
741
768
|
that needs it. The `<script>` rung needs it
|
|
742
769
|
to be correct at all, because script data has escape states no other rung has: `<!--`
|
|
743
770
|
in script data enters *script data escaped*, and a `<script` after that enters *script
|
|
@@ -746,7 +773,8 @@ what differs is how far the ladder goes:
|
|
|
746
773
|
and the wrapper then swallows its own end tag, the extra-data element and the rest of
|
|
747
774
|
the document. On the other six the start test is genuine conservatism: a nested `<!--`
|
|
748
775
|
is a parse error inside a comment but does not close it, and the raw-text rungs hold a
|
|
749
|
-
flat run of characters with no states at all
|
|
776
|
+
flat run of characters with no states at all, while CDATA sections do not nest. The
|
|
777
|
+
rule is uniform deliberately: the
|
|
750
778
|
exemption would save one pattern match per rung on bytes already in memory, at the
|
|
751
779
|
cost of a special case an implementer has to remember correctly about the single rung
|
|
752
780
|
where forgetting it destroys the document. An earlier draft offered exactly that
|
|
@@ -758,8 +786,12 @@ what differs is how far the ladder goes:
|
|
|
758
786
|
the rest of the document, title, bootstrap and extra-data element included — but the
|
|
759
787
|
`<plaintext>` rung is excluded from their ladder: those payloads sit in the middle
|
|
760
788
|
of the file, so a wrapper that can never close is not an option. When no rung fits,
|
|
761
|
-
the
|
|
762
|
-
|
|
789
|
+
the writer MUST omit the face and emit the archive without it. It MUST NOT write the
|
|
790
|
+
payload bare. Bare renders acceptably — the blank-page backstop of §4.1 keeps it
|
|
791
|
+
invisible — but the payload's markup joins the document, and a payload that is itself
|
|
792
|
+
a SingleFile archive then contributes an `sfz-data` node ahead of the file's own. A
|
|
793
|
+
reader looking for one node finds two, takes the first, and returns an archive that
|
|
794
|
+
passes every check it has (§7.4). A face is a convenience; the archive is not.
|
|
763
795
|
|
|
764
796
|
The comment rung has one restriction more than a terminator. HTML forbids comment text
|
|
765
797
|
that *starts* with `>` or `->`, and the tokenizer enforces it: it closes the comment
|
|
@@ -767,9 +799,14 @@ right there, spilling the payload into the parser. What starts the comment diffe
|
|
|
767
799
|
payload — the ZIP region begins with the identifier, the PDF face with a local file
|
|
768
800
|
header or `%PDF-` — but the PNG face begins with the CRC of the chunk carrying the
|
|
769
801
|
start tag, four bytes that are only settled once the tag is chosen, and one in 256 of
|
|
770
|
-
them is `>`. A writer using a comment there MUST compute that checksum and
|
|
771
|
-
|
|
772
|
-
|
|
802
|
+
them is `>`. A writer using a comment there MUST compute that checksum and leave the
|
|
803
|
+
comment rung when it opens with `>` or `->`. It MUST leave it by *resuming the rung
|
|
804
|
+
search* below it, not by taking the rung that follows: the checksum says only that the
|
|
805
|
+
comment is unusable, and which rung is usable remains the payload's to say. The test
|
|
806
|
+
itself cannot cascade — only the comment rung carries the restriction, and every rung
|
|
807
|
+
below it is an element — but the payload's terminators still apply, and a payload
|
|
808
|
+
holding `</script>` sends a writer that stepped rather than searched onto the one rung
|
|
809
|
+
it is guaranteed to close. HTML also forbids comment text ending
|
|
773
810
|
with `<!-`, which the terminator check covers by testing that pattern anchored at the
|
|
774
811
|
payload's end.
|
|
775
812
|
|
|
@@ -785,6 +822,18 @@ Verified on a build forced onto this rung with a screenshot embedded: the file e
|
|
|
785
822
|
`49 45 4e 44 ae 42 60 82`, decodes as a PNG, and its archive extracts from the parsed
|
|
786
823
|
page. The termination argument of §6.2 therefore holds for the faced variants too.
|
|
787
824
|
|
|
825
|
+
The ladder has a depth, and nesting reaches it. An archive used as the PDF or PNG
|
|
826
|
+
payload of another one carries the terminator of every rung its own faces climbed
|
|
827
|
+
through, and `-->`, `</script>` and `</style>` besides, which every prologue emits.
|
|
828
|
+
Each level of nesting therefore burns exactly one rung, and the eight a face may use
|
|
829
|
+
run out at the sixth: the reference writer selects `<!--`, then `<noframes>`,
|
|
830
|
+
`<noembed>`, `<iframe>`, `<xmp>`, the CDATA rung, and then has nothing left. No ladder
|
|
831
|
+
of fixed length avoids this; adding a rung moves the limit by one level, which is the
|
|
832
|
+
limit of what the CDATA rung buys here — its value is that real payloads rarely hold
|
|
833
|
+
`]]>`, not that it makes nesting unbounded. That is why the paragraph
|
|
834
|
+
above states a MUST rather than a quality-of-implementation preference — exhaustion is
|
|
835
|
+
reachable by construction, not only by a payload built to provoke it.
|
|
836
|
+
|
|
788
837
|
The check MUST be made against the payload's final bytes, for every payload the
|
|
789
838
|
writer hides and in every variant that hides one. Every rejection restarts the build
|
|
790
839
|
(§6): the wrapper choice changes the bytes preceding the archive, so the archive must
|
|
@@ -909,8 +958,8 @@ inverse mapping the extractor applies is, for the declared charset:
|
|
|
909
958
|
3. **U+FFFD → 0x00.** No byte decodes to U+FFFD under a qualifying encoding (§2.1), so
|
|
910
959
|
the replacement character can only have come from a NUL byte. This holds because
|
|
911
960
|
the payload is inside a wrapper: in every tokenizer state the ladder of §5.1
|
|
912
|
-
produces — comment, raw text, script data, plaintext — the parser
|
|
913
|
-
U+FFFD.
|
|
961
|
+
produces — comment, raw text, script data, CDATA section, plaintext — the parser
|
|
962
|
+
replaces NUL with U+FFFD.
|
|
914
963
|
4. **Newlines from the payload.** The parser normalizes CR and CR LF to LF, so the
|
|
915
964
|
original byte sequence is unrecoverable from the text alone; each newline consumes
|
|
916
965
|
the next 2-bit code (0 = LF, 1 = CR, 2 = CR LF).
|
|
@@ -1236,6 +1285,7 @@ only if it affects the bytes the page is built from:
|
|
|
1236
1285
|
| A `tEXt` chunk CRC does not match, or a chunk holds bytes PNG does not permit (§4.4) | Irrelevant to extraction; a reader of the archive MAY ignore both |
|
|
1237
1286
|
| `page.pdf` is present but its data does not begin with `%PDF-` | Not an error. The entry is data like any other |
|
|
1238
1287
|
| `index.html` is present without `manifest.json` | **MUST** still extract (§7.1) |
|
|
1288
|
+
| More than one node carries the `sfz-data` identifier | **MUST NOT** extract either silently. A conforming writer emits one (§5.1), so a second is a payload that escaped its wrapper — most often a nested archive written by a writer that emitted a face bare. Both candidates extract cleanly and check out, and the checksums say nothing about which one the file was built around |
|
|
1239
1289
|
| The recovered region (universal mode) disagrees with the same bytes read directly, in the EOCD's two comment-length bytes only | Expected, not an error. A recovered region always declares a zero-length comment (§4.5), so it differs here from any archive written in the declared form (§4.2). Compare the two only up to those bytes |
|
|
1240
1290
|
| The recovered region (universal mode) disagrees with the same bytes read directly, anywhere else | The file is not well-formed, whichever side is at fault, and a reader that has both MUST NOT silently merge them or pick per entry. Prefer the direct read — it is the writer's own output, where the recovered region is a reconstruction of it — and surface the disagreement rather than displaying either as intact |
|
|
1241
1291
|
|
|
@@ -1432,6 +1482,9 @@ predicts.
|
|
|
1432
1482
|
| August 2026 | Core 1.5.108: the recovery payload stops two bytes short of the End Of Central Directory record, excluding its comment-length field (§1.3), which lets universal-mode archives declare their appended data as the archive comment — a writer option, for `java.util.zip` and the readers that reject undeclared trailing bytes (§4.2) |
|
|
1433
1483
|
| August 2026 | Core 1.5.108: the PDF and PNG faces test a wrapper rung's start pattern as well as its end pattern, closing the same script-data escape hole the ZIP region was already guarded against — a face payload holding `<!--` and then `<script` took the `<script type=sfz-data>` rung and swallowed the rest of the document (§5.1) |
|
|
1434
1484
|
| August 2026 | Core 1.5.108: the retry loop discards a relocation reservation at most once per build, so a payload sitting on the appended-data boundary cannot oscillate between the two placements forever (§6.2) |
|
|
1485
|
+
| August 2026 | Core 1.5.110: a PDF or PNG face whose payload names every rung is dropped instead of written bare (§5.1). Found by nesting an archive inside itself as both faces: the fifth level exhausts the ladder, and readers then extracted the fourth level's archive — checksums intact, no way to tell (§7.4) |
|
|
1486
|
+
| August 2026 | Core 1.5.110: a PNG face leaving the comment rung on its checksum resumes the rung search instead of taking the next rung untested (§5.1). Taking it put a payload holding `</script>` on the script rung, where its own bytes closed the wrapper 93 bytes in and left the image data, the chunk framing and the whole ZIP region to the parser |
|
|
1487
|
+
| August 2026 | Core 1.5.110: `<svg><![CDATA[` joins the ladder above `<plaintext>` (§5.1) — the one rung whose terminator, `]]>`, real payloads rarely carry. It gives a payload naming every element rung somewhere to go that does not cost the appended-data placement, and moves the self-nesting limit from the fifth level to the sixth |
|
|
1435
1488
|
|
|
1436
1489
|
This document was itself revised in August 2026, against core 1.5.108, after several
|
|
1437
1490
|
independent reviews. One of them was a reader built from this specification alone, with
|
|
@@ -41,7 +41,11 @@ const REGEXP_COMMA = /\s*,\s*/;
|
|
|
41
41
|
const REGEXP_DASH = /-/;
|
|
42
42
|
const REGEXP_QUESTION_MARK = /\?/g;
|
|
43
43
|
const REGEXP_STARTS_U_PLUS = /^U\+/i;
|
|
44
|
+
const REGEXP_CUSTOM_PROPERTY = /var\((--[^),]*)\)/g;
|
|
44
45
|
const VALID_FONT_STYLES = [/^normal$/, /^italic$/, /^oblique$/, /^oblique\s+/];
|
|
46
|
+
// a family name kept when the "font" shorthand cannot be read: it resolves to nothing, so it
|
|
47
|
+
// survives the substitution below and marks the fonts as undetermined instead of unused
|
|
48
|
+
const UNRESOLVED_CUSTOM_PROPERTY_FAMILY = "var(--)";
|
|
45
49
|
|
|
46
50
|
export {
|
|
47
51
|
process
|
|
@@ -268,23 +272,36 @@ function getFontFamilyNames(declarations, options) {
|
|
|
268
272
|
}
|
|
269
273
|
const font = declarations.children.filter(node => node.property == "font").tail;
|
|
270
274
|
if (font && font.data && font.data.value) {
|
|
275
|
+
const fontValue = cssTree.generate(font.data.value);
|
|
271
276
|
try {
|
|
272
277
|
let value = font.data.value;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
value = cssTree.parse(globalThis.getComputedStyle(options.doc.body).getPropertyValue(matchedVar[1]), { context: "value" });
|
|
278
|
+
const resolvedFontValue = resolveCustomProperties(fontValue, options);
|
|
279
|
+
if (resolvedFontValue != fontValue) {
|
|
280
|
+
value = cssTree.parse(resolvedFontValue, { context: "value" });
|
|
277
281
|
}
|
|
278
282
|
const parsedFont = fontPropertyParser.parse(value);
|
|
279
283
|
parsedFont.family.forEach(familyName => fontFamilyNames.push(helper.normalizeFontFamily(familyName)));
|
|
280
284
|
// eslint-disable-next-line no-unused-vars
|
|
281
285
|
} catch (error) {
|
|
282
|
-
//
|
|
286
|
+
// the shorthand is unreadable, and dropping it here would count the fonts it names as
|
|
287
|
+
// unused: a custom property left in it means the families cannot be determined at all
|
|
288
|
+
if (fontValue.includes("var(")) {
|
|
289
|
+
fontFamilyNames.push(UNRESOLVED_CUSTOM_PROPERTY_FAMILY);
|
|
290
|
+
}
|
|
283
291
|
}
|
|
284
292
|
}
|
|
285
293
|
return fontFamilyNames;
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
function resolveCustomProperties(value, options) {
|
|
297
|
+
if (globalThis.getComputedStyle && options.doc) {
|
|
298
|
+
const computedStyle = globalThis.getComputedStyle(options.doc.body);
|
|
299
|
+
return value.replace(REGEXP_CUSTOM_PROPERTY, (property, name) => computedStyle.getPropertyValue(name) || property);
|
|
300
|
+
} else {
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
288
305
|
function parseFamilyNames(fontFamilyNameTokenData, fontFamilyNames) {
|
|
289
306
|
let nextToken = fontFamilyNameTokenData.children.head;
|
|
290
307
|
while (nextToken) {
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "single-file-core",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.110",
|
|
4
4
|
"description": "SingleFile Core",
|
|
5
5
|
"author": "Gildas Lormeau",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js",
|
|
8
|
+
"test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js",
|
|
9
9
|
"bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
|
|
10
10
|
"bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
|
|
11
11
|
"bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
|
|
@@ -83,6 +83,11 @@ async function createPagesArchive(pages, options) {
|
|
|
83
83
|
selfExtractingArchive: options.selfExtractingArchive,
|
|
84
84
|
extractDataFromPage: options.extractDataFromPage,
|
|
85
85
|
preventAppendedData: options.preventAppendedData,
|
|
86
|
+
declareAppendedData: options.declareAppendedData,
|
|
87
|
+
// the faces belong to the archive and are supplied by the caller: one rendered from a
|
|
88
|
+
// single page would misrepresent every other page, so nothing is generated here
|
|
89
|
+
embeddedPdf: options.embeddedPdf,
|
|
90
|
+
embeddedImage: options.embeddedImage,
|
|
86
91
|
includeBOM: options.includeBOM,
|
|
87
92
|
insertMetaCSP: options.insertMetaCSP,
|
|
88
93
|
insertCanonicalLink: options.insertCanonicalLink,
|
|
@@ -53,6 +53,11 @@ const SCRIPT_PATH = "/lib/single-file-zip.min.js";
|
|
|
53
53
|
// they drop a comment: macOS Spotlight indexes the content of every other rung, and textutil
|
|
54
54
|
// also reads the last two. Neither is applied nor executed, the type is neither CSS nor
|
|
55
55
|
// JavaScript. <plaintext> stays last, it is the only rung that cannot be closed
|
|
56
|
+
// the CDATA section sits second to last, and it is the one rung a payload is unlikely to hold
|
|
57
|
+
// the terminator of: every other rung ends on a sequence that real documents carry, which is
|
|
58
|
+
// also why each level of self-nesting burns one. It is low in the ladder only because text
|
|
59
|
+
// extractors read its content; nothing about the parse is weaker. A CDATA section is only a
|
|
60
|
+
// CDATA section in foreign content, hence the <svg> element around it
|
|
56
61
|
const EXTRA_DATA_TAGS = [
|
|
57
62
|
["<script type=sfz-data>", "</script>"],
|
|
58
63
|
["<style type=sfz-data>", "</style>"],
|
|
@@ -60,6 +65,7 @@ const EXTRA_DATA_TAGS = [
|
|
|
60
65
|
["<noembed>", "</noembed>"],
|
|
61
66
|
["<iframe>", "</iframe>"],
|
|
62
67
|
["<xmp>", "</xmp>"],
|
|
68
|
+
["<svg><![CDATA[", "]]></svg>"],
|
|
63
69
|
["<plaintext>", "</plaintext>"]
|
|
64
70
|
];
|
|
65
71
|
const EMBEDDED_DATA_TAGS = [
|
|
@@ -76,6 +82,11 @@ const EXTRA_DATA_REGEXPS = [
|
|
|
76
82
|
[/<noembed/i, /<\/noembed[\t\n\f\r />]/i],
|
|
77
83
|
[/<iframe/i, /<\/iframe[\t\n\f\r />]/i],
|
|
78
84
|
[/<xmp/i, /<\/xmp[\t\n\f\r />]/i],
|
|
85
|
+
// a CDATA section ends on "]]>" and nothing else, so the terminator is the whole test; the
|
|
86
|
+
// start pattern is the same conservatism the raw text rungs get, since a nested "<![CDATA["
|
|
87
|
+
// is text like any other. Trailing brackets are safe: a payload ending "]]" against the
|
|
88
|
+
// writer's "]]>" gives "]]]]>", and the tokenizer emits the payload's own two before closing
|
|
89
|
+
[/<!\[CDATA\[/i, /\]\]>/],
|
|
79
90
|
[/<plaintext/i, /<\/plaintext[\t\n\f\r />]/i]
|
|
80
91
|
];
|
|
81
92
|
// a comment must also not end with "<!-", the last of the restrictions HTML puts on comment
|
|
@@ -102,6 +113,7 @@ const MAX_APPENDED_DATA_LENGTH = 65535;
|
|
|
102
113
|
const PDF_ENTRY_FILENAME = "page.pdf";
|
|
103
114
|
const PDF_HEADER_MAX_OFFSET = 1024;
|
|
104
115
|
const MINIMAL_DOCTYPE = "<!DOCTYPE html>";
|
|
116
|
+
const UNHIDDEN_FACE_WARNING_MESSAGE = "SingleFile: the archive was written without a face, its data names every wrapper tag:";
|
|
105
117
|
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
106
118
|
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
|
|
107
119
|
const END_OF_CENTRAL_DIR_SIGNATURE = 0x06054b50;
|
|
@@ -110,9 +122,34 @@ const ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE = 0x07064b50;
|
|
|
110
122
|
|
|
111
123
|
const browser = globalThis.browser;
|
|
112
124
|
|
|
125
|
+
// the options process() accepts from its caller. single-file.js builds its argument from this
|
|
126
|
+
// list instead of a literal, because an option added here and forgotten there is undefined at
|
|
127
|
+
// every call site and the feature silently does nothing: that is how declareAppendedData and
|
|
128
|
+
// includeBOM both shipped inert. Options the module sets on itself between passes, and options
|
|
129
|
+
// the packager supplies, are deliberately absent
|
|
130
|
+
const PROCESS_OPTION_NAMES = [
|
|
131
|
+
"createRootDirectory",
|
|
132
|
+
"declareAppendedData",
|
|
133
|
+
"disableCompression",
|
|
134
|
+
"embeddedImage",
|
|
135
|
+
"embeddedPdf",
|
|
136
|
+
"extractDataFromPage",
|
|
137
|
+
"includeBOM",
|
|
138
|
+
"insertCanonicalLink",
|
|
139
|
+
"insertMetaCSP",
|
|
140
|
+
"insertMetaNoIndex",
|
|
141
|
+
"insertTextBody",
|
|
142
|
+
"password",
|
|
143
|
+
"preventAppendedData",
|
|
144
|
+
"selfExtractingArchive",
|
|
145
|
+
"url",
|
|
146
|
+
"zipScript"
|
|
147
|
+
];
|
|
148
|
+
|
|
113
149
|
export {
|
|
114
150
|
process,
|
|
115
|
-
createArchive
|
|
151
|
+
createArchive,
|
|
152
|
+
PROCESS_OPTION_NAMES
|
|
116
153
|
};
|
|
117
154
|
|
|
118
155
|
async function process(pageData, options, lastModDate = new Date()) {
|
|
@@ -136,32 +173,29 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
|
|
|
136
173
|
let extraDataOffset, extraData, embeddedImageDataOffset, endTag, pdfEntry;
|
|
137
174
|
if (options.embeddedImage) {
|
|
138
175
|
options.embeddedImage = new Uint8Array(options.embeddedImage);
|
|
139
|
-
|
|
176
|
+
}
|
|
177
|
+
// the whole chunk is built before the first byte of the image is written, because building it
|
|
178
|
+
// is what settles the rung, and the search can end with no rung at all: the image is then left
|
|
179
|
+
// out altogether rather than written unwrapped
|
|
180
|
+
let imageChunk;
|
|
181
|
+
if (options.embeddedImage && options.selfExtractingArchive) {
|
|
182
|
+
imageChunk = getImageHTMLChunk(pageData, options, lastModDate);
|
|
183
|
+
if (!imageChunk) {
|
|
184
|
+
dropUnhiddenFace(options, "embeddedImage");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (options.embeddedImage) {
|
|
188
|
+
const embeddedImageData = getEmbeddedImageData(options.embeddedImage);
|
|
140
189
|
await writeData(zipDataWriter.writable, options.embeddedImage.slice(0, PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH));
|
|
141
190
|
if (options.selfExtractingArchive) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
do {
|
|
146
|
-
[startTag, endTag] = tagIndex == -1 ? ["", ""] : EMBEDDED_DATA_TAGS[tagIndex];
|
|
147
|
-
startHTMLData = getStartHTMLArray(pageData, options, lastModDate, startTag);
|
|
148
|
-
htmlData = new Uint8Array([...getLength(startHTMLData.htmlArray.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x4e, 0x47, 0], ...startHTMLData.htmlArray]);
|
|
149
|
-
htmlDataCRC = getCRC32(htmlData, 4);
|
|
150
|
-
// what follows the start tag is this chunk's CRC, four bytes only known once the
|
|
151
|
-
// tag is chosen: a comment they open with ">" or "->" is closed by the parser
|
|
152
|
-
// there and then, leaving the image data to be read as markup
|
|
153
|
-
abruptComment = tagIndex == 0 && (htmlDataCRC[0] == 0x3e || (htmlDataCRC[0] == 0x2d && htmlDataCRC[1] == 0x3e));
|
|
154
|
-
if (abruptComment) {
|
|
155
|
-
tagIndex++;
|
|
156
|
-
}
|
|
157
|
-
} while (abruptComment);
|
|
158
|
-
if (startHTMLData.pdfEntry) {
|
|
159
|
-
pdfEntry = startHTMLData.pdfEntry;
|
|
191
|
+
endTag = imageChunk.endTag;
|
|
192
|
+
if (imageChunk.startHTMLData.pdfEntry) {
|
|
193
|
+
pdfEntry = imageChunk.startHTMLData.pdfEntry;
|
|
160
194
|
// the htmlArray starts after the 4-byte length and the 8-byte type of the tEXt chunk
|
|
161
195
|
pdfEntry.offset += zipDataWriter.offset + 12;
|
|
162
196
|
}
|
|
163
|
-
await writeData(zipDataWriter.writable, htmlData);
|
|
164
|
-
await writeData(zipDataWriter.writable, htmlDataCRC);
|
|
197
|
+
await writeData(zipDataWriter.writable, imageChunk.htmlData);
|
|
198
|
+
await writeData(zipDataWriter.writable, imageChunk.htmlDataCRC);
|
|
165
199
|
} else if (options.embeddedPdf) {
|
|
166
200
|
const data = new Uint8Array([...getLength(options.embeddedPdf.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x44, 0x46, 0], ...new Uint8Array(options.embeddedPdf)]);
|
|
167
201
|
await writeData(zipDataWriter.writable, data);
|
|
@@ -517,9 +551,13 @@ async function prependHTMLData(pageData, zipDataWriter, script, options, lastMod
|
|
|
517
551
|
// the extractor finds the zip data by identifier instead of by its position in the tree: an
|
|
518
552
|
// element carries it as an attribute, a comment as the first characters of its data
|
|
519
553
|
function getDataStartTag([startTag]) {
|
|
520
|
-
|
|
521
|
-
startTag + DATA_IDENTIFIER
|
|
522
|
-
|
|
554
|
+
if (startTag == "<!--") {
|
|
555
|
+
return startTag + DATA_IDENTIFIER;
|
|
556
|
+
}
|
|
557
|
+
// the attribute belongs to the element that opens the wrapper, which is not always the whole
|
|
558
|
+
// start tag: the CDATA rung opens with an <svg> and then a markup declaration that takes none
|
|
559
|
+
const tagEnd = startTag.indexOf(">");
|
|
560
|
+
return startTag.slice(0, tagEnd) + " id=" + DATA_IDENTIFIER + startTag.slice(tagEnd);
|
|
523
561
|
}
|
|
524
562
|
|
|
525
563
|
function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
|
|
@@ -543,24 +581,30 @@ function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
|
|
|
543
581
|
const localHeader = pdfEntry ? pdfEntry.localHeader : new Uint8Array(0);
|
|
544
582
|
const embeddedPdfText = TEXT_DECODER.decode(localHeader) + TEXT_DECODER.decode(embeddedPdf);
|
|
545
583
|
const pdfTagIndex = findEmbeddedDataTagIndex(embeddedPdfText);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
584
|
+
if (pdfTagIndex == -1) {
|
|
585
|
+
dropUnhiddenFace(options, "embeddedPdf");
|
|
586
|
+
pdfEntry = undefined;
|
|
587
|
+
} else {
|
|
588
|
+
const [pdfStartTag, pdfEndTag] = EMBEDDED_DATA_TAGS[pdfTagIndex];
|
|
589
|
+
let htmlArray1 = new TextEncoder().encode(html + pdfStartTag);
|
|
590
|
+
if (htmlArray1.length + localHeader.length > PDF_HEADER_MAX_OFFSET) {
|
|
591
|
+
// PDF readers only scan the start of the file for the %PDF- header, and the page
|
|
592
|
+
// doctype is copied verbatim: it is the one part of the prefix with no bound, so a
|
|
593
|
+
// long one is replaced rather than pushing the header out of the scan window
|
|
594
|
+
htmlArray1 = new TextEncoder().encode(bom + MINIMAL_DOCTYPE + documentStart + pdfStartTag);
|
|
595
|
+
}
|
|
596
|
+
const htmlArray2 = new TextEncoder().encode(pdfEndTag + comment + htmlHeadData + startTag);
|
|
597
|
+
htmlArray = new Uint8Array(htmlArray1.length + localHeader.length + embeddedPdf.length + htmlArray2.length);
|
|
598
|
+
htmlArray.set(htmlArray1);
|
|
599
|
+
htmlArray.set(localHeader, htmlArray1.length);
|
|
600
|
+
htmlArray.set(embeddedPdf, htmlArray1.length + localHeader.length);
|
|
601
|
+
htmlArray.set(htmlArray2, htmlArray1.length + localHeader.length + embeddedPdf.length);
|
|
602
|
+
if (pdfEntry) {
|
|
603
|
+
pdfEntry.offset = htmlArray1.length;
|
|
604
|
+
}
|
|
562
605
|
}
|
|
563
|
-
}
|
|
606
|
+
}
|
|
607
|
+
if (!options.embeddedPdf) {
|
|
564
608
|
htmlArray = new TextEncoder().encode(html + comment + htmlHeadData + startTag);
|
|
565
609
|
}
|
|
566
610
|
return { htmlArray, pdfEntry };
|
|
@@ -624,8 +668,45 @@ function findExtraDataTags(textContent, pageData, options, script, writeEntries,
|
|
|
624
668
|
// a rung is rejected on its end pattern, which terminates the wrapper, and on its start
|
|
625
669
|
// pattern: script data has escape states the raw text rungs do not have, where "<!--"
|
|
626
670
|
// followed by "<script" leaves "</script>" unable to close the element at all
|
|
627
|
-
function findEmbeddedDataTagIndex(text) {
|
|
628
|
-
|
|
671
|
+
function findEmbeddedDataTagIndex(text, fromIndex = 0) {
|
|
672
|
+
const tagIndex = EMBEDDED_DATA_REGEXPS.slice(fromIndex, -1).findIndex(([startRegExp, endRegExp]) => !text.match(startRegExp) && !text.match(endRegExp));
|
|
673
|
+
return tagIndex == -1 ? -1 : tagIndex + fromIndex;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// a face exists only while a rung can hide it. When the payload names every rung, the older
|
|
677
|
+
// fallback emitted it unwrapped, on the grounds that the page still rendered: but the payload's
|
|
678
|
+
// markup then joins the document, and a payload that is itself an archive contributes an
|
|
679
|
+
// sfz-data node ahead of this file's own. A reader takes that one and extracts it, checksum and
|
|
680
|
+
// all, with nothing to say the archive it returned is not the archive the file was built around.
|
|
681
|
+
// Dropping the face costs a picture; keeping it costs the archive
|
|
682
|
+
// what follows the start tag is the checksum of the chunk carrying it, four bytes only known once
|
|
683
|
+
// the tag is chosen: a comment they open with ">" or "->" is closed by the parser there and then,
|
|
684
|
+
// leaving the image data to be read as markup. Stepping past the comment rung means searching from
|
|
685
|
+
// the next one, not taking it — a rung qualifies on the payload, and the payload had no say in
|
|
686
|
+
// which rung the checksum sent the writer to
|
|
687
|
+
function getImageHTMLChunk(pageData, options, lastModDate) {
|
|
688
|
+
const embeddedImageText = TEXT_DECODER.decode(getEmbeddedImageData(options.embeddedImage));
|
|
689
|
+
let tagIndex = findEmbeddedDataTagIndex(embeddedImageText);
|
|
690
|
+
while (tagIndex != -1) {
|
|
691
|
+
const [startTag, endTag] = EMBEDDED_DATA_TAGS[tagIndex];
|
|
692
|
+
const startHTMLData = getStartHTMLArray(pageData, options, lastModDate, startTag);
|
|
693
|
+
const htmlData = new Uint8Array([...getLength(startHTMLData.htmlArray.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x4e, 0x47, 0], ...startHTMLData.htmlArray]);
|
|
694
|
+
const htmlDataCRC = getCRC32(htmlData, 4);
|
|
695
|
+
if (tagIndex == 0 && (htmlDataCRC[0] == 0x3e || (htmlDataCRC[0] == 0x2d && htmlDataCRC[1] == 0x3e))) {
|
|
696
|
+
tagIndex = findEmbeddedDataTagIndex(embeddedImageText, tagIndex + 1);
|
|
697
|
+
} else {
|
|
698
|
+
return { endTag, startHTMLData, htmlData, htmlDataCRC };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function getEmbeddedImageData(embeddedImage) {
|
|
704
|
+
return embeddedImage.slice(PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH, embeddedImage.length - PNG_IEND_LENGTH);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function dropUnhiddenFace(options, name) {
|
|
708
|
+
delete options[name];
|
|
709
|
+
console.warn(UNHIDDEN_FACE_WARNING_MESSAGE, name); // eslint-disable-line no-console
|
|
629
710
|
}
|
|
630
711
|
|
|
631
712
|
async function writeData(writable, array) {
|
package/single-file.js
CHANGED
|
@@ -111,22 +111,11 @@ async function getPageData(options = {}, initOptions, doc, win) {
|
|
|
111
111
|
}
|
|
112
112
|
const pageData = await processor.getPageData();
|
|
113
113
|
if (options.compressContent) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
disableCompression: options.disableCompression,
|
|
120
|
-
extractDataFromPage: options.extractDataFromPage,
|
|
121
|
-
preventAppendedData: options.preventAppendedData,
|
|
122
|
-
insertCanonicalLink: options.insertCanonicalLink,
|
|
123
|
-
insertMetaNoIndex: options.insertMetaNoIndex,
|
|
124
|
-
insertMetaCSP: options.insertMetaCSP,
|
|
125
|
-
password: options.password,
|
|
126
|
-
zipScript: options.zipScript,
|
|
127
|
-
embeddedImage: options.embeddedImage,
|
|
128
|
-
embeddedPdf: options.embeddedPdf
|
|
129
|
-
});
|
|
114
|
+
const compressionOptions = {};
|
|
115
|
+
for (const optionName of processors.compression.PROCESS_OPTION_NAMES) {
|
|
116
|
+
compressionOptions[optionName] = options[optionName];
|
|
117
|
+
}
|
|
118
|
+
const blob = await processors.compression.process(pageData, compressionOptions);
|
|
130
119
|
delete pageData.resources;
|
|
131
120
|
let arrayBuffer;
|
|
132
121
|
if (globalThis.FileReader) {
|
|
@@ -26,6 +26,7 @@ any check failed.
|
|
|
26
26
|
| `format-rules.js` | The rules of the format: charset round trip, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
|
|
27
27
|
| `stored-trigger.js` | That a stored (uncompressed) entry whose bytes contain a rung's pattern moves the writer to the right rung. |
|
|
28
28
|
| `check-determinism.js` | That the same inputs produce the same bytes, and that the levers which should change the output do. |
|
|
29
|
+
| `option-wiring.js` | That every option `compression.js` reads is either declared as a caller option or classified as internal, and that `single-file.js` still builds its argument from that declaration. Guards the layer the other three suites sit below. |
|
|
29
30
|
|
|
30
31
|
## The tools
|
|
31
32
|
|
|
@@ -166,7 +166,7 @@ function countIdentifiers(text) {
|
|
|
166
166
|
// the last rung is exempt from both tests: it has no terminator and no tokenizer
|
|
167
167
|
// states, so a payload naming every rung still fits inside it
|
|
168
168
|
const content = new Uint8Array(4096).fill(0x21);
|
|
169
|
-
content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp<plaintext"), 128);
|
|
169
|
+
content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp<![CDATA[<plaintext"), 128);
|
|
170
170
|
const options = makeOptions();
|
|
171
171
|
const pageData = makePageData(19, 4 * 1024);
|
|
172
172
|
pageData.resources.images.push({ name: "images/all-starts.png", extension: ".png", content });
|
|
@@ -176,6 +176,33 @@ function countIdentifiers(text) {
|
|
|
176
176
|
check("the last rung still carries the identifier", decodeText(bytes).includes("<plaintext id=sfz-data>PK"), true);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// the CDATA rung is the last one that can be closed, so it is what stands between a payload
|
|
180
|
+
// naming every element rung and <plaintext>, whose selection costs the appended-data placement.
|
|
181
|
+
// Its identifier goes on the svg, not on the markup declaration, which takes no attributes
|
|
182
|
+
{
|
|
183
|
+
const content = new Uint8Array(4096).fill(0x21);
|
|
184
|
+
content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp"), 128);
|
|
185
|
+
const options = makeOptions();
|
|
186
|
+
const pageData = makePageData(24, 4 * 1024);
|
|
187
|
+
pageData.resources.images.push({ name: "images/no-cdata.png", extension: ".png", content });
|
|
188
|
+
const { bytes } = await runProcess(pageData, options);
|
|
189
|
+
check("a payload naming every element rung stops at the cdata rung", options.extractDataFromPageTags[0], "<svg><![CDATA[");
|
|
190
|
+
check("the cdata rung identifies the svg, not the declaration", decodeText(bytes).includes("<svg id=sfz-data><![CDATA[PK"), true);
|
|
191
|
+
check("the cdata rung leaves the appended data placement alone", options.preventAppendedData !== true, true);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// the terminator is the whole test for this rung, so a payload holding it must step past
|
|
195
|
+
{
|
|
196
|
+
const content = new Uint8Array(4096).fill(0x21);
|
|
197
|
+
content.set(new TextEncoder().encode("<!--<script<style<noframes<noembed<iframe<xmp]]>"), 128);
|
|
198
|
+
const options = makeOptions();
|
|
199
|
+
const pageData = makePageData(25, 4 * 1024);
|
|
200
|
+
pageData.resources.images.push({ name: "images/cdata-end.png", extension: ".png", content });
|
|
201
|
+
const { bytes } = await runProcess(pageData, options);
|
|
202
|
+
check("a payload holding \"]]>\" defeats the cdata rung", options.extractDataFromPageTags[0], "<plaintext>");
|
|
203
|
+
check("the rung below takes it", decodeText(bytes).includes("<plaintext id=sfz-data>PK"), true);
|
|
204
|
+
}
|
|
205
|
+
|
|
179
206
|
// script data has escape states the raw text rungs do not: "<!--" then "<script" in a
|
|
180
207
|
// payload leaves "</script>" unable to close the element, so a face carrying both must
|
|
181
208
|
// step past the script rung even though it holds no closer at all
|
|
@@ -202,6 +229,42 @@ const DOUBLE_ESCAPE = "--> <!-- <script ";
|
|
|
202
229
|
check("the png face takes no script rung", decodeText(bytes).includes("<script type=sfz-data>"), false);
|
|
203
230
|
}
|
|
204
231
|
|
|
232
|
+
// a face is hidden by the same ladder as the zip data, minus the rung that cannot be closed:
|
|
233
|
+
// a payload naming all seven of the rest leaves nowhere to put it. Writing it unwrapped was the
|
|
234
|
+
// older behaviour, and it is the dangerous one, because the payload's own markup then joins the
|
|
235
|
+
// document. These payloads carry the identifier the way a nested archive does: a reader looking
|
|
236
|
+
// for one node finds two, takes the first, and extracts an archive that checksums
|
|
237
|
+
const ALL_FACE_RUNGS = "<!--sfz-data<script<style<noframes<noembed<iframe<xmp<![CDATA[";
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
const embeddedPdf = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<< /X (" + ALL_FACE_RUNGS + ") >>\nendobj\ntrailer\n<<>>\n%%EOF\n");
|
|
241
|
+
const options = makeOptions({ embeddedPdf });
|
|
242
|
+
const pageData = makePageData(22, 4 * 1024);
|
|
243
|
+
const { bytes } = await runProcess(pageData, options);
|
|
244
|
+
const text = decodeText(bytes);
|
|
245
|
+
check("an unhidable pdf face is dropped", text.includes("%PDF-"), false);
|
|
246
|
+
check("the dropped pdf face leaves one identifier", countIdentifiers(text), 1);
|
|
247
|
+
const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
|
|
248
|
+
const entries = await zipReader.getEntries();
|
|
249
|
+
await zipReader.close();
|
|
250
|
+
check("the archive survives the dropped pdf face", entries.length > 0, true);
|
|
251
|
+
check("no page.pdf entry is left behind", entries.some(entry => entry.filename.endsWith("page.pdf")), false);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
const embeddedImage = new Uint8Array(8 + 25 + 512 + 12);
|
|
256
|
+
embeddedImage.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
257
|
+
embeddedImage.fill(0x41, 33, 33 + 512);
|
|
258
|
+
embeddedImage.set(new TextEncoder().encode(ALL_FACE_RUNGS), 100);
|
|
259
|
+
const options = makeOptions({ embeddedImage });
|
|
260
|
+
const pageData = makePageData(23, 4 * 1024);
|
|
261
|
+
const { bytes } = await runProcess(pageData, options);
|
|
262
|
+
const text = decodeText(bytes);
|
|
263
|
+
check("an unhidable png face is dropped", bytes[0], 0x3c);
|
|
264
|
+
check("the dropped png face leaves one identifier", countIdentifiers(text), 1);
|
|
265
|
+
check("the page keeps its doctype without the png face", text.startsWith("<!DOCTYPE html>"), true);
|
|
266
|
+
}
|
|
267
|
+
|
|
205
268
|
// every rung pattern is matched case-insensitively, because the HTML tokenizer closes an
|
|
206
269
|
// element on any case of its end tag. Dropping that would pick a rung the payload itself
|
|
207
270
|
// terminates, and no other test in this file would notice
|
|
@@ -245,6 +308,29 @@ for (const seed of [147, 148, 616]) {
|
|
|
245
308
|
check("no wrapper comment is closed abruptly, seed " + seed, /<!--(>|->)/.test(decodeText(bytes)), false);
|
|
246
309
|
}
|
|
247
310
|
|
|
311
|
+
// leaving the comment rung is a search, not an increment. The checksum decides only that the
|
|
312
|
+
// comment is unusable; which rung is usable is still the payload's to say, and these payloads
|
|
313
|
+
// terminate the rung immediately below. Taking it on trust put the image data, the chunk framing
|
|
314
|
+
// and the whole ZIP region outside the wrapper, 19KB of it, read by the parser as markup
|
|
315
|
+
for (const seed of [148, 616]) {
|
|
316
|
+
const embeddedImage = randomPng(seed);
|
|
317
|
+
embeddedImage.set(new TextEncoder().encode("</script>"), 100);
|
|
318
|
+
const options = makeOptions({ embeddedImage });
|
|
319
|
+
const pageData = makePageData(seed, 2 * 1024);
|
|
320
|
+
const { bytes } = await runProcess(pageData, options);
|
|
321
|
+
const text = decodeText(bytes);
|
|
322
|
+
check("the step off the comment rung skips what the payload terminates, seed " + seed, text.includes("<script type=sfz-data>"), false);
|
|
323
|
+
check("the step off the comment rung lands on a rung that qualifies, seed " + seed, text.includes("<style type=sfz-data>"), true);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// the control for the pair above: the same seeds, the same step, a payload that leaves the rung
|
|
327
|
+
// below usable — the writer must still take it rather than skip past it
|
|
328
|
+
{
|
|
329
|
+
const options = makeOptions({ embeddedImage: randomPng(148) });
|
|
330
|
+
const { bytes } = await runProcess(makePageData(148, 2 * 1024), options);
|
|
331
|
+
check("the step off the comment rung takes a usable rung", decodeText(bytes).includes("<script type=sfz-data>"), true);
|
|
332
|
+
}
|
|
333
|
+
|
|
248
334
|
function readAppendedData(bytes) {
|
|
249
335
|
const view = new DataView(bytes.buffer, bytes.byteOffset);
|
|
250
336
|
for (let index = bytes.length - 22; index >= 0; index--) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The compression processor is reached through a whitelist in single-file.js. An option this
|
|
2
|
+
// module reads but the whitelist omits is undefined at every call site, so the feature works on
|
|
3
|
+
// the multi-page path (which calls createArchive directly) and silently does nothing on every
|
|
4
|
+
// single-page capture. That is how --declare-appended-data and --include-BOM both shipped inert,
|
|
5
|
+
// and no test caught either: the other suites call compression.process() directly, one layer
|
|
6
|
+
// below the wiring they would have to see.
|
|
7
|
+
import { PROCESS_OPTION_NAMES } from "../../processors/compression/compression.js";
|
|
8
|
+
|
|
9
|
+
// names matched by the source scan that are NOT caller options. Keeping them listed here rather
|
|
10
|
+
// than filtering them out silently is the point of this test: a new name must be classified as
|
|
11
|
+
// one thing or the other before the suite goes green again
|
|
12
|
+
const INTERNAL_OPTION_NAMES = [
|
|
13
|
+
// state the module sets on itself between build passes
|
|
14
|
+
"extraData",
|
|
15
|
+
"extraDataSize",
|
|
16
|
+
"extraDataSizeDropped",
|
|
17
|
+
"extractDataFromPageTags",
|
|
18
|
+
"preventEmbeddedPdfEntry",
|
|
19
|
+
// supplied by compression-packager.js, which calls createArchive directly
|
|
20
|
+
"embeddedScreenshotImage",
|
|
21
|
+
"multiPageArchive",
|
|
22
|
+
// read for the root directory name, but no caller has ever passed it
|
|
23
|
+
"tabId",
|
|
24
|
+
// a different `options` object: the per-entry zip options built inside addFile()
|
|
25
|
+
"comment",
|
|
26
|
+
"level"
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
let failed = false;
|
|
30
|
+
|
|
31
|
+
function check(label, actual, expected) {
|
|
32
|
+
const ok = actual === expected;
|
|
33
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
|
|
34
|
+
failed ||= !ok;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const compressionSource = await Deno.readTextFile(new URL("../../processors/compression/compression.js", import.meta.url));
|
|
38
|
+
const singleFileSource = await Deno.readTextFile(new URL("../../single-file.js", import.meta.url));
|
|
39
|
+
const readNames = [...new Set([...compressionSource.matchAll(/options\.([A-Za-z0-9_]+)/g)].map(match => match[1]))].sort();
|
|
40
|
+
|
|
41
|
+
const unclassified = readNames.filter(name => !PROCESS_OPTION_NAMES.includes(name) && !INTERNAL_OPTION_NAMES.includes(name));
|
|
42
|
+
check("every option compression.js reads is classified", unclassified.join(", "), "");
|
|
43
|
+
|
|
44
|
+
const unread = PROCESS_OPTION_NAMES.filter(name => !readNames.includes(name));
|
|
45
|
+
check("no declared caller option is dead", unread.join(", "), "");
|
|
46
|
+
|
|
47
|
+
const misclassified = INTERNAL_OPTION_NAMES.filter(name => PROCESS_OPTION_NAMES.includes(name));
|
|
48
|
+
check("no option is both a caller option and internal", misclassified.join(", "), "");
|
|
49
|
+
|
|
50
|
+
check("the declared list is sorted", PROCESS_OPTION_NAMES.join(), [...PROCESS_OPTION_NAMES].sort().join());
|
|
51
|
+
|
|
52
|
+
// the guard is only worth anything while single-file.js builds its argument from the list; a
|
|
53
|
+
// literal object there would drift again, which is exactly the bug this file exists to prevent
|
|
54
|
+
check("single-file.js builds its argument from the list", singleFileSource.includes("PROCESS_OPTION_NAMES"), true);
|
|
55
|
+
check("single-file.js keeps no literal option whitelist", /insertTextBody:\s*options\.insertTextBody/.test(singleFileSource), false);
|
|
56
|
+
|
|
57
|
+
check("includeBOM reaches the compressed path", PROCESS_OPTION_NAMES.includes("includeBOM"), true);
|
|
58
|
+
|
|
59
|
+
if (failed) {
|
|
60
|
+
console.log("FAILED");
|
|
61
|
+
Deno.exit(1);
|
|
62
|
+
}
|
|
63
|
+
console.log("OK");
|
|
@@ -2,7 +2,7 @@ import { makePageData, makeOptions, runProcess, mulberry32 } from "./common.js";
|
|
|
2
2
|
|
|
3
3
|
function storedResource(name, literals) {
|
|
4
4
|
const rand = mulberry32(0xbeef);
|
|
5
|
-
const bytes = new Uint8Array(4096).map(() => (rand() * 256) | 0);
|
|
5
|
+
const bytes = new Uint8Array(Math.max(4096, 256 + literals.length * 512)).map(() => (rand() * 256) | 0);
|
|
6
6
|
const encoder = new TextEncoder();
|
|
7
7
|
let offset = 256;
|
|
8
8
|
for (const literal of literals) {
|
|
@@ -33,7 +33,7 @@ function check(label, actual, expected) {
|
|
|
33
33
|
const options = makeOptions();
|
|
34
34
|
const pageData = makePageData(2, 64 * 1024);
|
|
35
35
|
pageData.resources.images.push(storedResource("photo.jpg",
|
|
36
|
-
["-->", "</noscript>", "</noframes>", "</noembed>", "</script>", "</style>", "</iframe>", "</xmp>"]));
|
|
36
|
+
["-->", "</noscript>", "</noframes>", "</noembed>", "</script>", "</style>", "</iframe>", "</xmp>", "]]>"]));
|
|
37
37
|
const result = await runProcess(pageData, options);
|
|
38
38
|
check("all closers exhaust to", result.fallbackTag, "<plaintext>");
|
|
39
39
|
check("all closers keep extraction", result.extractionDisabled, false);
|