Skip to content

Commit 0ec8fec

Browse files
committed
feat(zip): Support deflate compression (bring your own)
1 parent 801f4c5 commit 0ec8fec

File tree

16 files changed

+577
-238
lines changed

16 files changed

+577
-238
lines changed

packages/compartment-mapper/test/integrity.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ test('extracting an archive with a missing file', async t => {
6464
}),
6565
{
6666
message:
67-
'Failed to load module "./main.js" in package "app-v1.0.0" (1 underlying failures: Cannot find file app-v1.0.0/main.js in Zip file missing.zip',
67+
'Failed to load module "./main.js" in package "app-v1.0.0" (1 underlying failures: Cannot find file app-v1.0.0/main.js in ZIP file missing.zip',
6868
},
6969
);
7070

@@ -88,6 +88,8 @@ test('extracting an archive with an inconsistent hash', async t => {
8888
const content = new Uint8Array(node.content.byteLength + 1);
8989
content.set(node.content, 0);
9090
node.content = content;
91+
node.uncompressedLength += 1;
92+
node.compressedLength += 1;
9193

9294
const invalidBytes = writer.snapshot();
9395

@@ -136,6 +138,8 @@ test('extracting an archive with an inconsistent compartment map hash', async t
136138
const content = new Uint8Array(node.content.byteLength + 1);
137139
content.fill(' '.charCodeAt(0));
138140
content.set(node.content, 0);
141+
node.uncompressedLength += 1;
142+
node.compressedLength += 1;
139143
node.content = content;
140144

141145
const invalidBytes = writer.snapshot();
@@ -176,6 +180,8 @@ test('extracting an archive with an inconsistent compartment map hash with expec
176180
const content = new Uint8Array(node.content.byteLength + 1);
177181
content.fill(' '.charCodeAt(0));
178182
content.set(node.content, 0);
183+
node.uncompressedLength += 1;
184+
node.compressedLength += 1;
179185
node.content = content;
180186

181187
const invalidBytes = writer.snapshot();

packages/compartment-mapper/test/retained.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ test('archives only contain compartments retained by modules', async t => {
2121
});
2222

2323
const reader = new ZipReader(bytes);
24-
const compartmentMapBytes = reader.files.get('compartment-map.json').content;
24+
const compartmentMapBytes = reader.files.get('compartment-map.json')?.content;
25+
t.assert(compartmentMapBytes);
2526
const compartmentMapText = new TextDecoder().decode(compartmentMapBytes);
2627
const compartmentMap = JSON.parse(compartmentMapText);
2728
t.deepEqual(Object.keys(compartmentMap.compartments), [

packages/compartment-mapper/test/stability.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ test('order of duplicate name/version packages', async t => {
1010
const bytes = await makeArchive(readPowers, fixture);
1111

1212
const reader = new ZipReader(bytes);
13-
const compartmentMapBytes = reader.files.get('compartment-map.json').content;
13+
const compartmentMapBytes = reader.files.get('compartment-map.json')?.content;
14+
t.assert(compartmentMapBytes);
1415
const compartmentMapText = new TextDecoder().decode(compartmentMapBytes);
1516
const compartmentMap = JSON.parse(compartmentMapText);
1617

packages/zip/NEWS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
User-visible changes in Zip:
1+
User-visible changes in ZIP:
2+
3+
# Next release
4+
5+
- Adds support for DEFLATE compression and decompression.
26

37
# 0.2.0 (2021-06-01)
48

packages/zip/README.md

Lines changed: 145 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,137 @@
1-
# Endo Zip
1+
# Endo ZIP
2+
3+
This is a lightweight JavaScript implementation of ZIP.
4+
The implementation operates on whole ZIP archives in memory and operates
5+
exclusively on `Uint8Array` file contents.
6+
7+
The library does entrain a specific DEFLATE compressor or decompressor, but it
8+
will use one if you provide it, and will otherwise just archive or extract
9+
uncompressed files.
10+
11+
## Usage
12+
13+
### Writing ZIP archives
14+
15+
Create a ZIP archive by instantiating `ZipWriter`, adding files with `write()`,
16+
and generating the final archive with `snapshot()`:
17+
18+
```javascript
19+
import { ZipWriter } from '@endo/zip';
20+
21+
const textEncoder = new TextEncoder();
22+
const writer = new ZipWriter();
23+
24+
// Add a file to the archive
25+
writer.set('hello.txt', textEncoder.encode('Hello, World!\n'), {
26+
mode: 0o644,
27+
date: new Date(),
28+
});
29+
30+
// Generate the ZIP archive as a Uint8Array
31+
const zipBytes = writer.snapshot();
32+
```
33+
34+
#### Options for `write()`
35+
36+
- `mode` (number, default: `0o644`): Unix file permissions
37+
- `date` (Date, optional): File modification date
38+
- `comment` (string, default: `''`): File comment
39+
40+
#### Compression support
41+
42+
By default, files are stored uncompressed.
43+
To enable DEFLATE compression, provide compression functions when creating the
44+
writer:
45+
46+
```javascript
47+
// Using the Compression Streams API (available in modern browsers and Node.js 18+)
48+
const deflate = async (bytes) => {
49+
const blob = new Blob([bytes]);
50+
const stream = blob.stream().pipeThrough(new CompressionStream('deflate-raw'));
51+
const compressed = await new Response(stream).arrayBuffer();
52+
return new Uint8Array(compressed);
53+
};
54+
55+
const writer = new ZipWriter({ deflate });
56+
await writer.set('data.txt', textEncoder.encode('Large data...'), {
57+
date: new Date(),
58+
});
59+
```
60+
61+
For synchronous compression, if available:
62+
63+
```javascript
64+
const writer = new ZipWriter({ deflateNow });
65+
writer.write('data.txt', textEncoder.encode('Data...'));
66+
```
67+
68+
### Reading ZIP archives
69+
70+
Read files from a ZIP archive using `ZipReader`:
71+
72+
```javascript
73+
import { ZipReader } from '@endo/zip';
74+
75+
const textDecoder = new TextDecoder();
76+
77+
// Create a reader from ZIP bytes
78+
const reader = new ZipReader(zipBytes);
79+
80+
// Read a file (synchronous for uncompressed files)
81+
const fileBytes = reader.read('hello.txt');
82+
const text = textDecoder.decode(fileBytes);
83+
84+
// Get file metadata
85+
const stat = reader.stat('hello.txt');
86+
console.log(stat.mode, stat.date, stat.comment);
87+
```
88+
89+
#### Reading compressed archives
90+
91+
To read ZIP files with DEFLATE compression, provide an inflate function:
92+
93+
```javascript
94+
// Using the Compression Streams API
95+
const inflate = async (bytes) => {
96+
const blob = new Blob([bytes]);
97+
const stream = blob.stream().pipeThrough(new DecompressionStream('deflate-raw'));
98+
const decompressed = await new Response(stream).arrayBuffer();
99+
return new Uint8Array(decompressed);
100+
};
101+
102+
const reader = new ZipReader(zipBytes, { inflate });
103+
104+
// Decompress asynchronously
105+
const fileBytes = await reader.get('compressed-file.txt');
106+
```
107+
108+
For synchronous decompression:
109+
110+
```javascript
111+
const reader = new ZipReader(zipBytes, { inflateNow: syncDecompressFunction });
112+
const fileBytes = reader.getNow('compressed-file.txt');
113+
```
114+
115+
### Helper functions
116+
117+
The package also exports constructor-free adapters.
118+
These make the archive more like a file system by adding gratuitious
119+
asynchrony.
120+
121+
```javascript
122+
import { writeZip, readZip } from '@endo/zip';
123+
124+
// Writing
125+
const { write, snapshot } = writeZip({ deflate });
126+
await write('file.txt', textEncoder.encode('content'));
127+
const zipBytes = await snapshot();
128+
129+
// Reading
130+
const { read } = await readZip(zipBytes, 'archive.zip', { inflate });
131+
const fileBytes = await read('file.txt');
132+
```
133+
134+
## Implementation Notes
2135

3136
This is a modernization and specialization of [JSZip][] (MIT License) that has
4137
no dependencies on any built-in modules and is entirely implemented with
@@ -13,33 +146,33 @@ requiring a date to be expressly provided instead of reaching for the ambient
13146
original Date constructor, which will pointedly be absent in constructed
14147
compartments in locked-down environments.
15148

16-
Zip format allows for an arbitrary-length comment and an arbitrary number of
149+
ZIP format allows for an arbitrary-length comment and an arbitrary number of
17150
Zip64 headers in the "end of central directory block".
18-
Zip implementations must therefore scan backward from the end for the magic
151+
ZIP implementations must therefore scan backward from the end for the magic
19152
numbers that introduce the "EOCDB".
20-
However, a specially crafted Zip file may contain those magic numbers
153+
However, a specially crafted ZIP file may contain those magic numbers
21154
before the end.
22155

23156
So, for security, this specialized library does not support Zip64 nor
24157
the variable width archive comment.
25158
With some difficulty, Zip64 might be recovered by scanning backward from the
26159
end of the file until we find a coherent EOCDB with no trailing bytes.
27160
Even careful support for the variable width comment at the end of the archive
28-
would always allow for the possibility of a comment that is itself a valid Zip
29-
file with a long prefix, since Zip files allow an arbitrary length prefix.
161+
would always allow for the possibility of a comment that is itself a valid ZIP
162+
file with a long prefix, since ZIP files allow an arbitrary length prefix.
30163

31-
For expedience, the specialization dropped support for INFLATE compression.
32-
The dependency would need to be converted to ECMAScript modules, which is not
33-
much effort. Pursuing that intent, one should factor out the shared CRC32
34-
module.
164+
DEFLATE compression support requires providing your own compression/decompression
165+
functions. Modern environments can use the [Compression Streams API][] with
166+
`'deflate-raw'` format. The dependency would need to be converted to ECMAScript
167+
modules, which is not much effort.
35168

36169
JSZip supports an asynchronous mode, that despite the name, is not concurrent.
37170
The mode is intended to keep the main thread lively while emitting progress
38171
reports. For expedience, this mode is omitted, but could be restored using the
39172
same underlying utilities, and I expect async/await and async iterators would
40173
make the feature easier to maintain.
41174

42-
Provided an async seekable reader, a lazy Zip reader could be built on the same
175+
Provided an async seekable reader, a lazy ZIP reader could be built on the same
43176
foundations, deferring decompression and validation until the file is opened.
44177

45178
For expedience, support for streaming compression and the necessary data
@@ -55,3 +188,4 @@ For expedience, there is no API for enumerating the contents of the archive.
55188
This would be straightforward to implement.
56189

57190
[JSZip]: https://github.com/Stuk/jszip
191+
[Compression Streams API]: https://developer.mozilla.org/en-US/docs/Web/API/Compression_Streams_API

packages/zip/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@endo/zip",
33
"version": "1.0.11",
4-
"description": "A minimal, synchronous Zip reader and writer",
4+
"description": "A minimal ZIP archive reader and writer",
55
"keywords": [
66
"zip",
77
"ses",

packages/zip/reader.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { ZipReader } from './src/reader.js';
1+
export { ZipReader, readZip } from './src/reader.js';

packages/zip/src/compression.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22

33
// STORE is the magic number for "not compressed".
44
export const STORE = 0;
5+
export const DEFLATE = 8;
6+
// export const BZIP2 = 12;

0 commit comments

Comments
 (0)