@lp-labs/archive-stream 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Libin Prasanth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # @lp-labs/archive-stream
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Standards-compatible streaming stored ZIP writing and bounded buffered ZIP extraction.
4
+
5
+ Original independent MIT TypeScript implementation with **zero runtime/peer dependencies**. ESM and CommonJS with separate declarations, Node 18+ (browser use when adapters/APIs are available). Development dependencies are not consumer runtime dependencies.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @lp-labs/archive-stream
11
+ ```
12
+
13
+ ## Complete example
14
+
15
+ ```js
16
+ import assert from 'node:assert/strict';
17
+ import { writeZip, extractZipBuffered, crc32, ArchiveStreamError } from '@lp-labs/archive-stream';
18
+ const segments = [];
19
+ for await (const chunk of writeZip([
20
+ { name: 'hello.txt', data: new TextEncoder().encode('hello') },
21
+ ]))
22
+ segments.push(chunk);
23
+ const bytes = Uint8Array.from(segments.flatMap((chunk) => [...chunk]));
24
+ const files = [];
25
+ for await (const entry of extractZipBuffered(bytes)) files.push(entry);
26
+ assert.equal(files[0].name, 'hello.txt');
27
+ assert.equal(new TextDecoder().decode(files[0].data), 'hello');
28
+ assert.equal(crc32(new TextEncoder().encode('123456789')), 0xcbf43926);
29
+ ```
30
+
31
+ CommonJS exposes the same API: `const api = require('@lp-labs/archive-stream')`.
32
+
33
+ ## Scope, customization and performance
34
+
35
+ Writer emits real ZIP method0 with UTF8 names, signed descriptors, central directory and EOCD. Input chunks max1MiB, archive64MiB, entry16MiB, entries4096, metadata4MiB defaults. Extractor explicitly buffers entire archive (peak approximately twice archive bytes plus segments), NOT streaming decompression. Supports stored-only, ASCII/UTF8 names, ordinary comments/extras and signed/unsigned descriptors. Rejects compressed/encrypted/ZIP64/multidisk/preambles/gaps/overlap. CRC checked before each yield, not all entries before first yield; stage output before activation. No filesystem writes. Path policy is basic ZIP syntax, not OS extraction containment: use a dedicated guard. Returned data slices owned separately. Cancellation cooperative between chunks; writer consumers may receive partial output on late failure.
36
+
37
+ Configure explicit limits, clocks, policies and adapters through the API below. Error classes and standard TypeError/RangeError reject malformed input; adapter/handler errors propagate. Await asynchronous methods and handle rejection in your application; no success-shaped fallback on failed work. Inputs must be trusted ordinary data, not hostile proxies; adapters must meet documented atomicity, isolation and ownership contracts. This package never publishes data, loads external code or starts background services at import time.
38
+
39
+ ## API reference
40
+
41
+ The complete exported TypeScript contract (including option defaults discussed above, method return shapes, and error codes) is included below. TypeScript users import interfaces using `import type`; JavaScript users pass matching ordinary objects. Signature optionals indicate intentionally optional fields, not silent recovery.
42
+
43
+ ```ts
44
+ export type ArchiveErrorCode =
45
+ 'invalid' | 'limit' | 'unsupported' | 'corrupt' | 'truncated' | 'crc' | 'cancelled';
46
+ export declare class ArchiveStreamError extends Error {
47
+ readonly code: ArchiveErrorCode;
48
+ constructor(code: ArchiveErrorCode, message: string);
49
+ }
50
+ export interface ZipLimits {
51
+ maxArchiveBytes?: number;
52
+ maxEntryBytes?: number;
53
+ maxEntries?: number;
54
+ maxNameBytes?: number;
55
+ maxMetadataBytes?: number;
56
+ maxChunkBytes?: number;
57
+ signal?: AbortSignal;
58
+ }
59
+ export interface ZipInputEntry {
60
+ name: string;
61
+ data: Uint8Array | AsyncIterable<Uint8Array>;
62
+ }
63
+ export interface ZipExtractedEntry {
64
+ name: string;
65
+ data: Uint8Array;
66
+ crc32: number;
67
+ }
68
+ export declare function crc32(bytes: Uint8Array): number;
69
+ /** Streaming stored-method ZIP writer with signed data descriptors and bounded central metadata. */
70
+ export declare function writeZip(
71
+ entries: AsyncIterable<ZipInputEntry> | Iterable<ZipInputEntry>,
72
+ options?: ZipLimits,
73
+ ): AsyncGenerator<Uint8Array>;
74
+ /** Intentionally buffers the entire archive before parsing; no decompression or filesystem writes. */
75
+ export declare function extractZipBuffered(
76
+ source: AsyncIterable<Uint8Array> | Uint8Array,
77
+ options?: ZipLimits,
78
+ ): AsyncGenerator<ZipExtractedEntry>;
79
+ ```
80
+
81
+ ## Support
82
+
83
+ If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
84
+
85
+ ## Author
86
+
87
+ Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
@@ -0,0 +1,28 @@
1
+ export type ArchiveErrorCode = 'invalid' | 'limit' | 'unsupported' | 'corrupt' | 'truncated' | 'crc' | 'cancelled';
2
+ export declare class ArchiveStreamError extends Error {
3
+ readonly code: ArchiveErrorCode;
4
+ constructor(code: ArchiveErrorCode, message: string);
5
+ }
6
+ export interface ZipLimits {
7
+ maxArchiveBytes?: number;
8
+ maxEntryBytes?: number;
9
+ maxEntries?: number;
10
+ maxNameBytes?: number;
11
+ maxMetadataBytes?: number;
12
+ maxChunkBytes?: number;
13
+ signal?: AbortSignal;
14
+ }
15
+ export interface ZipInputEntry {
16
+ name: string;
17
+ data: Uint8Array | AsyncIterable<Uint8Array>;
18
+ }
19
+ export interface ZipExtractedEntry {
20
+ name: string;
21
+ data: Uint8Array;
22
+ crc32: number;
23
+ }
24
+ export declare function crc32(bytes: Uint8Array): number;
25
+ /** Streaming stored-method ZIP writer with signed data descriptors and bounded central metadata. */
26
+ export declare function writeZip(entries: AsyncIterable<ZipInputEntry> | Iterable<ZipInputEntry>, options?: ZipLimits): AsyncGenerator<Uint8Array>;
27
+ /** Intentionally buffers the entire archive before parsing; no decompression or filesystem writes. */
28
+ export declare function extractZipBuffered(source: AsyncIterable<Uint8Array> | Uint8Array, options?: ZipLimits): AsyncGenerator<ZipExtractedEntry>;
@@ -0,0 +1,371 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ArchiveStreamError = void 0;
4
+ exports.crc32 = crc32;
5
+ exports.writeZip = writeZip;
6
+ exports.extractZipBuffered = extractZipBuffered;
7
+ class ArchiveStreamError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = 'ArchiveStreamError';
13
+ }
14
+ }
15
+ exports.ArchiveStreamError = ArchiveStreamError;
16
+ function fail(code, message) {
17
+ throw new ArchiveStreamError(code, message);
18
+ }
19
+ function bounds(options) {
20
+ const result = {
21
+ archive: options.maxArchiveBytes ?? 67108864,
22
+ entry: options.maxEntryBytes ?? 16777216,
23
+ entries: options.maxEntries ?? 4096,
24
+ name: options.maxNameBytes ?? 4096,
25
+ metadata: options.maxMetadataBytes ?? 4194304,
26
+ chunk: options.maxChunkBytes ?? 1048576,
27
+ };
28
+ for (const value of Object.values(result))
29
+ if (!Number.isSafeInteger(value) || value < 1)
30
+ fail('invalid', 'Limits must be positive safe integers');
31
+ if (result.archive >= 0xffffffff ||
32
+ result.entry >= 0xffffffff ||
33
+ result.entries >= 0xffff ||
34
+ result.name > 0xffff) {
35
+ fail('unsupported', 'ZIP64-sized limits are unsupported');
36
+ }
37
+ return result;
38
+ }
39
+ function check(signal) {
40
+ if (signal?.aborted)
41
+ fail('cancelled', 'Archive operation cancelled');
42
+ }
43
+ function updateCrc(crc, bytes) {
44
+ for (const byte of bytes) {
45
+ crc ^= byte;
46
+ for (let bit = 0; bit < 8; bit++)
47
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
48
+ }
49
+ return crc >>> 0;
50
+ }
51
+ function crc32(bytes) {
52
+ if (!(bytes instanceof Uint8Array))
53
+ fail('invalid', 'Expected Uint8Array');
54
+ return (updateCrc(0xffffffff, bytes) ^ 0xffffffff) >>> 0;
55
+ }
56
+ function header(size) {
57
+ const bytes = new Uint8Array(size);
58
+ return { bytes, view: new DataView(bytes.buffer) };
59
+ }
60
+ function nameBytes(name, bound) {
61
+ if (typeof name !== 'string' ||
62
+ !name ||
63
+ name.startsWith('/') ||
64
+ name.includes('\\') ||
65
+ name.includes('\0') ||
66
+ /^[A-Za-z]:/.test(name) ||
67
+ name
68
+ .split('/')
69
+ .some((part, index, array) => part === '..' || part === '.' || (!part && index !== array.length - 1))) {
70
+ fail('invalid', 'Unsafe or invalid archive path');
71
+ }
72
+ const bytes = new TextEncoder().encode(name);
73
+ if (new TextDecoder('utf-8', { fatal: true }).decode(bytes) !== name)
74
+ fail('invalid', 'Name has unpaired Unicode surrogates');
75
+ if (bytes.length > bound.name)
76
+ fail('limit', 'Name byte bound exceeded');
77
+ return bytes;
78
+ }
79
+ /** Streaming stored-method ZIP writer with signed data descriptors and bounded central metadata. */
80
+ async function* writeZip(entries, options = {}) {
81
+ const bound = bounds(options);
82
+ const records = [];
83
+ const names = new Set();
84
+ let offset = 0, metadata = 0;
85
+ function emit(bytes) {
86
+ check(options.signal);
87
+ if (offset + bytes.length > bound.archive)
88
+ fail('limit', 'Archive byte bound exceeded');
89
+ offset += bytes.length;
90
+ return bytes;
91
+ }
92
+ for await (const entry of entries) {
93
+ check(options.signal);
94
+ if (records.length >= bound.entries)
95
+ fail('limit', 'Entry count exceeded');
96
+ const name = nameBytes(entry.name, bound);
97
+ if (names.has(entry.name))
98
+ fail('invalid', 'Duplicate archive path');
99
+ names.add(entry.name);
100
+ metadata += 46 + name.length;
101
+ if (metadata > bound.metadata)
102
+ fail('limit', 'Central metadata bound exceeded');
103
+ const start = offset;
104
+ const local = header(30 + name.length);
105
+ local.view.setUint32(0, 0x04034b50, true);
106
+ local.view.setUint16(4, 20, true);
107
+ local.view.setUint16(6, 0x0808, true);
108
+ local.view.setUint16(12, 0x0021, true);
109
+ local.view.setUint16(26, name.length, true);
110
+ local.bytes.set(name, 30);
111
+ yield emit(local.bytes);
112
+ let size = 0, crc = 0xffffffff;
113
+ const data = entry.data;
114
+ const source = data instanceof Uint8Array
115
+ ? (async function* () {
116
+ for (let i = 0; i < data.length; i += bound.chunk)
117
+ yield data.subarray(i, i + bound.chunk);
118
+ })()
119
+ : data;
120
+ for await (const chunk of source) {
121
+ check(options.signal);
122
+ if (!(chunk instanceof Uint8Array))
123
+ fail('invalid', 'Entry source must yield Uint8Array');
124
+ if (chunk.length > bound.chunk || size + chunk.length > bound.entry)
125
+ fail('limit', 'Entry or input chunk bound exceeded');
126
+ if (entry.name.endsWith('/') && chunk.length)
127
+ fail('invalid', 'Directory must have empty data');
128
+ const bytes = chunk.slice();
129
+ size += bytes.length;
130
+ crc = updateCrc(crc, bytes);
131
+ yield emit(bytes);
132
+ }
133
+ crc = (crc ^ 0xffffffff) >>> 0;
134
+ const descriptor = header(16);
135
+ descriptor.view.setUint32(0, 0x08074b50, true);
136
+ descriptor.view.setUint32(4, crc, true);
137
+ descriptor.view.setUint32(8, size, true);
138
+ descriptor.view.setUint32(12, size, true);
139
+ yield emit(descriptor.bytes);
140
+ records.push({ name, crc, size, offset: start });
141
+ }
142
+ const centralOffset = offset;
143
+ for (const record of records) {
144
+ const central = header(46 + record.name.length);
145
+ central.view.setUint32(0, 0x02014b50, true);
146
+ central.view.setUint16(4, 20, true);
147
+ central.view.setUint16(6, 20, true);
148
+ central.view.setUint16(8, 0x0808, true);
149
+ central.view.setUint16(14, 0x0021, true);
150
+ central.view.setUint32(16, record.crc, true);
151
+ central.view.setUint32(20, record.size, true);
152
+ central.view.setUint32(24, record.size, true);
153
+ central.view.setUint16(28, record.name.length, true);
154
+ central.view.setUint32(42, record.offset, true);
155
+ central.bytes.set(record.name, 46);
156
+ yield emit(central.bytes);
157
+ }
158
+ const centralSize = offset - centralOffset;
159
+ const end = header(22);
160
+ end.view.setUint32(0, 0x06054b50, true);
161
+ end.view.setUint16(8, records.length, true);
162
+ end.view.setUint16(10, records.length, true);
163
+ end.view.setUint32(12, centralSize, true);
164
+ end.view.setUint32(16, centralOffset, true);
165
+ yield emit(end.bytes);
166
+ }
167
+ /** Intentionally buffers the entire archive before parsing; no decompression or filesystem writes. */
168
+ async function* extractZipBuffered(source, options = {}) {
169
+ const bound = bounds(options);
170
+ check(options.signal);
171
+ const segments = [];
172
+ let length = 0;
173
+ if (source instanceof Uint8Array) {
174
+ if (source.length > bound.archive)
175
+ fail('limit', 'Archive byte bound exceeded');
176
+ segments.push(source.slice());
177
+ length = source.length;
178
+ }
179
+ else {
180
+ for await (const chunk of source) {
181
+ check(options.signal);
182
+ if (!(chunk instanceof Uint8Array))
183
+ fail('invalid', 'Archive source must yield Uint8Array');
184
+ if (chunk.length > bound.chunk || length + chunk.length > bound.archive)
185
+ fail('limit', 'Archive or input chunk bound exceeded');
186
+ if (chunk.length)
187
+ segments.push(chunk.slice());
188
+ length += chunk.length;
189
+ }
190
+ }
191
+ check(options.signal);
192
+ if (length < 22)
193
+ fail('truncated', 'Missing ZIP end record');
194
+ const bytes = new Uint8Array(length);
195
+ let cursor = 0;
196
+ for (const chunk of segments) {
197
+ bytes.set(chunk, cursor);
198
+ cursor += chunk.length;
199
+ }
200
+ segments.length = 0;
201
+ const view = new DataView(bytes.buffer);
202
+ function need(at, size) {
203
+ if (!Number.isSafeInteger(at) || at < 0 || at + size > length)
204
+ fail('truncated', 'ZIP record exceeds archive');
205
+ }
206
+ function u16(at) {
207
+ need(at, 2);
208
+ return view.getUint16(at, true);
209
+ }
210
+ function u32(at) {
211
+ need(at, 4);
212
+ return view.getUint32(at, true);
213
+ }
214
+ let end = -1;
215
+ for (let i = length - 22; i >= Math.max(0, length - 65557); i--) {
216
+ if (u32(i) === 0x06054b50 && i + 22 + u16(i + 20) === length) {
217
+ end = i;
218
+ break;
219
+ }
220
+ }
221
+ if (end < 0)
222
+ fail('truncated', 'Missing or truncated ZIP end record');
223
+ if (u16(end + 4) !== 0 || u16(end + 6) !== 0 || u16(end + 8) !== u16(end + 10))
224
+ fail('unsupported', 'Multidisk ZIP unsupported');
225
+ const count = u16(end + 10), centralSize = u32(end + 12), centralOffset = u32(end + 16);
226
+ if (count === 0xffff ||
227
+ centralSize === 0xffffffff ||
228
+ centralOffset === 0xffffffff ||
229
+ (end >= 20 && u32(end - 20) === 0x07064b50))
230
+ fail('unsupported', 'ZIP64 unsupported');
231
+ if (count > bound.entries || centralSize > bound.metadata)
232
+ fail('limit', 'Central directory bound exceeded');
233
+ if (centralOffset + centralSize !== end)
234
+ fail('corrupt', 'Central directory does not end at EOCD');
235
+ need(centralOffset, centralSize);
236
+ function extra(at, size) {
237
+ const stop = at + size;
238
+ while (at < stop) {
239
+ if (at + 4 > stop)
240
+ fail('corrupt', 'Malformed extra field');
241
+ const tag = u16(at), size = u16(at + 2);
242
+ if (tag === 1)
243
+ fail('unsupported', 'ZIP64 extra field unsupported');
244
+ at += 4 + size;
245
+ if (at > stop)
246
+ fail('corrupt', 'Truncated extra field');
247
+ }
248
+ }
249
+ function flags(value, method, version) {
250
+ if (method !== 0)
251
+ fail('unsupported', 'Only stored-method ZIP supported');
252
+ if (value & 1)
253
+ fail('unsupported', 'Encrypted ZIP unsupported');
254
+ if ((value & ~0x0808) !== 0 || version > 20)
255
+ fail('unsupported', 'Unsupported ZIP flags or version');
256
+ }
257
+ const records = [];
258
+ const names = new Set();
259
+ cursor = centralOffset;
260
+ for (let i = 0; i < count; i++) {
261
+ check(options.signal);
262
+ need(cursor, 46);
263
+ if (u32(cursor) !== 0x02014b50)
264
+ fail('corrupt', 'Invalid central directory signature');
265
+ const flag = u16(cursor + 8), method = u16(cursor + 10);
266
+ flags(flag, method, u16(cursor + 6));
267
+ const crc = u32(cursor + 16), compressed = u32(cursor + 20), size = u32(cursor + 24);
268
+ const nameLength = u16(cursor + 28), extraLength = u16(cursor + 30), commentLength = u16(cursor + 32);
269
+ const offset = u32(cursor + 42);
270
+ if (compressed === 0xffffffff || size === 0xffffffff || offset === 0xffffffff)
271
+ fail('unsupported', 'ZIP64 entry unsupported');
272
+ if (u16(cursor + 34) !== 0)
273
+ fail('unsupported', 'Multidisk entry unsupported');
274
+ if (compressed !== size)
275
+ fail('corrupt', 'Stored sizes must match');
276
+ if (size > bound.entry || nameLength > bound.name)
277
+ fail('limit', 'Entry bound exceeded');
278
+ const next = cursor + 46 + nameLength + extraLength + commentLength;
279
+ if (next > end)
280
+ fail('corrupt', 'Central record exceeds directory');
281
+ const encoded = bytes.subarray(cursor + 46, cursor + 46 + nameLength);
282
+ if (!(flag & 0x800) && encoded.some((byte) => byte >= 128))
283
+ fail('unsupported', 'Non-UTF8 non-ASCII filenames unsupported');
284
+ let name;
285
+ try {
286
+ name = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
287
+ }
288
+ catch {
289
+ return fail('corrupt', 'Invalid UTF-8 filename');
290
+ }
291
+ nameBytes(name, bound);
292
+ if (names.has(name))
293
+ fail('corrupt', 'Duplicate archive path');
294
+ names.add(name);
295
+ extra(cursor + 46 + nameLength, extraLength);
296
+ records.push({ name, encoded, flags: flag, crc, size, offset, dataStart: 0, end: 0 });
297
+ cursor = next;
298
+ }
299
+ if (cursor !== end)
300
+ fail('corrupt', 'Central directory count or size mismatch');
301
+ const ordered = [...records].sort((a, b) => a.offset - b.offset);
302
+ let localEnd = 0;
303
+ for (const record of ordered) {
304
+ check(options.signal);
305
+ const at = record.offset;
306
+ if (at !== localEnd)
307
+ fail('corrupt', 'Overlapping entries, gaps, or preamble unsupported');
308
+ need(at, 30);
309
+ if (u32(at) !== 0x04034b50)
310
+ fail('corrupt', 'Invalid local header signature');
311
+ flags(u16(at + 6), u16(at + 8), u16(at + 4));
312
+ if (u16(at + 6) !== record.flags)
313
+ fail('corrupt', 'Local flags mismatch');
314
+ const nl = u16(at + 26), el = u16(at + 28);
315
+ need(at + 30, nl + el);
316
+ if (nl !== record.encoded.length ||
317
+ record.encoded.some((byte, index) => bytes[at + 30 + index] !== byte))
318
+ fail('corrupt', 'Local filename mismatch');
319
+ extra(at + 30 + nl, el);
320
+ const start = at + 30 + nl + el, stop = start + record.size;
321
+ if (stop > centralOffset)
322
+ fail('truncated', 'Entry data exceeds local region');
323
+ if (record.name.endsWith('/') && record.size)
324
+ fail('corrupt', 'Directory contains data');
325
+ if (!(record.flags & 8)) {
326
+ if (u32(at + 14) !== record.crc ||
327
+ u32(at + 18) !== record.size ||
328
+ u32(at + 22) !== record.size)
329
+ fail('corrupt', 'Local CRC or sizes mismatch');
330
+ localEnd = stop;
331
+ }
332
+ else {
333
+ if ((u32(at + 14) !== 0 && u32(at + 14) !== record.crc) ||
334
+ (u32(at + 18) !== 0 && u32(at + 18) !== record.size) ||
335
+ (u32(at + 22) !== 0 && u32(at + 22) !== record.size))
336
+ fail('corrupt', 'Descriptor local values mismatch');
337
+ let descriptorSize = 0;
338
+ if (stop + 16 <= centralOffset &&
339
+ u32(stop) === 0x08074b50 &&
340
+ u32(stop + 4) === record.crc &&
341
+ u32(stop + 8) === record.size &&
342
+ u32(stop + 12) === record.size)
343
+ descriptorSize = 16;
344
+ else if (stop + 12 <= centralOffset &&
345
+ u32(stop) === record.crc &&
346
+ u32(stop + 4) === record.size &&
347
+ u32(stop + 8) === record.size)
348
+ descriptorSize = 12;
349
+ else
350
+ fail('corrupt', 'Missing or invalid data descriptor');
351
+ localEnd = stop + descriptorSize;
352
+ }
353
+ record.dataStart = start;
354
+ record.end = stop;
355
+ }
356
+ if (localEnd !== centralOffset)
357
+ fail('corrupt', 'Local region and central directory mismatch');
358
+ for (const record of records) {
359
+ check(options.signal);
360
+ const data = bytes.subarray(record.dataStart, record.end);
361
+ // Check incrementally to bound cancellation latency for large entries.
362
+ let crc = 0xffffffff;
363
+ for (let i = 0; i < data.length; i += bound.chunk) {
364
+ check(options.signal);
365
+ crc = updateCrc(crc, data.subarray(i, i + bound.chunk));
366
+ }
367
+ if ((crc ^ 0xffffffff) >>> 0 !== record.crc)
368
+ fail('crc', 'Entry CRC32 mismatch');
369
+ yield { name: record.name, data: data.slice(), crc32: record.crc };
370
+ }
371
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,28 @@
1
+ export type ArchiveErrorCode = 'invalid' | 'limit' | 'unsupported' | 'corrupt' | 'truncated' | 'crc' | 'cancelled';
2
+ export declare class ArchiveStreamError extends Error {
3
+ readonly code: ArchiveErrorCode;
4
+ constructor(code: ArchiveErrorCode, message: string);
5
+ }
6
+ export interface ZipLimits {
7
+ maxArchiveBytes?: number;
8
+ maxEntryBytes?: number;
9
+ maxEntries?: number;
10
+ maxNameBytes?: number;
11
+ maxMetadataBytes?: number;
12
+ maxChunkBytes?: number;
13
+ signal?: AbortSignal;
14
+ }
15
+ export interface ZipInputEntry {
16
+ name: string;
17
+ data: Uint8Array | AsyncIterable<Uint8Array>;
18
+ }
19
+ export interface ZipExtractedEntry {
20
+ name: string;
21
+ data: Uint8Array;
22
+ crc32: number;
23
+ }
24
+ export declare function crc32(bytes: Uint8Array): number;
25
+ /** Streaming stored-method ZIP writer with signed data descriptors and bounded central metadata. */
26
+ export declare function writeZip(entries: AsyncIterable<ZipInputEntry> | Iterable<ZipInputEntry>, options?: ZipLimits): AsyncGenerator<Uint8Array>;
27
+ /** Intentionally buffers the entire archive before parsing; no decompression or filesystem writes. */
28
+ export declare function extractZipBuffered(source: AsyncIterable<Uint8Array> | Uint8Array, options?: ZipLimits): AsyncGenerator<ZipExtractedEntry>;
@@ -0,0 +1,364 @@
1
+ export class ArchiveStreamError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'ArchiveStreamError';
7
+ }
8
+ }
9
+ function fail(code, message) {
10
+ throw new ArchiveStreamError(code, message);
11
+ }
12
+ function bounds(options) {
13
+ const result = {
14
+ archive: options.maxArchiveBytes ?? 67108864,
15
+ entry: options.maxEntryBytes ?? 16777216,
16
+ entries: options.maxEntries ?? 4096,
17
+ name: options.maxNameBytes ?? 4096,
18
+ metadata: options.maxMetadataBytes ?? 4194304,
19
+ chunk: options.maxChunkBytes ?? 1048576,
20
+ };
21
+ for (const value of Object.values(result))
22
+ if (!Number.isSafeInteger(value) || value < 1)
23
+ fail('invalid', 'Limits must be positive safe integers');
24
+ if (result.archive >= 0xffffffff ||
25
+ result.entry >= 0xffffffff ||
26
+ result.entries >= 0xffff ||
27
+ result.name > 0xffff) {
28
+ fail('unsupported', 'ZIP64-sized limits are unsupported');
29
+ }
30
+ return result;
31
+ }
32
+ function check(signal) {
33
+ if (signal?.aborted)
34
+ fail('cancelled', 'Archive operation cancelled');
35
+ }
36
+ function updateCrc(crc, bytes) {
37
+ for (const byte of bytes) {
38
+ crc ^= byte;
39
+ for (let bit = 0; bit < 8; bit++)
40
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
41
+ }
42
+ return crc >>> 0;
43
+ }
44
+ export function crc32(bytes) {
45
+ if (!(bytes instanceof Uint8Array))
46
+ fail('invalid', 'Expected Uint8Array');
47
+ return (updateCrc(0xffffffff, bytes) ^ 0xffffffff) >>> 0;
48
+ }
49
+ function header(size) {
50
+ const bytes = new Uint8Array(size);
51
+ return { bytes, view: new DataView(bytes.buffer) };
52
+ }
53
+ function nameBytes(name, bound) {
54
+ if (typeof name !== 'string' ||
55
+ !name ||
56
+ name.startsWith('/') ||
57
+ name.includes('\\') ||
58
+ name.includes('\0') ||
59
+ /^[A-Za-z]:/.test(name) ||
60
+ name
61
+ .split('/')
62
+ .some((part, index, array) => part === '..' || part === '.' || (!part && index !== array.length - 1))) {
63
+ fail('invalid', 'Unsafe or invalid archive path');
64
+ }
65
+ const bytes = new TextEncoder().encode(name);
66
+ if (new TextDecoder('utf-8', { fatal: true }).decode(bytes) !== name)
67
+ fail('invalid', 'Name has unpaired Unicode surrogates');
68
+ if (bytes.length > bound.name)
69
+ fail('limit', 'Name byte bound exceeded');
70
+ return bytes;
71
+ }
72
+ /** Streaming stored-method ZIP writer with signed data descriptors and bounded central metadata. */
73
+ export async function* writeZip(entries, options = {}) {
74
+ const bound = bounds(options);
75
+ const records = [];
76
+ const names = new Set();
77
+ let offset = 0, metadata = 0;
78
+ function emit(bytes) {
79
+ check(options.signal);
80
+ if (offset + bytes.length > bound.archive)
81
+ fail('limit', 'Archive byte bound exceeded');
82
+ offset += bytes.length;
83
+ return bytes;
84
+ }
85
+ for await (const entry of entries) {
86
+ check(options.signal);
87
+ if (records.length >= bound.entries)
88
+ fail('limit', 'Entry count exceeded');
89
+ const name = nameBytes(entry.name, bound);
90
+ if (names.has(entry.name))
91
+ fail('invalid', 'Duplicate archive path');
92
+ names.add(entry.name);
93
+ metadata += 46 + name.length;
94
+ if (metadata > bound.metadata)
95
+ fail('limit', 'Central metadata bound exceeded');
96
+ const start = offset;
97
+ const local = header(30 + name.length);
98
+ local.view.setUint32(0, 0x04034b50, true);
99
+ local.view.setUint16(4, 20, true);
100
+ local.view.setUint16(6, 0x0808, true);
101
+ local.view.setUint16(12, 0x0021, true);
102
+ local.view.setUint16(26, name.length, true);
103
+ local.bytes.set(name, 30);
104
+ yield emit(local.bytes);
105
+ let size = 0, crc = 0xffffffff;
106
+ const data = entry.data;
107
+ const source = data instanceof Uint8Array
108
+ ? (async function* () {
109
+ for (let i = 0; i < data.length; i += bound.chunk)
110
+ yield data.subarray(i, i + bound.chunk);
111
+ })()
112
+ : data;
113
+ for await (const chunk of source) {
114
+ check(options.signal);
115
+ if (!(chunk instanceof Uint8Array))
116
+ fail('invalid', 'Entry source must yield Uint8Array');
117
+ if (chunk.length > bound.chunk || size + chunk.length > bound.entry)
118
+ fail('limit', 'Entry or input chunk bound exceeded');
119
+ if (entry.name.endsWith('/') && chunk.length)
120
+ fail('invalid', 'Directory must have empty data');
121
+ const bytes = chunk.slice();
122
+ size += bytes.length;
123
+ crc = updateCrc(crc, bytes);
124
+ yield emit(bytes);
125
+ }
126
+ crc = (crc ^ 0xffffffff) >>> 0;
127
+ const descriptor = header(16);
128
+ descriptor.view.setUint32(0, 0x08074b50, true);
129
+ descriptor.view.setUint32(4, crc, true);
130
+ descriptor.view.setUint32(8, size, true);
131
+ descriptor.view.setUint32(12, size, true);
132
+ yield emit(descriptor.bytes);
133
+ records.push({ name, crc, size, offset: start });
134
+ }
135
+ const centralOffset = offset;
136
+ for (const record of records) {
137
+ const central = header(46 + record.name.length);
138
+ central.view.setUint32(0, 0x02014b50, true);
139
+ central.view.setUint16(4, 20, true);
140
+ central.view.setUint16(6, 20, true);
141
+ central.view.setUint16(8, 0x0808, true);
142
+ central.view.setUint16(14, 0x0021, true);
143
+ central.view.setUint32(16, record.crc, true);
144
+ central.view.setUint32(20, record.size, true);
145
+ central.view.setUint32(24, record.size, true);
146
+ central.view.setUint16(28, record.name.length, true);
147
+ central.view.setUint32(42, record.offset, true);
148
+ central.bytes.set(record.name, 46);
149
+ yield emit(central.bytes);
150
+ }
151
+ const centralSize = offset - centralOffset;
152
+ const end = header(22);
153
+ end.view.setUint32(0, 0x06054b50, true);
154
+ end.view.setUint16(8, records.length, true);
155
+ end.view.setUint16(10, records.length, true);
156
+ end.view.setUint32(12, centralSize, true);
157
+ end.view.setUint32(16, centralOffset, true);
158
+ yield emit(end.bytes);
159
+ }
160
+ /** Intentionally buffers the entire archive before parsing; no decompression or filesystem writes. */
161
+ export async function* extractZipBuffered(source, options = {}) {
162
+ const bound = bounds(options);
163
+ check(options.signal);
164
+ const segments = [];
165
+ let length = 0;
166
+ if (source instanceof Uint8Array) {
167
+ if (source.length > bound.archive)
168
+ fail('limit', 'Archive byte bound exceeded');
169
+ segments.push(source.slice());
170
+ length = source.length;
171
+ }
172
+ else {
173
+ for await (const chunk of source) {
174
+ check(options.signal);
175
+ if (!(chunk instanceof Uint8Array))
176
+ fail('invalid', 'Archive source must yield Uint8Array');
177
+ if (chunk.length > bound.chunk || length + chunk.length > bound.archive)
178
+ fail('limit', 'Archive or input chunk bound exceeded');
179
+ if (chunk.length)
180
+ segments.push(chunk.slice());
181
+ length += chunk.length;
182
+ }
183
+ }
184
+ check(options.signal);
185
+ if (length < 22)
186
+ fail('truncated', 'Missing ZIP end record');
187
+ const bytes = new Uint8Array(length);
188
+ let cursor = 0;
189
+ for (const chunk of segments) {
190
+ bytes.set(chunk, cursor);
191
+ cursor += chunk.length;
192
+ }
193
+ segments.length = 0;
194
+ const view = new DataView(bytes.buffer);
195
+ function need(at, size) {
196
+ if (!Number.isSafeInteger(at) || at < 0 || at + size > length)
197
+ fail('truncated', 'ZIP record exceeds archive');
198
+ }
199
+ function u16(at) {
200
+ need(at, 2);
201
+ return view.getUint16(at, true);
202
+ }
203
+ function u32(at) {
204
+ need(at, 4);
205
+ return view.getUint32(at, true);
206
+ }
207
+ let end = -1;
208
+ for (let i = length - 22; i >= Math.max(0, length - 65557); i--) {
209
+ if (u32(i) === 0x06054b50 && i + 22 + u16(i + 20) === length) {
210
+ end = i;
211
+ break;
212
+ }
213
+ }
214
+ if (end < 0)
215
+ fail('truncated', 'Missing or truncated ZIP end record');
216
+ if (u16(end + 4) !== 0 || u16(end + 6) !== 0 || u16(end + 8) !== u16(end + 10))
217
+ fail('unsupported', 'Multidisk ZIP unsupported');
218
+ const count = u16(end + 10), centralSize = u32(end + 12), centralOffset = u32(end + 16);
219
+ if (count === 0xffff ||
220
+ centralSize === 0xffffffff ||
221
+ centralOffset === 0xffffffff ||
222
+ (end >= 20 && u32(end - 20) === 0x07064b50))
223
+ fail('unsupported', 'ZIP64 unsupported');
224
+ if (count > bound.entries || centralSize > bound.metadata)
225
+ fail('limit', 'Central directory bound exceeded');
226
+ if (centralOffset + centralSize !== end)
227
+ fail('corrupt', 'Central directory does not end at EOCD');
228
+ need(centralOffset, centralSize);
229
+ function extra(at, size) {
230
+ const stop = at + size;
231
+ while (at < stop) {
232
+ if (at + 4 > stop)
233
+ fail('corrupt', 'Malformed extra field');
234
+ const tag = u16(at), size = u16(at + 2);
235
+ if (tag === 1)
236
+ fail('unsupported', 'ZIP64 extra field unsupported');
237
+ at += 4 + size;
238
+ if (at > stop)
239
+ fail('corrupt', 'Truncated extra field');
240
+ }
241
+ }
242
+ function flags(value, method, version) {
243
+ if (method !== 0)
244
+ fail('unsupported', 'Only stored-method ZIP supported');
245
+ if (value & 1)
246
+ fail('unsupported', 'Encrypted ZIP unsupported');
247
+ if ((value & ~0x0808) !== 0 || version > 20)
248
+ fail('unsupported', 'Unsupported ZIP flags or version');
249
+ }
250
+ const records = [];
251
+ const names = new Set();
252
+ cursor = centralOffset;
253
+ for (let i = 0; i < count; i++) {
254
+ check(options.signal);
255
+ need(cursor, 46);
256
+ if (u32(cursor) !== 0x02014b50)
257
+ fail('corrupt', 'Invalid central directory signature');
258
+ const flag = u16(cursor + 8), method = u16(cursor + 10);
259
+ flags(flag, method, u16(cursor + 6));
260
+ const crc = u32(cursor + 16), compressed = u32(cursor + 20), size = u32(cursor + 24);
261
+ const nameLength = u16(cursor + 28), extraLength = u16(cursor + 30), commentLength = u16(cursor + 32);
262
+ const offset = u32(cursor + 42);
263
+ if (compressed === 0xffffffff || size === 0xffffffff || offset === 0xffffffff)
264
+ fail('unsupported', 'ZIP64 entry unsupported');
265
+ if (u16(cursor + 34) !== 0)
266
+ fail('unsupported', 'Multidisk entry unsupported');
267
+ if (compressed !== size)
268
+ fail('corrupt', 'Stored sizes must match');
269
+ if (size > bound.entry || nameLength > bound.name)
270
+ fail('limit', 'Entry bound exceeded');
271
+ const next = cursor + 46 + nameLength + extraLength + commentLength;
272
+ if (next > end)
273
+ fail('corrupt', 'Central record exceeds directory');
274
+ const encoded = bytes.subarray(cursor + 46, cursor + 46 + nameLength);
275
+ if (!(flag & 0x800) && encoded.some((byte) => byte >= 128))
276
+ fail('unsupported', 'Non-UTF8 non-ASCII filenames unsupported');
277
+ let name;
278
+ try {
279
+ name = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
280
+ }
281
+ catch {
282
+ return fail('corrupt', 'Invalid UTF-8 filename');
283
+ }
284
+ nameBytes(name, bound);
285
+ if (names.has(name))
286
+ fail('corrupt', 'Duplicate archive path');
287
+ names.add(name);
288
+ extra(cursor + 46 + nameLength, extraLength);
289
+ records.push({ name, encoded, flags: flag, crc, size, offset, dataStart: 0, end: 0 });
290
+ cursor = next;
291
+ }
292
+ if (cursor !== end)
293
+ fail('corrupt', 'Central directory count or size mismatch');
294
+ const ordered = [...records].sort((a, b) => a.offset - b.offset);
295
+ let localEnd = 0;
296
+ for (const record of ordered) {
297
+ check(options.signal);
298
+ const at = record.offset;
299
+ if (at !== localEnd)
300
+ fail('corrupt', 'Overlapping entries, gaps, or preamble unsupported');
301
+ need(at, 30);
302
+ if (u32(at) !== 0x04034b50)
303
+ fail('corrupt', 'Invalid local header signature');
304
+ flags(u16(at + 6), u16(at + 8), u16(at + 4));
305
+ if (u16(at + 6) !== record.flags)
306
+ fail('corrupt', 'Local flags mismatch');
307
+ const nl = u16(at + 26), el = u16(at + 28);
308
+ need(at + 30, nl + el);
309
+ if (nl !== record.encoded.length ||
310
+ record.encoded.some((byte, index) => bytes[at + 30 + index] !== byte))
311
+ fail('corrupt', 'Local filename mismatch');
312
+ extra(at + 30 + nl, el);
313
+ const start = at + 30 + nl + el, stop = start + record.size;
314
+ if (stop > centralOffset)
315
+ fail('truncated', 'Entry data exceeds local region');
316
+ if (record.name.endsWith('/') && record.size)
317
+ fail('corrupt', 'Directory contains data');
318
+ if (!(record.flags & 8)) {
319
+ if (u32(at + 14) !== record.crc ||
320
+ u32(at + 18) !== record.size ||
321
+ u32(at + 22) !== record.size)
322
+ fail('corrupt', 'Local CRC or sizes mismatch');
323
+ localEnd = stop;
324
+ }
325
+ else {
326
+ if ((u32(at + 14) !== 0 && u32(at + 14) !== record.crc) ||
327
+ (u32(at + 18) !== 0 && u32(at + 18) !== record.size) ||
328
+ (u32(at + 22) !== 0 && u32(at + 22) !== record.size))
329
+ fail('corrupt', 'Descriptor local values mismatch');
330
+ let descriptorSize = 0;
331
+ if (stop + 16 <= centralOffset &&
332
+ u32(stop) === 0x08074b50 &&
333
+ u32(stop + 4) === record.crc &&
334
+ u32(stop + 8) === record.size &&
335
+ u32(stop + 12) === record.size)
336
+ descriptorSize = 16;
337
+ else if (stop + 12 <= centralOffset &&
338
+ u32(stop) === record.crc &&
339
+ u32(stop + 4) === record.size &&
340
+ u32(stop + 8) === record.size)
341
+ descriptorSize = 12;
342
+ else
343
+ fail('corrupt', 'Missing or invalid data descriptor');
344
+ localEnd = stop + descriptorSize;
345
+ }
346
+ record.dataStart = start;
347
+ record.end = stop;
348
+ }
349
+ if (localEnd !== centralOffset)
350
+ fail('corrupt', 'Local region and central directory mismatch');
351
+ for (const record of records) {
352
+ check(options.signal);
353
+ const data = bytes.subarray(record.dataStart, record.end);
354
+ // Check incrementally to bound cancellation latency for large entries.
355
+ let crc = 0xffffffff;
356
+ for (let i = 0; i < data.length; i += bound.chunk) {
357
+ check(options.signal);
358
+ crc = updateCrc(crc, data.subarray(i, i + bound.chunk));
359
+ }
360
+ if ((crc ^ 0xffffffff) >>> 0 !== record.crc)
361
+ fail('crc', 'Entry CRC32 mismatch');
362
+ yield { name: record.name, data: data.slice(), crc32: record.crc };
363
+ }
364
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@lp-labs/archive-stream",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Standards-compatible streaming stored ZIP writing and bounded buffered ZIP extraction.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/cjs/index.js",
8
+ "module": "./dist/esm/index.js",
9
+ "types": "./dist/esm/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": {
13
+ "types": "./dist/esm/index.d.ts",
14
+ "default": "./dist/esm/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/cjs/index.d.ts",
18
+ "default": "./dist/cjs/index.js"
19
+ }
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "scripts": {
33
+ "build": "node scripts/build.mjs",
34
+ "typecheck": "npm run build && node scripts/check-types.mjs",
35
+ "test": "npm run build && node --test tests/*.test.mjs",
36
+ "format": "prettier --write src scripts tests *.json README.md",
37
+ "format:check": "prettier --check src scripts tests *.json README.md",
38
+ "prepack": "npm run build"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.9.3",
42
+ "prettier": "^3.6.2"
43
+ },
44
+ "prettier": {
45
+ "singleQuote": true,
46
+ "semi": true,
47
+ "trailingComma": "all",
48
+ "printWidth": 100,
49
+ "endOfLine": "lf"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public",
53
+ "registry": "https://registry.npmjs.org/"
54
+ },
55
+ "author": {
56
+ "name": "Libin Prasanth",
57
+ "url": "https://www.linkedin.com/in/libinprasanth/"
58
+ },
59
+ "funding": {
60
+ "type": "paypal",
61
+ "url": "https://www.paypal.me/LibinPrasanth"
62
+ }
63
+ }