cursedbelt-server 4.44.0 โ 4.45.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.
- package/dist/server/master-lock/lockPage.d.ts +9 -2
- package/dist/server/master-lock/lockPage.js +9 -2
- package/dist/server/zipStream.d.ts +83 -0
- package/dist/server/zipStream.js +320 -0
- package/package.json +7 -1
- package/src/leafSubpathsImportNothing.spec.ts +8 -0
- package/src/server/master-lock/fieldsNeverZoomIos.spec.ts +74 -0
- package/src/server/master-lock/lockPage.ts +9 -2
- package/src/server/zipStream.spec.ts +264 -0
- package/src/server/zipStream.ts +372 -0
|
@@ -26,8 +26,15 @@ export interface LockPageOptions {
|
|
|
26
26
|
* would not say which app it is cannot tell the owner which password to type.
|
|
27
27
|
*/
|
|
28
28
|
export declare function lockPageHtml(options: LockPageOptions): string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The stylesheet. Follows the viewer's theme and commits to nothing else.
|
|
31
|
+
*
|
|
32
|
+
* ๐ด Every field is 16px, never `inherit` alone. The body is 15px, and iOS zooms the page into
|
|
33
|
+
* any field under 16px when it takes focus โ then the unlock's `location.reload()` keeps that
|
|
34
|
+
* zoom, so the app opened "slightly zoomed in" behind the wall (owner, collections on an iPhone,
|
|
35
|
+
* 2026-10-10). `fieldsNeverZoomIos.spec.ts` holds every page this module serves to it.
|
|
36
|
+
*/
|
|
37
|
+
export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit;font-size:16px}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n.secret{position:relative;display:block}\n.secret input{padding-right:48px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;\n display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}\n.secret .reveal svg{width:18px;height:18px}\n.secret .reveal .eye-off,.secret .reveal[aria-pressed=\"true\"] .eye{display:none}\n.secret .reveal[aria-pressed=\"true\"] .eye-off{display:block}\n.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}\n.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}\n";
|
|
31
38
|
export declare function withRevealToggles(html: string): string;
|
|
32
39
|
/**
|
|
33
40
|
* The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
|
|
@@ -96,7 +96,14 @@ export function lockPageHtml(options) {
|
|
|
96
96
|
</html>
|
|
97
97
|
`);
|
|
98
98
|
}
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* The stylesheet. Follows the viewer's theme and commits to nothing else.
|
|
101
|
+
*
|
|
102
|
+
* ๐ด Every field is 16px, never `inherit` alone. The body is 15px, and iOS zooms the page into
|
|
103
|
+
* any field under 16px when it takes focus โ then the unlock's `location.reload()` keeps that
|
|
104
|
+
* zoom, so the app opened "slightly zoomed in" behind the wall (owner, collections on an iPhone,
|
|
105
|
+
* 2026-10-10). `fieldsNeverZoomIos.spec.ts` holds every page this module serves to it.
|
|
106
|
+
*/
|
|
100
107
|
export const LOCK_STYLE = `:root{color-scheme:light dark;
|
|
101
108
|
--ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;
|
|
102
109
|
--ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;
|
|
@@ -117,7 +124,7 @@ h1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}
|
|
|
117
124
|
.field{display:block;text-align:left;margin-bottom:14px}
|
|
118
125
|
.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}
|
|
119
126
|
input{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;
|
|
120
|
-
background:var(--ml-bg);color:inherit;font:inherit}
|
|
127
|
+
background:var(--ml-bg);color:inherit;font:inherit;font-size:16px}
|
|
121
128
|
input:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}
|
|
122
129
|
button{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);
|
|
123
130
|
color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A streaming ZIP writer โ a download of many files as one, built as it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Graduated from `apps/music/src/server/zip.ts` (2026-10-15, forge task desk-updates) when desk
|
|
5
|
+
* needed the same thing for "download this folder": two copies of a hand-rolled format writer
|
|
6
|
+
* is two places for the next bug. Folders (directory entries) and file dates arrived with it,
|
|
7
|
+
* because a folder download that loses its empty folders and stamps every file 1980 is not a
|
|
8
|
+
* backup.
|
|
9
|
+
*
|
|
10
|
+
* โโ Why this is hand-written rather than a dependency โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
11
|
+
* Three properties are needed together, and the popular libraries give at most two:
|
|
12
|
+
*
|
|
13
|
+
* ยท **Store, never deflate.** Pictures, video, audio and `.xlsx` are already compressed.
|
|
14
|
+
* Deflating them spends CPU โ on a Worker, the scarce thing โ to produce a slightly LARGER
|
|
15
|
+
* file. Method 0 (store) is the correct encoding for them, and a few kilobytes of notes
|
|
16
|
+
* beside them are not worth a second code path.
|
|
17
|
+
* ยท **True streaming.** "Download this folder" can be thousands of files and many gigabytes.
|
|
18
|
+
* Nothing is buffered beyond one entry: bytes arrive from the store and leave for the
|
|
19
|
+
* browser in the same pass.
|
|
20
|
+
* ยท **ZIP64.** 4 GB is an entirely reachable selection, and the classic format's 32-bit size
|
|
21
|
+
* fields wrap silently at that point. A truncated archive that reports success is the worst
|
|
22
|
+
* available failure, so ZIP64 is written UNCONDITIONALLY rather than only when a threshold is
|
|
23
|
+
* crossed โ a conditional format is a format that is only tested on the small path.
|
|
24
|
+
*
|
|
25
|
+
* โโ Streaming means data descriptors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
26
|
+
* A local file header must state the CRC-32 and the sizes, and none of those are known until
|
|
27
|
+
* the file's bytes have gone by. The format's answer is general-purpose flag bit 3: write zeros
|
|
28
|
+
* in the header, and follow the data with a DATA DESCRIPTOR carrying the real values. That is
|
|
29
|
+
* what makes a single forward pass possible, why this writer never seeks, and so why the
|
|
30
|
+
* response can be a `ReadableStream` and the server's memory stays flat whatever the size.
|
|
31
|
+
*
|
|
32
|
+
* โโ The CPU it costs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
33
|
+
* The CRC-32 is computed over every byte in the isolate: measured 2026-09-22 at ~280 MB of CPU
|
|
34
|
+
* per second (240 MB in 860 ms). A Worker's default budget is 30 s of CPU per request, so a
|
|
35
|
+
* caller on a Worker caps what one archive may hold โ music at 4 GB / 2,000 files โ and says so
|
|
36
|
+
* BEFORE the download starts, rather than letting the edge cut it off part-way.
|
|
37
|
+
*/
|
|
38
|
+
/** Incremental CRC-32, so it can be fed one chunk at a time as bytes stream past. */
|
|
39
|
+
export declare class Crc32 {
|
|
40
|
+
private crc;
|
|
41
|
+
update(bytes: Uint8Array): void;
|
|
42
|
+
get value(): number;
|
|
43
|
+
}
|
|
44
|
+
/** One member of the archive: a file whose bytes are opened lazily, or a folder. */
|
|
45
|
+
export type ZipEntry = {
|
|
46
|
+
/** Path inside the archive, `/`-separated. Sanitized by {@link zipStream}. */
|
|
47
|
+
name: string;
|
|
48
|
+
/**
|
|
49
|
+
* Opens the bytes โ a stream, or bytes already in hand. Called only when the entry's turn
|
|
50
|
+
* comes, so a 2,000-file archive does not open 2,000 connections at once. Returning `null`
|
|
51
|
+
* (or throwing) skips the entry: a file that vanished must not abort the rest.
|
|
52
|
+
*/
|
|
53
|
+
open: () => Promise<ReadableStream<Uint8Array> | Uint8Array | null>;
|
|
54
|
+
/** When the file last changed. Omitted: the archive says 1980-01-01, the format's zero. */
|
|
55
|
+
modified?: Date | string | null;
|
|
56
|
+
directory?: false;
|
|
57
|
+
} | {
|
|
58
|
+
/** A folder โ written so an EMPTY one survives the round trip. A trailing `/` is added. */
|
|
59
|
+
name: string;
|
|
60
|
+
directory: true;
|
|
61
|
+
modified?: Date | string | null;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Names inside a zip must not escape the extraction directory, and must not carry characters
|
|
65
|
+
* Windows refuses. `..` segments and absolute paths are the security half; the character set is
|
|
66
|
+
* the "it downloaded but won't extract" half. Each segment is capped, so a long name inside a
|
|
67
|
+
* deep folder keeps its folders.
|
|
68
|
+
*/
|
|
69
|
+
export declare function safeEntryName(name: string, fallback?: string): string;
|
|
70
|
+
/** The MS-DOS date and time a zip header carries โ local wall-clock, 2-second resolution, 1980 at the earliest. */
|
|
71
|
+
export declare function dosDateTime(when: Date | string | null | undefined): {
|
|
72
|
+
date: number;
|
|
73
|
+
time: number;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Build the archive as a stream.
|
|
77
|
+
*
|
|
78
|
+
* Duplicate file names are disambiguated rather than colliding โ two different files that both
|
|
79
|
+
* want `Notes/Summary.md` produce `Summary.md` and `Summary (2).md`, because a zip with two
|
|
80
|
+
* identical names extracts as one file, silently losing the other. A duplicate FOLDER is written
|
|
81
|
+
* once: its files already land in it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array>;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A streaming ZIP writer โ a download of many files as one, built as it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Graduated from `apps/music/src/server/zip.ts` (2026-10-15, forge task desk-updates) when desk
|
|
5
|
+
* needed the same thing for "download this folder": two copies of a hand-rolled format writer
|
|
6
|
+
* is two places for the next bug. Folders (directory entries) and file dates arrived with it,
|
|
7
|
+
* because a folder download that loses its empty folders and stamps every file 1980 is not a
|
|
8
|
+
* backup.
|
|
9
|
+
*
|
|
10
|
+
* โโ Why this is hand-written rather than a dependency โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
11
|
+
* Three properties are needed together, and the popular libraries give at most two:
|
|
12
|
+
*
|
|
13
|
+
* ยท **Store, never deflate.** Pictures, video, audio and `.xlsx` are already compressed.
|
|
14
|
+
* Deflating them spends CPU โ on a Worker, the scarce thing โ to produce a slightly LARGER
|
|
15
|
+
* file. Method 0 (store) is the correct encoding for them, and a few kilobytes of notes
|
|
16
|
+
* beside them are not worth a second code path.
|
|
17
|
+
* ยท **True streaming.** "Download this folder" can be thousands of files and many gigabytes.
|
|
18
|
+
* Nothing is buffered beyond one entry: bytes arrive from the store and leave for the
|
|
19
|
+
* browser in the same pass.
|
|
20
|
+
* ยท **ZIP64.** 4 GB is an entirely reachable selection, and the classic format's 32-bit size
|
|
21
|
+
* fields wrap silently at that point. A truncated archive that reports success is the worst
|
|
22
|
+
* available failure, so ZIP64 is written UNCONDITIONALLY rather than only when a threshold is
|
|
23
|
+
* crossed โ a conditional format is a format that is only tested on the small path.
|
|
24
|
+
*
|
|
25
|
+
* โโ Streaming means data descriptors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
26
|
+
* A local file header must state the CRC-32 and the sizes, and none of those are known until
|
|
27
|
+
* the file's bytes have gone by. The format's answer is general-purpose flag bit 3: write zeros
|
|
28
|
+
* in the header, and follow the data with a DATA DESCRIPTOR carrying the real values. That is
|
|
29
|
+
* what makes a single forward pass possible, why this writer never seeks, and so why the
|
|
30
|
+
* response can be a `ReadableStream` and the server's memory stays flat whatever the size.
|
|
31
|
+
*
|
|
32
|
+
* โโ The CPU it costs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
33
|
+
* The CRC-32 is computed over every byte in the isolate: measured 2026-09-22 at ~280 MB of CPU
|
|
34
|
+
* per second (240 MB in 860 ms). A Worker's default budget is 30 s of CPU per request, so a
|
|
35
|
+
* caller on a Worker caps what one archive may hold โ music at 4 GB / 2,000 files โ and says so
|
|
36
|
+
* BEFORE the download starts, rather than letting the edge cut it off part-way.
|
|
37
|
+
*/
|
|
38
|
+
// โโ CRC-32 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
39
|
+
/** The standard IEEE 802.3 table, built once. */
|
|
40
|
+
const CRC_TABLE = (() => {
|
|
41
|
+
const table = new Uint32Array(256);
|
|
42
|
+
for (let i = 0; i < 256; i++) {
|
|
43
|
+
let c = i;
|
|
44
|
+
for (let k = 0; k < 8; k++)
|
|
45
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
46
|
+
table[i] = c >>> 0;
|
|
47
|
+
}
|
|
48
|
+
return table;
|
|
49
|
+
})();
|
|
50
|
+
/** Incremental CRC-32, so it can be fed one chunk at a time as bytes stream past. */
|
|
51
|
+
export class Crc32 {
|
|
52
|
+
crc = 0xffffffff;
|
|
53
|
+
update(bytes) {
|
|
54
|
+
let crc = this.crc;
|
|
55
|
+
for (let i = 0; i < bytes.length; i++)
|
|
56
|
+
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
57
|
+
this.crc = crc;
|
|
58
|
+
}
|
|
59
|
+
get value() {
|
|
60
|
+
return (this.crc ^ 0xffffffff) >>> 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// โโ Little-endian writing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
64
|
+
class ByteWriter {
|
|
65
|
+
parts = [];
|
|
66
|
+
u16(n) {
|
|
67
|
+
const b = new Uint8Array(2);
|
|
68
|
+
new DataView(b.buffer).setUint16(0, n, true);
|
|
69
|
+
this.parts.push(b);
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
u32(n) {
|
|
73
|
+
const b = new Uint8Array(4);
|
|
74
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, true);
|
|
75
|
+
this.parts.push(b);
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
/** 64-bit, via BigInt โ a whole-library download can pass 2^32, and Number would quietly lose precision past 2^53. */
|
|
79
|
+
u64(n) {
|
|
80
|
+
const b = new Uint8Array(8);
|
|
81
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
|
|
82
|
+
this.parts.push(b);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
raw(bytes) {
|
|
86
|
+
this.parts.push(bytes);
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
done() {
|
|
90
|
+
const total = this.parts.reduce((n, p) => n + p.length, 0);
|
|
91
|
+
const out = new Uint8Array(total);
|
|
92
|
+
let at = 0;
|
|
93
|
+
for (const part of this.parts) {
|
|
94
|
+
out.set(part, at);
|
|
95
|
+
at += part.length;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const encoder = new TextEncoder();
|
|
101
|
+
/** ZIP64 needs version 4.5; bit 3 = sizes/CRC follow in a data descriptor; bit 11 = UTF-8
|
|
102
|
+
* names, which every accented and non-Latin file name needs. */
|
|
103
|
+
const VERSION_ZIP64 = 45;
|
|
104
|
+
const FLAG_DATA_DESCRIPTOR = 0x08;
|
|
105
|
+
const FLAG_UTF8 = 0x0800;
|
|
106
|
+
const METHOD_STORE = 0;
|
|
107
|
+
/** MS-DOS directory attribute, in the low byte of the external attributes. */
|
|
108
|
+
const ATTR_DIRECTORY = 0x10;
|
|
109
|
+
/**
|
|
110
|
+
* Names inside a zip must not escape the extraction directory, and must not carry characters
|
|
111
|
+
* Windows refuses. `..` segments and absolute paths are the security half; the character set is
|
|
112
|
+
* the "it downloaded but won't extract" half. Each segment is capped, so a long name inside a
|
|
113
|
+
* deep folder keeps its folders.
|
|
114
|
+
*/
|
|
115
|
+
export function safeEntryName(name, fallback = "file") {
|
|
116
|
+
// Control characters (< 0x20) and the set Windows refuses. Filtered by code point rather than
|
|
117
|
+
// by a regex class, because a literal control character inside a regex is unreadable and the
|
|
118
|
+
// linter is right to object to it.
|
|
119
|
+
const FORBIDDEN = new Set(["<", ">", ":", '"', "|", "?", "*"]);
|
|
120
|
+
const segments = name
|
|
121
|
+
.split(/[/\\]/)
|
|
122
|
+
.filter((seg) => seg !== "" && seg !== "." && seg !== "..")
|
|
123
|
+
.map((seg) => {
|
|
124
|
+
let out = "";
|
|
125
|
+
for (const ch of seg)
|
|
126
|
+
out += ch.codePointAt(0) < 0x20 || FORBIDDEN.has(ch) ? "_" : ch;
|
|
127
|
+
// Windows also refuses a segment ending in a dot or a space; trimmed rather than replaced.
|
|
128
|
+
return out.slice(0, 200).replace(/[. ]+$/, "") || fallback;
|
|
129
|
+
});
|
|
130
|
+
return segments.join("/") || fallback;
|
|
131
|
+
}
|
|
132
|
+
/** The MS-DOS date and time a zip header carries โ local wall-clock, 2-second resolution, 1980 at the earliest. */
|
|
133
|
+
export function dosDateTime(when) {
|
|
134
|
+
const at = when ? new Date(when) : null;
|
|
135
|
+
if (!at || Number.isNaN(at.getTime()) || at.getUTCFullYear() < 1980)
|
|
136
|
+
return { date: (0 << 9) | (1 << 5) | 1, time: 0 };
|
|
137
|
+
// UTC, not the server's zone: a Worker has none, and the same archive should not depend on where it was built.
|
|
138
|
+
const year = Math.min(at.getUTCFullYear(), 2107) - 1980;
|
|
139
|
+
return {
|
|
140
|
+
date: (year << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
|
|
141
|
+
time: (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | Math.floor(at.getUTCSeconds() / 2),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function asStream(body) {
|
|
145
|
+
if (!(body instanceof Uint8Array))
|
|
146
|
+
return body;
|
|
147
|
+
return new ReadableStream({
|
|
148
|
+
start(controller) {
|
|
149
|
+
if (body.byteLength > 0)
|
|
150
|
+
controller.enqueue(body);
|
|
151
|
+
controller.close();
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build the archive as a stream.
|
|
157
|
+
*
|
|
158
|
+
* Duplicate file names are disambiguated rather than colliding โ two different files that both
|
|
159
|
+
* want `Notes/Summary.md` produce `Summary.md` and `Summary (2).md`, because a zip with two
|
|
160
|
+
* identical names extracts as one file, silently losing the other. A duplicate FOLDER is written
|
|
161
|
+
* once: its files already land in it.
|
|
162
|
+
*/
|
|
163
|
+
export function zipStream(entries) {
|
|
164
|
+
const central = [];
|
|
165
|
+
let offset = 0n;
|
|
166
|
+
const used = new Set();
|
|
167
|
+
const iterator = Symbol.asyncIterator in entries ? entries[Symbol.asyncIterator]() : entries[Symbol.iterator]();
|
|
168
|
+
return new ReadableStream({
|
|
169
|
+
async pull(controller) {
|
|
170
|
+
// One entry per pull keeps backpressure honest: the next file is not opened until the
|
|
171
|
+
// consumer has taken the previous one.
|
|
172
|
+
//
|
|
173
|
+
// ๐ด The loop is not decoration. A `pull` that returns having enqueued NOTHING is not
|
|
174
|
+
// guaranteed to be called again, so an entry that turns out to be unreadable would stall
|
|
175
|
+
// the entire archive โ the consumer waits forever for bytes that will never come. Skipping
|
|
176
|
+
// therefore has to continue to the next entry within the SAME pull. (Measured in music:
|
|
177
|
+
// two tests hung for the full 5 s timeout before this loop existed.)
|
|
178
|
+
while (true) {
|
|
179
|
+
const next = await iterator.next();
|
|
180
|
+
if (next.done)
|
|
181
|
+
break;
|
|
182
|
+
const entry = next.value;
|
|
183
|
+
const directory = entry.directory === true;
|
|
184
|
+
let name = safeEntryName(entry.name);
|
|
185
|
+
if (directory) {
|
|
186
|
+
name = `${name}/`;
|
|
187
|
+
if (used.has(name))
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
else if (used.has(name)) {
|
|
191
|
+
const slash = name.lastIndexOf("/");
|
|
192
|
+
const dot = name.lastIndexOf(".");
|
|
193
|
+
const cut = dot > slash + 1 ? dot : name.length;
|
|
194
|
+
const stem = name.slice(0, cut);
|
|
195
|
+
const ext = name.slice(cut);
|
|
196
|
+
let n = 2;
|
|
197
|
+
while (used.has(`${stem} (${n})${ext}`))
|
|
198
|
+
n++;
|
|
199
|
+
name = `${stem} (${n})${ext}`;
|
|
200
|
+
}
|
|
201
|
+
let body = null;
|
|
202
|
+
if (!directory) {
|
|
203
|
+
const opened = await entry.open().catch(() => null);
|
|
204
|
+
// A file whose bytes have gone is skipped, not fatal: one missing file must not abort
|
|
205
|
+
// a 2,000-file download that is otherwise fine. `continue`, never `return` โ see the
|
|
206
|
+
// note on the loop above.
|
|
207
|
+
if (!opened)
|
|
208
|
+
continue;
|
|
209
|
+
body = asStream(opened);
|
|
210
|
+
}
|
|
211
|
+
used.add(name);
|
|
212
|
+
const nameBytes = encoder.encode(name);
|
|
213
|
+
const { date, time } = dosDateTime(entry.modified);
|
|
214
|
+
// ZIP64 extra field, sizes zeroed โ the data descriptor carries the truth.
|
|
215
|
+
const localExtra = new ByteWriter().u16(0x0001).u16(16).u64(0).u64(0).done();
|
|
216
|
+
const header = new ByteWriter()
|
|
217
|
+
.u32(0x04034b50)
|
|
218
|
+
.u16(VERSION_ZIP64)
|
|
219
|
+
.u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
|
|
220
|
+
.u16(METHOD_STORE)
|
|
221
|
+
.u16(time)
|
|
222
|
+
.u16(date)
|
|
223
|
+
.u32(0) // crc, in the descriptor
|
|
224
|
+
.u32(0xffffffff) // compressed size โ see ZIP64 extra
|
|
225
|
+
.u32(0xffffffff) // uncompressed size โ see ZIP64 extra
|
|
226
|
+
.u16(nameBytes.length)
|
|
227
|
+
.u16(localExtra.length)
|
|
228
|
+
.raw(nameBytes)
|
|
229
|
+
.raw(localExtra)
|
|
230
|
+
.done();
|
|
231
|
+
controller.enqueue(header);
|
|
232
|
+
const entryOffset = offset;
|
|
233
|
+
offset += BigInt(header.length);
|
|
234
|
+
const crc = new Crc32();
|
|
235
|
+
let size = 0n;
|
|
236
|
+
if (body) {
|
|
237
|
+
const reader = body.getReader();
|
|
238
|
+
while (true) {
|
|
239
|
+
const { done, value } = await reader.read();
|
|
240
|
+
if (done)
|
|
241
|
+
break;
|
|
242
|
+
crc.update(value);
|
|
243
|
+
size += BigInt(value.length);
|
|
244
|
+
controller.enqueue(value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
offset += size;
|
|
248
|
+
// ZIP64 data descriptor: 8-byte sizes, unlike the 4-byte classic form.
|
|
249
|
+
const descriptor = new ByteWriter().u32(0x08074b50).u32(crc.value).u64(size).u64(size).done();
|
|
250
|
+
controller.enqueue(descriptor);
|
|
251
|
+
offset += BigInt(descriptor.length);
|
|
252
|
+
central.push({ name: nameBytes, crc: crc.value, size, offset: entryOffset, date, time, directory });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Central directory, then the ZIP64 end records, then the classic end record.
|
|
256
|
+
const start = offset;
|
|
257
|
+
for (const item of central) {
|
|
258
|
+
const extra = new ByteWriter().u16(0x0001).u16(24).u64(item.size).u64(item.size).u64(item.offset).done();
|
|
259
|
+
const record = new ByteWriter()
|
|
260
|
+
.u32(0x02014b50)
|
|
261
|
+
.u16(VERSION_ZIP64) // version made by (MS-DOS attributes)
|
|
262
|
+
.u16(VERSION_ZIP64) // version needed
|
|
263
|
+
.u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
|
|
264
|
+
.u16(METHOD_STORE)
|
|
265
|
+
.u16(item.time)
|
|
266
|
+
.u16(item.date)
|
|
267
|
+
.u32(item.crc)
|
|
268
|
+
.u32(0xffffffff)
|
|
269
|
+
.u32(0xffffffff)
|
|
270
|
+
.u16(item.name.length)
|
|
271
|
+
.u16(extra.length)
|
|
272
|
+
.u16(0) // comment length
|
|
273
|
+
.u16(0) // disk number
|
|
274
|
+
.u16(0) // internal attrs
|
|
275
|
+
.u32(item.directory ? ATTR_DIRECTORY : 0) // external attrs
|
|
276
|
+
.u32(0xffffffff) // local header offset โ ZIP64 extra
|
|
277
|
+
.raw(item.name)
|
|
278
|
+
.raw(extra)
|
|
279
|
+
.done();
|
|
280
|
+
controller.enqueue(record);
|
|
281
|
+
offset += BigInt(record.length);
|
|
282
|
+
}
|
|
283
|
+
const centralSize = offset - start;
|
|
284
|
+
const zip64End = new ByteWriter()
|
|
285
|
+
.u32(0x06064b50)
|
|
286
|
+
.u64(44) // size of this record, minus its first 12 bytes
|
|
287
|
+
.u16(VERSION_ZIP64)
|
|
288
|
+
.u16(VERSION_ZIP64)
|
|
289
|
+
.u32(0)
|
|
290
|
+
.u32(0)
|
|
291
|
+
.u64(central.length)
|
|
292
|
+
.u64(central.length)
|
|
293
|
+
.u64(centralSize)
|
|
294
|
+
.u64(start)
|
|
295
|
+
.done();
|
|
296
|
+
controller.enqueue(zip64End);
|
|
297
|
+
const locator = new ByteWriter()
|
|
298
|
+
.u32(0x07064b50)
|
|
299
|
+
.u32(0)
|
|
300
|
+
.u64(offset) // where the ZIP64 end record starts
|
|
301
|
+
.u32(1)
|
|
302
|
+
.done();
|
|
303
|
+
controller.enqueue(locator);
|
|
304
|
+
// The classic record still has to be there for every reader to find the archive; its
|
|
305
|
+
// 0xffff/0xffffffff sentinels say "look at the ZIP64 record instead".
|
|
306
|
+
const end = new ByteWriter()
|
|
307
|
+
.u32(0x06054b50)
|
|
308
|
+
.u16(0)
|
|
309
|
+
.u16(0)
|
|
310
|
+
.u16(central.length > 0xffff ? 0xffff : central.length)
|
|
311
|
+
.u16(central.length > 0xffff ? 0xffff : central.length)
|
|
312
|
+
.u32(0xffffffff)
|
|
313
|
+
.u32(0xffffffff)
|
|
314
|
+
.u16(0)
|
|
315
|
+
.done();
|
|
316
|
+
controller.enqueue(end);
|
|
317
|
+
controller.close();
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.45.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split โ storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -355,6 +355,12 @@
|
|
|
355
355
|
"source": "./src/server/youtube/ytdlp.ts",
|
|
356
356
|
"import": "./dist/server/youtube/ytdlp.js"
|
|
357
357
|
},
|
|
358
|
+
"./zip-stream": {
|
|
359
|
+
"types": "./dist/server/zipStream.d.ts",
|
|
360
|
+
"bun": "./src/server/zipStream.ts",
|
|
361
|
+
"source": "./src/server/zipStream.ts",
|
|
362
|
+
"import": "./dist/server/zipStream.js"
|
|
363
|
+
},
|
|
358
364
|
"./ws": {
|
|
359
365
|
"types": "./dist/server/ws/index.d.ts",
|
|
360
366
|
"bun": "./src/server/ws/index.ts",
|
|
@@ -243,6 +243,14 @@ const LEAVES = [
|
|
|
243
243
|
*/
|
|
244
244
|
evaluates: 'createYtDlp',
|
|
245
245
|
},
|
|
246
|
+
{
|
|
247
|
+
subpath: './zip-stream',
|
|
248
|
+
/**
|
|
249
|
+
* The streaming ZIP64 writer (4.45.0, forge task desk-updates) โ out of Music so desk's folder
|
|
250
|
+
* downloads share it. Both run it on a Worker, so it imports nothing at all.
|
|
251
|
+
*/
|
|
252
|
+
evaluates: 'zipStream',
|
|
253
|
+
},
|
|
246
254
|
] as const;
|
|
247
255
|
|
|
248
256
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ด No field on a page the master lock serves is under 16px โ iOS zooms into one that is.
|
|
3
|
+
*
|
|
4
|
+
* Owner, collections on an iPhone, 2026-10-10: *"Loading the site on iphone sometimes starts the
|
|
5
|
+
* view slightly zoomed in, making the user pinch the screen in and let go to see the full
|
|
6
|
+
* window."* The wall's password field inherited the body's 15px; focusing it zoomed the page, and
|
|
7
|
+
* the unlock's `location.reload()` kept the zoom, so the app itself opened magnified โ only on the
|
|
8
|
+
* loads that went through the wall, which is the "sometimes". The apps' Apple pass (FRM-11) never
|
|
9
|
+
* saw it because its harness signs in past the wall.
|
|
10
|
+
*
|
|
11
|
+
* Read off the STYLESHEET each page actually serves: the last declaration that sizes a rule naming
|
|
12
|
+
* `input`, `select` or `textarea` is the size such a field gets.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { MASTER_LOCK_PATHS } from "cursedbelt-core/master-lock";
|
|
16
|
+
import { accountsPageHtml, MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_STYLE } from "./accountsPage.js";
|
|
17
|
+
import { LOCK_STYLE, lockPageHtml } from "./lockPage.js";
|
|
18
|
+
|
|
19
|
+
/** Every stylesheet a wall page links, by the href it links it at. A page linking one not
|
|
20
|
+
* listed here fails below rather than going unread. */
|
|
21
|
+
const SERVED_CSS: Record<string, string> = {
|
|
22
|
+
[MASTER_LOCK_PATHS.style]: LOCK_STYLE,
|
|
23
|
+
[MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style]: MASTER_LOCK_ACCOUNTS_STYLE,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The px size the stylesheet gives `field`, or null when no rule names it. */
|
|
27
|
+
export function fieldFontPx(css: string, field: "input" | "select" | "textarea", bodyPx: number): number | null {
|
|
28
|
+
let size: number | null = null;
|
|
29
|
+
for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
30
|
+
const selectors = (match[1] ?? "").split(",").map((part) => part.trim());
|
|
31
|
+
if (!selectors.some((selector) => new RegExp(`(^|[\\s>+~])${field}(?![\\w-])`).test(selector))) continue;
|
|
32
|
+
for (const decl of (match[2] ?? "").split(";")) {
|
|
33
|
+
const [prop, ...rest] = decl.split(":");
|
|
34
|
+
const name = prop?.trim();
|
|
35
|
+
const value = rest.join(":").trim();
|
|
36
|
+
if (name === "font-size" || name === "font") {
|
|
37
|
+
if (value === "inherit") size = bodyPx;
|
|
38
|
+
const px = /(\d+(?:\.\d+)?)px/.exec(value);
|
|
39
|
+
if (px) size = Number(px[1]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OPTIONS = { appLabel: "Collections" };
|
|
47
|
+
|
|
48
|
+
describe("no master-lock field makes iOS zoom", () => {
|
|
49
|
+
const pages = {
|
|
50
|
+
lock: lockPageHtml(OPTIONS),
|
|
51
|
+
enroll: lockPageHtml({ ...OPTIONS, enroll: true }),
|
|
52
|
+
accounts: accountsPageHtml(OPTIONS),
|
|
53
|
+
};
|
|
54
|
+
for (const [name, html] of Object.entries(pages)) {
|
|
55
|
+
test(`${name}: every field is at least 16px`, () => {
|
|
56
|
+
const hrefs = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)">/g)].map((m) => m[1] ?? "");
|
|
57
|
+
for (const href of hrefs) expect(SERVED_CSS[href], `${name} links ${href}, which this check does not read`).toBeDefined();
|
|
58
|
+
const css = [...hrefs.map((href) => SERVED_CSS[href] ?? ""), ...[...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1])].join("\n");
|
|
59
|
+
expect(css.length).toBeGreaterThan(0);
|
|
60
|
+
const bodyPx = Number(/body\{[^}]*?font:\s*(\d+)px/.exec(css)?.[1] ?? 16);
|
|
61
|
+
for (const field of ["input", "select", "textarea"] as const) {
|
|
62
|
+
if (!new RegExp(`<${field}\\b`).test(html)) continue;
|
|
63
|
+
const px = fieldFontPx(css, field, bodyPx);
|
|
64
|
+
expect(px ?? bodyPx, `${name}: <${field}> is ${px ?? bodyPx}px โ iOS zooms into a field under 16px`).toBeGreaterThanOrEqual(16);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("the reader sees an inherited 15px as 15px", () => {
|
|
70
|
+
expect(fieldFontPx("input{font:inherit}", "input", 15)).toBe(15);
|
|
71
|
+
expect(fieldFontPx("input{font:inherit;font-size:16px}", "input", 15)).toBe(16);
|
|
72
|
+
expect(fieldFontPx(".secret input{padding:0}", "input", 15)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -130,7 +130,14 @@ export function lockPageHtml(options: LockPageOptions): string {
|
|
|
130
130
|
`);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* The stylesheet. Follows the viewer's theme and commits to nothing else.
|
|
135
|
+
*
|
|
136
|
+
* ๐ด Every field is 16px, never `inherit` alone. The body is 15px, and iOS zooms the page into
|
|
137
|
+
* any field under 16px when it takes focus โ then the unlock's `location.reload()` keeps that
|
|
138
|
+
* zoom, so the app opened "slightly zoomed in" behind the wall (owner, collections on an iPhone,
|
|
139
|
+
* 2026-10-10). `fieldsNeverZoomIos.spec.ts` holds every page this module serves to it.
|
|
140
|
+
*/
|
|
134
141
|
export const LOCK_STYLE = `:root{color-scheme:light dark;
|
|
135
142
|
--ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;
|
|
136
143
|
--ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;
|
|
@@ -151,7 +158,7 @@ h1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}
|
|
|
151
158
|
.field{display:block;text-align:left;margin-bottom:14px}
|
|
152
159
|
.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}
|
|
153
160
|
input{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;
|
|
154
|
-
background:var(--ml-bg);color:inherit;font:inherit}
|
|
161
|
+
background:var(--ml-bg);color:inherit;font:inherit;font-size:16px}
|
|
155
162
|
input:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}
|
|
156
163
|
button{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);
|
|
157
164
|
color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The zip writer is hand-rolled (see the file header for why), so it is verified against a REAL
|
|
3
|
+
* unzip rather than against itself: a self-consistent archive that no extractor accepts is exactly
|
|
4
|
+
* the failure a hand-rolled format writer produces. Moved here from `apps/music/src/server/zip.test.ts`
|
|
5
|
+
* with the writer (2026-10-15); the folder and date cases arrived with desk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { Crc32, dosDateTime, safeEntryName, type ZipEntry, zipStream } from './zipStream';
|
|
13
|
+
|
|
14
|
+
const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text);
|
|
15
|
+
|
|
16
|
+
const entryFrom = (name: string, body: string): ZipEntry => ({
|
|
17
|
+
name,
|
|
18
|
+
open: async () =>
|
|
19
|
+
new ReadableStream<Uint8Array>({
|
|
20
|
+
start(controller) {
|
|
21
|
+
// Two chunks, so the streaming CRC and size accumulation are exercised rather than a
|
|
22
|
+
// single-chunk fast path that would hide an accumulator bug.
|
|
23
|
+
const half = Math.ceil(body.length / 2);
|
|
24
|
+
controller.enqueue(bytesOf(body.slice(0, half)));
|
|
25
|
+
controller.enqueue(bytesOf(body.slice(half)));
|
|
26
|
+
controller.close();
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const collect = async (stream: ReadableStream<Uint8Array>): Promise<Uint8Array> => new Uint8Array(await new Response(stream).arrayBuffer());
|
|
32
|
+
|
|
33
|
+
/** Run the system `unzip` over the archive in a throwaway directory. */
|
|
34
|
+
async function unzip(archive: Uint8Array, args: string[]): Promise<{ out: string; dir: string; cleanup: () => void }> {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), 'cbs-zip-'));
|
|
36
|
+
const path = join(dir, 'test.zip');
|
|
37
|
+
writeFileSync(path, archive);
|
|
38
|
+
const proc = Bun.spawn(['unzip', ...args, path], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
|
|
39
|
+
const out = await new Response(proc.stdout).text();
|
|
40
|
+
await proc.exited;
|
|
41
|
+
return { out, dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write the archive out and list it with the system `unzip` โ the real test. */
|
|
45
|
+
async function unzipList(archive: Uint8Array): Promise<string> {
|
|
46
|
+
const run = await unzip(archive, ['-l']);
|
|
47
|
+
run.cleanup();
|
|
48
|
+
return run.out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Extract and read one member back, proving the BYTES survive, not just the listing. */
|
|
52
|
+
async function unzipRead(archive: Uint8Array, member: string): Promise<string> {
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'cbs-zip-'));
|
|
54
|
+
const path = join(dir, 'test.zip');
|
|
55
|
+
writeFileSync(path, archive);
|
|
56
|
+
const proc = Bun.spawn(['unzip', '-p', path, member], { stdout: 'pipe', stderr: 'pipe' });
|
|
57
|
+
const out = await new Response(proc.stdout).text();
|
|
58
|
+
await proc.exited;
|
|
59
|
+
rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("Crc32", () => {
|
|
64
|
+
test("matches the known IEEE check value for '123456789'", () => {
|
|
65
|
+
const crc = new Crc32();
|
|
66
|
+
crc.update(bytesOf("123456789"));
|
|
67
|
+
expect(crc.value).toBe(0xcbf43926);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("is order-dependent across chunks, i.e. genuinely incremental", () => {
|
|
71
|
+
const whole = new Crc32();
|
|
72
|
+
whole.update(bytesOf("hello world"));
|
|
73
|
+
const split = new Crc32();
|
|
74
|
+
split.update(bytesOf("hello "));
|
|
75
|
+
split.update(bytesOf("world"));
|
|
76
|
+
expect(split.value).toBe(whole.value);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("safeEntryName", () => {
|
|
81
|
+
test("๐ด strips '..' and absolute paths โ an archive must not escape its extract dir", () => {
|
|
82
|
+
expect(safeEntryName("../../etc/passwd")).toBe("etc/passwd");
|
|
83
|
+
expect(safeEntryName("/etc/passwd")).toBe("etc/passwd");
|
|
84
|
+
expect(safeEntryName("a/../../b")).toBe("a/b");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("replaces characters Windows refuses, so the archive extracts everywhere", () => {
|
|
88
|
+
expect(safeEntryName('a:b"c|d?e*f.mp3')).toBe("a_b_c_d_e_f.mp3");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("never yields an empty name", () => {
|
|
92
|
+
expect(safeEntryName("")).toBe("file");
|
|
93
|
+
expect(safeEntryName("../..")).toBe("file");
|
|
94
|
+
expect(safeEntryName("", "track")).toBe("track");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps ordinary nested paths intact", () => {
|
|
98
|
+
expect(safeEntryName("In Sides/01 - Belfast.mp3")).toBe("In Sides/01 - Belfast.mp3");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("zipStream", () => {
|
|
103
|
+
test("produces an archive a real unzip accepts and lists", async () => {
|
|
104
|
+
const archive = await collect(
|
|
105
|
+
zipStream([entryFrom("one.mp3", "first body"), entryFrom("two.mp3", "second body")]),
|
|
106
|
+
);
|
|
107
|
+
const listing = await unzipList(archive);
|
|
108
|
+
expect(listing).toContain("one.mp3");
|
|
109
|
+
expect(listing).toContain("two.mp3");
|
|
110
|
+
// unzip prints this banner for anything it could not parse.
|
|
111
|
+
expect(listing).not.toContain("cannot find zipfile directory");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the extracted BYTES round-trip exactly", async () => {
|
|
115
|
+
const body = "the quick brown fox jumps over the lazy dog";
|
|
116
|
+
const archive = await collect(zipStream([entryFrom("song.mp3", body)]));
|
|
117
|
+
expect(await unzipRead(archive, "song.mp3")).toBe(body);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("๐ด duplicate names are disambiguated, not silently collapsed to one file", async () => {
|
|
121
|
+
// Two different rips of one song produce the same "Artist - Title.mp3". A zip with
|
|
122
|
+
// two identical names extracts as ONE file, losing the other without a word.
|
|
123
|
+
const archive = await collect(
|
|
124
|
+
zipStream([entryFrom("same.mp3", "copy one"), entryFrom("same.mp3", "copy two")]),
|
|
125
|
+
);
|
|
126
|
+
const listing = await unzipList(archive);
|
|
127
|
+
expect(listing).toContain("same.mp3");
|
|
128
|
+
expect(listing).toContain("same (2).mp3");
|
|
129
|
+
expect(await unzipRead(archive, "same.mp3")).toBe("copy one");
|
|
130
|
+
expect(await unzipRead(archive, "same (2).mp3")).toBe("copy two");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("๐ด a member whose bytes have gone is SKIPPED, not fatal", async () => {
|
|
134
|
+
// One missing track must not abort a 2,000-file download that is otherwise fine.
|
|
135
|
+
const archive = await collect(
|
|
136
|
+
zipStream([
|
|
137
|
+
entryFrom("good.mp3", "here"),
|
|
138
|
+
{ name: "gone.mp3", open: async () => null },
|
|
139
|
+
entryFrom("also-good.mp3", "also here"),
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
const listing = await unzipList(archive);
|
|
143
|
+
expect(listing).toContain("good.mp3");
|
|
144
|
+
expect(listing).toContain("also-good.mp3");
|
|
145
|
+
expect(listing).not.toContain("gone.mp3");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("an entry whose open() THROWS is skipped rather than killing the archive", async () => {
|
|
149
|
+
const archive = await collect(
|
|
150
|
+
zipStream([
|
|
151
|
+
{
|
|
152
|
+
name: "broken.mp3",
|
|
153
|
+
open: async () => {
|
|
154
|
+
throw new Error("upstream said no");
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
entryFrom("fine.mp3", "fine"),
|
|
158
|
+
]),
|
|
159
|
+
);
|
|
160
|
+
expect(await unzipRead(archive, "fine.mp3")).toBe("fine");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an empty selection still produces a structurally valid (empty) archive", async () => {
|
|
164
|
+
const archive = await collect(zipStream([]));
|
|
165
|
+
const listing = await unzipList(archive);
|
|
166
|
+
expect(listing).not.toContain("cannot find zipfile directory");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("nested paths survive, so a big archive extracts into album folders", async () => {
|
|
170
|
+
const archive = await collect(zipStream([entryFrom("In Sides/Belfast.mp3", "b")]));
|
|
171
|
+
expect(await unzipRead(archive, "In Sides/Belfast.mp3")).toBe("b");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("UTF-8 names are stored as UTF-8 bytes with the UTF-8 flag set", async () => {
|
|
175
|
+
// Asserted on the ARCHIVE, not on `unzip -l`: macOS ships Info-ZIP 6.0, whose
|
|
176
|
+
// listing mangles non-ASCII for display regardless of what the file contains.
|
|
177
|
+
// This library has accented and non-Latin titles, so the encoding has to be right.
|
|
178
|
+
const name = "Bjรถrk - Jรณga.mp3";
|
|
179
|
+
const archive = await collect(zipStream([entryFrom(name, "x")]));
|
|
180
|
+
const encoded = new TextEncoder().encode(name);
|
|
181
|
+
const found = archive.some((_, i) => encoded.every((b, k) => archive[i + k] === b));
|
|
182
|
+
expect(found).toBe(true);
|
|
183
|
+
|
|
184
|
+
// General-purpose flag bit 11 (0x0800) = "the name is UTF-8". It sits at offset 6
|
|
185
|
+
// of the local file header, which starts the archive.
|
|
186
|
+
const flags = archive[6]! | (archive[7]! << 8);
|
|
187
|
+
expect(flags & 0x0800).toBe(0x0800);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("writes the ZIP64 end-of-central-directory record unconditionally", async () => {
|
|
191
|
+
// Written for every archive rather than only past 4 GB: a conditional format is a
|
|
192
|
+
// format that is only ever tested on the small path. 0x06064b50, little-endian.
|
|
193
|
+
const archive = await collect(zipStream([entryFrom("a.mp3", "a")]));
|
|
194
|
+
const signature = [0x50, 0x4b, 0x06, 0x06];
|
|
195
|
+
const found = archive.some((_, i) => signature.every((b, k) => archive[i + k] === b));
|
|
196
|
+
expect(found).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("streams lazily โ a later entry is not opened before an earlier one is consumed", async () => {
|
|
200
|
+
// This is what keeps a 2,000-file archive from opening 2,000 connections at once.
|
|
201
|
+
const opened: string[] = [];
|
|
202
|
+
const lazy = (name: string): ZipEntry => ({
|
|
203
|
+
name,
|
|
204
|
+
open: async () => {
|
|
205
|
+
opened.push(name);
|
|
206
|
+
return new ReadableStream<Uint8Array>({
|
|
207
|
+
start(controller) {
|
|
208
|
+
controller.enqueue(bytesOf("x"));
|
|
209
|
+
controller.close();
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const stream = zipStream([lazy("1.mp3"), lazy("2.mp3"), lazy("3.mp3")]);
|
|
215
|
+
const reader = stream.getReader();
|
|
216
|
+
await reader.read();
|
|
217
|
+
expect(opened).toEqual(["1.mp3"]);
|
|
218
|
+
await reader.cancel();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('folders and dates (desk)', () => {
|
|
223
|
+
test('๐ด an EMPTY folder survives extraction โ a folder download that drops it is not a backup', async () => {
|
|
224
|
+
const archive = await collect(zipStream([{ name: 'Project/Empty', directory: true }, entryFrom('Project/a.md', 'a')]));
|
|
225
|
+
const run = await unzip(archive, ['-o', '-q', '-d', 'out']);
|
|
226
|
+
try {
|
|
227
|
+
expect(statSync(join(run.dir, 'out/Project/Empty')).isDirectory()).toBe(true);
|
|
228
|
+
expect(existsSync(join(run.dir, 'out/Project/a.md'))).toBe(true);
|
|
229
|
+
} finally {
|
|
230
|
+
run.cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a folder named twice is written once', async () => {
|
|
235
|
+
const listing = await unzipList(await collect(zipStream([{ name: 'A', directory: true }, { name: 'A/', directory: true }])));
|
|
236
|
+
expect(listing.match(/ A\//g)?.length).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a duplicate inside a dotted folder numbers the FILE, not the folder's dot", async () => {
|
|
240
|
+
const listing = await unzipList(await collect(zipStream([entryFrom('v1.2/notes', 'a'), entryFrom('v1.2/notes', 'b')])));
|
|
241
|
+
expect(listing).toContain('v1.2/notes (2)');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('a file keeps its date', async () => {
|
|
245
|
+
const listing = await unzipList(await collect(zipStream([{ ...entryFrom('dated.txt', 'x'), modified: '2026-10-15T12:34:56Z' }])));
|
|
246
|
+
expect(listing).toMatch(/10-15-2026\s+12:34/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('dosDateTime: before 1980 or unreadable is the format zero, never a wrapped year', () => {
|
|
250
|
+
expect(dosDateTime('1970-01-01T00:00:00Z')).toEqual({ date: 33, time: 0 });
|
|
251
|
+
expect(dosDateTime('not a date')).toEqual({ date: 33, time: 0 });
|
|
252
|
+
expect(dosDateTime(null)).toEqual({ date: 33, time: 0 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('bytes in hand and an async iterable of entries both work', async () => {
|
|
256
|
+
async function* entries(): AsyncGenerator<ZipEntry> {
|
|
257
|
+
yield { name: 'inline.txt', open: async () => bytesOf('inline bytes') };
|
|
258
|
+
yield { name: 'empty.txt', open: async () => new Uint8Array(0) };
|
|
259
|
+
}
|
|
260
|
+
const archive = await collect(zipStream(entries()));
|
|
261
|
+
expect(await unzipRead(archive, 'inline.txt')).toBe('inline bytes');
|
|
262
|
+
expect(await unzipList(archive)).toContain('empty.txt');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A streaming ZIP writer โ a download of many files as one, built as it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Graduated from `apps/music/src/server/zip.ts` (2026-10-15, forge task desk-updates) when desk
|
|
5
|
+
* needed the same thing for "download this folder": two copies of a hand-rolled format writer
|
|
6
|
+
* is two places for the next bug. Folders (directory entries) and file dates arrived with it,
|
|
7
|
+
* because a folder download that loses its empty folders and stamps every file 1980 is not a
|
|
8
|
+
* backup.
|
|
9
|
+
*
|
|
10
|
+
* โโ Why this is hand-written rather than a dependency โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
11
|
+
* Three properties are needed together, and the popular libraries give at most two:
|
|
12
|
+
*
|
|
13
|
+
* ยท **Store, never deflate.** Pictures, video, audio and `.xlsx` are already compressed.
|
|
14
|
+
* Deflating them spends CPU โ on a Worker, the scarce thing โ to produce a slightly LARGER
|
|
15
|
+
* file. Method 0 (store) is the correct encoding for them, and a few kilobytes of notes
|
|
16
|
+
* beside them are not worth a second code path.
|
|
17
|
+
* ยท **True streaming.** "Download this folder" can be thousands of files and many gigabytes.
|
|
18
|
+
* Nothing is buffered beyond one entry: bytes arrive from the store and leave for the
|
|
19
|
+
* browser in the same pass.
|
|
20
|
+
* ยท **ZIP64.** 4 GB is an entirely reachable selection, and the classic format's 32-bit size
|
|
21
|
+
* fields wrap silently at that point. A truncated archive that reports success is the worst
|
|
22
|
+
* available failure, so ZIP64 is written UNCONDITIONALLY rather than only when a threshold is
|
|
23
|
+
* crossed โ a conditional format is a format that is only tested on the small path.
|
|
24
|
+
*
|
|
25
|
+
* โโ Streaming means data descriptors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
26
|
+
* A local file header must state the CRC-32 and the sizes, and none of those are known until
|
|
27
|
+
* the file's bytes have gone by. The format's answer is general-purpose flag bit 3: write zeros
|
|
28
|
+
* in the header, and follow the data with a DATA DESCRIPTOR carrying the real values. That is
|
|
29
|
+
* what makes a single forward pass possible, why this writer never seeks, and so why the
|
|
30
|
+
* response can be a `ReadableStream` and the server's memory stays flat whatever the size.
|
|
31
|
+
*
|
|
32
|
+
* โโ The CPU it costs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
33
|
+
* The CRC-32 is computed over every byte in the isolate: measured 2026-09-22 at ~280 MB of CPU
|
|
34
|
+
* per second (240 MB in 860 ms). A Worker's default budget is 30 s of CPU per request, so a
|
|
35
|
+
* caller on a Worker caps what one archive may hold โ music at 4 GB / 2,000 files โ and says so
|
|
36
|
+
* BEFORE the download starts, rather than letting the edge cut it off part-way.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// โโ CRC-32 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
40
|
+
|
|
41
|
+
/** The standard IEEE 802.3 table, built once. */
|
|
42
|
+
const CRC_TABLE: Uint32Array = (() => {
|
|
43
|
+
const table = new Uint32Array(256);
|
|
44
|
+
for (let i = 0; i < 256; i++) {
|
|
45
|
+
let c = i;
|
|
46
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
47
|
+
table[i] = c >>> 0;
|
|
48
|
+
}
|
|
49
|
+
return table;
|
|
50
|
+
})();
|
|
51
|
+
|
|
52
|
+
/** Incremental CRC-32, so it can be fed one chunk at a time as bytes stream past. */
|
|
53
|
+
export class Crc32 {
|
|
54
|
+
private crc = 0xffffffff;
|
|
55
|
+
|
|
56
|
+
update(bytes: Uint8Array): void {
|
|
57
|
+
let crc = this.crc;
|
|
58
|
+
for (let i = 0; i < bytes.length; i++) crc = CRC_TABLE[(crc ^ (bytes[i] as number)) & 0xff]! ^ (crc >>> 8);
|
|
59
|
+
this.crc = crc;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get value(): number {
|
|
63
|
+
return (this.crc ^ 0xffffffff) >>> 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// โโ Little-endian writing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
68
|
+
|
|
69
|
+
class ByteWriter {
|
|
70
|
+
private parts: Uint8Array[] = [];
|
|
71
|
+
|
|
72
|
+
u16(n: number): this {
|
|
73
|
+
const b = new Uint8Array(2);
|
|
74
|
+
new DataView(b.buffer).setUint16(0, n, true);
|
|
75
|
+
this.parts.push(b);
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
u32(n: number): this {
|
|
80
|
+
const b = new Uint8Array(4);
|
|
81
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, true);
|
|
82
|
+
this.parts.push(b);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 64-bit, via BigInt โ a whole-library download can pass 2^32, and Number would quietly lose precision past 2^53. */
|
|
87
|
+
u64(n: number | bigint): this {
|
|
88
|
+
const b = new Uint8Array(8);
|
|
89
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
|
|
90
|
+
this.parts.push(b);
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
raw(bytes: Uint8Array): this {
|
|
95
|
+
this.parts.push(bytes);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
done(): Uint8Array {
|
|
100
|
+
const total = this.parts.reduce((n, p) => n + p.length, 0);
|
|
101
|
+
const out = new Uint8Array(total);
|
|
102
|
+
let at = 0;
|
|
103
|
+
for (const part of this.parts) {
|
|
104
|
+
out.set(part, at);
|
|
105
|
+
at += part.length;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One member of the archive: a file whose bytes are opened lazily, or a folder. */
|
|
112
|
+
export type ZipEntry =
|
|
113
|
+
| {
|
|
114
|
+
/** Path inside the archive, `/`-separated. Sanitized by {@link zipStream}. */
|
|
115
|
+
name: string;
|
|
116
|
+
/**
|
|
117
|
+
* Opens the bytes โ a stream, or bytes already in hand. Called only when the entry's turn
|
|
118
|
+
* comes, so a 2,000-file archive does not open 2,000 connections at once. Returning `null`
|
|
119
|
+
* (or throwing) skips the entry: a file that vanished must not abort the rest.
|
|
120
|
+
*/
|
|
121
|
+
open: () => Promise<ReadableStream<Uint8Array> | Uint8Array | null>;
|
|
122
|
+
/** When the file last changed. Omitted: the archive says 1980-01-01, the format's zero. */
|
|
123
|
+
modified?: Date | string | null;
|
|
124
|
+
directory?: false;
|
|
125
|
+
}
|
|
126
|
+
| {
|
|
127
|
+
/** A folder โ written so an EMPTY one survives the round trip. A trailing `/` is added. */
|
|
128
|
+
name: string;
|
|
129
|
+
directory: true;
|
|
130
|
+
modified?: Date | string | null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const encoder = new TextEncoder();
|
|
134
|
+
|
|
135
|
+
/** ZIP64 needs version 4.5; bit 3 = sizes/CRC follow in a data descriptor; bit 11 = UTF-8
|
|
136
|
+
* names, which every accented and non-Latin file name needs. */
|
|
137
|
+
const VERSION_ZIP64 = 45;
|
|
138
|
+
const FLAG_DATA_DESCRIPTOR = 0x08;
|
|
139
|
+
const FLAG_UTF8 = 0x0800;
|
|
140
|
+
const METHOD_STORE = 0;
|
|
141
|
+
/** MS-DOS directory attribute, in the low byte of the external attributes. */
|
|
142
|
+
const ATTR_DIRECTORY = 0x10;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Names inside a zip must not escape the extraction directory, and must not carry characters
|
|
146
|
+
* Windows refuses. `..` segments and absolute paths are the security half; the character set is
|
|
147
|
+
* the "it downloaded but won't extract" half. Each segment is capped, so a long name inside a
|
|
148
|
+
* deep folder keeps its folders.
|
|
149
|
+
*/
|
|
150
|
+
export function safeEntryName(name: string, fallback = "file"): string {
|
|
151
|
+
// Control characters (< 0x20) and the set Windows refuses. Filtered by code point rather than
|
|
152
|
+
// by a regex class, because a literal control character inside a regex is unreadable and the
|
|
153
|
+
// linter is right to object to it.
|
|
154
|
+
const FORBIDDEN = new Set(["<", ">", ":", '"', "|", "?", "*"]);
|
|
155
|
+
const segments = name
|
|
156
|
+
.split(/[/\\]/)
|
|
157
|
+
.filter((seg) => seg !== "" && seg !== "." && seg !== "..")
|
|
158
|
+
.map((seg) => {
|
|
159
|
+
let out = "";
|
|
160
|
+
for (const ch of seg) out += ch.codePointAt(0)! < 0x20 || FORBIDDEN.has(ch) ? "_" : ch;
|
|
161
|
+
// Windows also refuses a segment ending in a dot or a space; trimmed rather than replaced.
|
|
162
|
+
return out.slice(0, 200).replace(/[. ]+$/, "") || fallback;
|
|
163
|
+
});
|
|
164
|
+
return segments.join("/") || fallback;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The MS-DOS date and time a zip header carries โ local wall-clock, 2-second resolution, 1980 at the earliest. */
|
|
168
|
+
export function dosDateTime(when: Date | string | null | undefined): { date: number; time: number } {
|
|
169
|
+
const at = when ? new Date(when) : null;
|
|
170
|
+
if (!at || Number.isNaN(at.getTime()) || at.getUTCFullYear() < 1980) return { date: (0 << 9) | (1 << 5) | 1, time: 0 };
|
|
171
|
+
// UTC, not the server's zone: a Worker has none, and the same archive should not depend on where it was built.
|
|
172
|
+
const year = Math.min(at.getUTCFullYear(), 2107) - 1980;
|
|
173
|
+
return {
|
|
174
|
+
date: (year << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
|
|
175
|
+
time: (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | Math.floor(at.getUTCSeconds() / 2),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function asStream(body: ReadableStream<Uint8Array> | Uint8Array): ReadableStream<Uint8Array> {
|
|
180
|
+
if (!(body instanceof Uint8Array)) return body;
|
|
181
|
+
return new ReadableStream<Uint8Array>({
|
|
182
|
+
start(controller) {
|
|
183
|
+
if (body.byteLength > 0) controller.enqueue(body);
|
|
184
|
+
controller.close();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the archive as a stream.
|
|
191
|
+
*
|
|
192
|
+
* Duplicate file names are disambiguated rather than colliding โ two different files that both
|
|
193
|
+
* want `Notes/Summary.md` produce `Summary.md` and `Summary (2).md`, because a zip with two
|
|
194
|
+
* identical names extracts as one file, silently losing the other. A duplicate FOLDER is written
|
|
195
|
+
* once: its files already land in it.
|
|
196
|
+
*/
|
|
197
|
+
export function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array> {
|
|
198
|
+
interface Central {
|
|
199
|
+
name: Uint8Array;
|
|
200
|
+
crc: number;
|
|
201
|
+
size: bigint;
|
|
202
|
+
offset: bigint;
|
|
203
|
+
date: number;
|
|
204
|
+
time: number;
|
|
205
|
+
directory: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const central: Central[] = [];
|
|
209
|
+
let offset = 0n;
|
|
210
|
+
const used = new Set<string>();
|
|
211
|
+
const iterator: AsyncIterator<ZipEntry> | Iterator<ZipEntry> =
|
|
212
|
+
Symbol.asyncIterator in entries ? (entries as AsyncIterable<ZipEntry>)[Symbol.asyncIterator]() : (entries as Iterable<ZipEntry>)[Symbol.iterator]();
|
|
213
|
+
|
|
214
|
+
return new ReadableStream<Uint8Array>({
|
|
215
|
+
async pull(controller) {
|
|
216
|
+
// One entry per pull keeps backpressure honest: the next file is not opened until the
|
|
217
|
+
// consumer has taken the previous one.
|
|
218
|
+
//
|
|
219
|
+
// ๐ด The loop is not decoration. A `pull` that returns having enqueued NOTHING is not
|
|
220
|
+
// guaranteed to be called again, so an entry that turns out to be unreadable would stall
|
|
221
|
+
// the entire archive โ the consumer waits forever for bytes that will never come. Skipping
|
|
222
|
+
// therefore has to continue to the next entry within the SAME pull. (Measured in music:
|
|
223
|
+
// two tests hung for the full 5 s timeout before this loop existed.)
|
|
224
|
+
while (true) {
|
|
225
|
+
const next = await iterator.next();
|
|
226
|
+
if (next.done) break;
|
|
227
|
+
const entry = next.value;
|
|
228
|
+
|
|
229
|
+
const directory = entry.directory === true;
|
|
230
|
+
let name = safeEntryName(entry.name);
|
|
231
|
+
if (directory) {
|
|
232
|
+
name = `${name}/`;
|
|
233
|
+
if (used.has(name)) continue;
|
|
234
|
+
} else if (used.has(name)) {
|
|
235
|
+
const slash = name.lastIndexOf("/");
|
|
236
|
+
const dot = name.lastIndexOf(".");
|
|
237
|
+
const cut = dot > slash + 1 ? dot : name.length;
|
|
238
|
+
const stem = name.slice(0, cut);
|
|
239
|
+
const ext = name.slice(cut);
|
|
240
|
+
let n = 2;
|
|
241
|
+
while (used.has(`${stem} (${n})${ext}`)) n++;
|
|
242
|
+
name = `${stem} (${n})${ext}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let body: ReadableStream<Uint8Array> | null = null;
|
|
246
|
+
if (!directory) {
|
|
247
|
+
const opened = await entry.open().catch(() => null);
|
|
248
|
+
// A file whose bytes have gone is skipped, not fatal: one missing file must not abort
|
|
249
|
+
// a 2,000-file download that is otherwise fine. `continue`, never `return` โ see the
|
|
250
|
+
// note on the loop above.
|
|
251
|
+
if (!opened) continue;
|
|
252
|
+
body = asStream(opened);
|
|
253
|
+
}
|
|
254
|
+
used.add(name);
|
|
255
|
+
const nameBytes = encoder.encode(name);
|
|
256
|
+
const { date, time } = dosDateTime(entry.modified);
|
|
257
|
+
|
|
258
|
+
// ZIP64 extra field, sizes zeroed โ the data descriptor carries the truth.
|
|
259
|
+
const localExtra = new ByteWriter().u16(0x0001).u16(16).u64(0).u64(0).done();
|
|
260
|
+
const header = new ByteWriter()
|
|
261
|
+
.u32(0x04034b50)
|
|
262
|
+
.u16(VERSION_ZIP64)
|
|
263
|
+
.u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
|
|
264
|
+
.u16(METHOD_STORE)
|
|
265
|
+
.u16(time)
|
|
266
|
+
.u16(date)
|
|
267
|
+
.u32(0) // crc, in the descriptor
|
|
268
|
+
.u32(0xffffffff) // compressed size โ see ZIP64 extra
|
|
269
|
+
.u32(0xffffffff) // uncompressed size โ see ZIP64 extra
|
|
270
|
+
.u16(nameBytes.length)
|
|
271
|
+
.u16(localExtra.length)
|
|
272
|
+
.raw(nameBytes)
|
|
273
|
+
.raw(localExtra)
|
|
274
|
+
.done();
|
|
275
|
+
|
|
276
|
+
controller.enqueue(header);
|
|
277
|
+
const entryOffset = offset;
|
|
278
|
+
offset += BigInt(header.length);
|
|
279
|
+
|
|
280
|
+
const crc = new Crc32();
|
|
281
|
+
let size = 0n;
|
|
282
|
+
if (body) {
|
|
283
|
+
const reader = body.getReader();
|
|
284
|
+
while (true) {
|
|
285
|
+
const { done, value } = await reader.read();
|
|
286
|
+
if (done) break;
|
|
287
|
+
crc.update(value);
|
|
288
|
+
size += BigInt(value.length);
|
|
289
|
+
controller.enqueue(value);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
offset += size;
|
|
293
|
+
|
|
294
|
+
// ZIP64 data descriptor: 8-byte sizes, unlike the 4-byte classic form.
|
|
295
|
+
const descriptor = new ByteWriter().u32(0x08074b50).u32(crc.value).u64(size).u64(size).done();
|
|
296
|
+
controller.enqueue(descriptor);
|
|
297
|
+
offset += BigInt(descriptor.length);
|
|
298
|
+
|
|
299
|
+
central.push({ name: nameBytes, crc: crc.value, size, offset: entryOffset, date, time, directory });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Central directory, then the ZIP64 end records, then the classic end record.
|
|
304
|
+
const start = offset;
|
|
305
|
+
for (const item of central) {
|
|
306
|
+
const extra = new ByteWriter().u16(0x0001).u16(24).u64(item.size).u64(item.size).u64(item.offset).done();
|
|
307
|
+
const record = new ByteWriter()
|
|
308
|
+
.u32(0x02014b50)
|
|
309
|
+
.u16(VERSION_ZIP64) // version made by (MS-DOS attributes)
|
|
310
|
+
.u16(VERSION_ZIP64) // version needed
|
|
311
|
+
.u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
|
|
312
|
+
.u16(METHOD_STORE)
|
|
313
|
+
.u16(item.time)
|
|
314
|
+
.u16(item.date)
|
|
315
|
+
.u32(item.crc)
|
|
316
|
+
.u32(0xffffffff)
|
|
317
|
+
.u32(0xffffffff)
|
|
318
|
+
.u16(item.name.length)
|
|
319
|
+
.u16(extra.length)
|
|
320
|
+
.u16(0) // comment length
|
|
321
|
+
.u16(0) // disk number
|
|
322
|
+
.u16(0) // internal attrs
|
|
323
|
+
.u32(item.directory ? ATTR_DIRECTORY : 0) // external attrs
|
|
324
|
+
.u32(0xffffffff) // local header offset โ ZIP64 extra
|
|
325
|
+
.raw(item.name)
|
|
326
|
+
.raw(extra)
|
|
327
|
+
.done();
|
|
328
|
+
controller.enqueue(record);
|
|
329
|
+
offset += BigInt(record.length);
|
|
330
|
+
}
|
|
331
|
+
const centralSize = offset - start;
|
|
332
|
+
|
|
333
|
+
const zip64End = new ByteWriter()
|
|
334
|
+
.u32(0x06064b50)
|
|
335
|
+
.u64(44) // size of this record, minus its first 12 bytes
|
|
336
|
+
.u16(VERSION_ZIP64)
|
|
337
|
+
.u16(VERSION_ZIP64)
|
|
338
|
+
.u32(0)
|
|
339
|
+
.u32(0)
|
|
340
|
+
.u64(central.length)
|
|
341
|
+
.u64(central.length)
|
|
342
|
+
.u64(centralSize)
|
|
343
|
+
.u64(start)
|
|
344
|
+
.done();
|
|
345
|
+
controller.enqueue(zip64End);
|
|
346
|
+
|
|
347
|
+
const locator = new ByteWriter()
|
|
348
|
+
.u32(0x07064b50)
|
|
349
|
+
.u32(0)
|
|
350
|
+
.u64(offset) // where the ZIP64 end record starts
|
|
351
|
+
.u32(1)
|
|
352
|
+
.done();
|
|
353
|
+
controller.enqueue(locator);
|
|
354
|
+
|
|
355
|
+
// The classic record still has to be there for every reader to find the archive; its
|
|
356
|
+
// 0xffff/0xffffffff sentinels say "look at the ZIP64 record instead".
|
|
357
|
+
const end = new ByteWriter()
|
|
358
|
+
.u32(0x06054b50)
|
|
359
|
+
.u16(0)
|
|
360
|
+
.u16(0)
|
|
361
|
+
.u16(central.length > 0xffff ? 0xffff : central.length)
|
|
362
|
+
.u16(central.length > 0xffff ? 0xffff : central.length)
|
|
363
|
+
.u32(0xffffffff)
|
|
364
|
+
.u32(0xffffffff)
|
|
365
|
+
.u16(0)
|
|
366
|
+
.done();
|
|
367
|
+
controller.enqueue(end);
|
|
368
|
+
|
|
369
|
+
controller.close();
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|