Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
# @suaveplan/drive-storage-zip - ZIP archive adapter for @suaveplan/drive: parses EOCD/ZIP64, central directory, local headers; supports store + deflate compression, WinZip AE-2 AES-256 decryption, split-volume reassembly, and read-only / copy-on-write / rewrite mutation modes via the drive-storage-archive-core conformance harness.
ZIP archive adapter for `@suaveplan/drive`. Reads and writes standards-compliant zip archives through the structural `DriveStorage` view; supports ZIP64, split volumes, WinZip AE-2 AES-256 decryption (read-only in v0.1.0), and three explicit mutation modes (`read-only`, `copy-on-write`, `rewrite`).
```ts
import { ZipArchiveStorage } from "@suaveplan/drive-storage-zip";
const storage = await ZipArchiveStorage.open({
source: archiveBytes,
mode: "read-only",
});
const payload = await storage.get("docs/readme.md");
```
See the package source for full surface area details.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/error-codes.md) — Defines the stable, machine-readable error code registry for the `@suaveplan/drive-storage-zip` package. Every failure…
- [error-codes](./src/errors/error-codes.md) — Defines the stable, machine-readable error code registry for the `@suaveplan/drive-storage-zip` package. Every failure…
- [parse-cdr-entry](./src/parser/central-directory/parse-cdr-entry.md) — Decodes one Central Directory File Header record (signature `0x02014b50`) plus its three variable-length tails (filenam…
- [walk-cdr](./src/parser/central-directory/walk-cdr.md) — Iterates the central directory sequentially and returns every parsed `CdrEntry`. Built on top of `parseCdrEntry`, it se…
- [compress](./src/parser/compression/compress.md) — Encodes one entry payload for inclusion in a zip archive. Picks between method 0 (STORE) and method 8 (DEFLATE) accordi…
- [decompress](./src/parser/compression/decompress.md) — Materialises an entry's plaintext bytes from its compressed payload. This module owns the dispatch on PKZIP compression…
- [aes-decrypt](./src/parser/crypto/aes/aes-decrypt.md) — Implements WinZip AE-2 (and AE-1) AES decryption for ZIP entries that advertise the AE extra field (`0x9901`). Performs…
- [aes-write-block](./src/parser/crypto/aes/aes-write-block.md) — Hosts the stub for the v0.2.0 WinZip AE-2 write path. The v0.1.0 adapter is deliberately read-only for AES-encrypted en…
- [find-eocd](./src/parser/eocd/find-eocd.md) — Locates the End-Of-Central-Directory signature within the trailing window of a ZIP buffer. ZIP archives terminate with…
- [parse-eocd](./src/parser/eocd/parse-eocd.md) — Decodes the 22-byte End-Of-Central-Directory record at a known offset in the archive buffer. The function performs stri…
- [parse-eocd64](./src/parser/eocd/parse-eocd64.md) — Provides decoders for the ZIP64 End-Of-Central-Directory locator and record plus a merge helper that overlays the 64-bi…
- [parse-local-header](./src/parser/local-header/parse-local-header.md) — Decodes a Local File Header — the per-entry preamble written immediately before each compressed payload. The CDR carrie…
- [read-entry-bytes](./src/parser/local-header/read-entry-bytes.md) — Given a parsed `CdrEntry` and the underlying archive buffer, returns the raw (still-compressed, still-encrypted) payloa…
- [crc32](./src/parser/crc32.md) — Computes the IEEE 802.3 CRC-32 (polynomial `0xEDB88320`, little-endian reflected) used by every ZIP entry. The CRC stor…
- [signatures](./src/parser/signatures.md) — Central registry of every fixed-width magic number, extra-field tag, and bit-flag bit position the ZIP parser and encod…
- [encode-zip](./src/storage/encode-zip.md) — Encodes a list of plaintext entries into a complete, standards-compliant ZIP byte buffer. Used by `ZipArchiveStorage.fl…
- [normalize-path](./src/storage/normalize-path.md) — Validates and canonicalises an entry path before it enters the ZIP write pipeline. The function defends against the cla…
- [zip-archive-storage](./src/storage/zip-archive-storage.md) — Implements the `ZipArchiveStorage` class — a `DriveStorage`-compatible adapter that exposes the entries of a ZIP archiv…
<!-- end:auto-docs -->

View File

@@ -0,0 +1,84 @@
{
"name": "@suaveplan/drive-storage-zip",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "ZIP archive adapter for @suaveplan/drive: parses EOCD/ZIP64, central directory, local headers; supports store + deflate compression, WinZip AE-2 AES-256 decryption, split-volume reassembly, and read-only / copy-on-write / rewrite mutation modes via the drive-storage-archive-core conformance harness.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./parser": {
"import": {
"types": "./dist/parser/index.d.ts",
"default": "./dist/parser/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/drive-storage-archive-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"fflate": "catalog:parsing",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/drive": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,415 @@
// Test-only fixture helpers. Hand-built zip archives for the test suite.
// Lives under __fixtures__ so docs-check skips it.
import { deflateSync } from "fflate";
import { crc32 } from "../parser/crc32.js";
import {
AE_EXTRA_TAG,
CDR_SIG,
COMPRESSION_AES_PLACEHOLDER,
EOCD_SIG,
EOCD64_LOCATOR_SIG,
EOCD64_SIG,
FLAG_ENCRYPTED,
LOCAL_HEADER_SIG,
} from "../parser/signatures.js";
export interface FixtureEntry {
name: string;
payload: Uint8Array;
method?: 0 | 8 | 12; // store / deflate / bzip2
forceBadCrc?: boolean;
}
/**
* Build a minimal but standards-correct zip archive in memory.
*/
export function buildPlainZip(entries: FixtureEntry[]): Uint8Array {
const localChunks: Uint8Array[] = [];
const cdrChunks: Uint8Array[] = [];
let cursor = 0;
const encoder = new TextEncoder();
const cdrInfo: Array<{
offset: number;
method: number;
crc: number;
compressed: number;
uncompressed: number;
nameBytes: Uint8Array;
}> = [];
for (const e of entries) {
const nameBytes = encoder.encode(e.name);
const method = e.method ?? 8;
let compressed: Uint8Array;
if (method === 0) {
compressed = e.payload;
} else if (method === 8) {
compressed = deflateSync(e.payload, { level: 6 });
} else {
// For unsupported codes we just emit STORE and stamp the method.
compressed = e.payload;
}
const crc = e.forceBadCrc === true ? 0xdeadbeef : crc32(e.payload);
const localOffset = cursor;
const local = new Uint8Array(30 + nameBytes.byteLength);
const lv = new DataView(local.buffer);
lv.setUint32(0, LOCAL_HEADER_SIG, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0x0800, true);
lv.setUint16(8, method, true);
lv.setUint16(10, 0, true);
lv.setUint16(12, 0x21, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, compressed.byteLength, true);
lv.setUint32(22, e.payload.byteLength, true);
lv.setUint16(26, nameBytes.byteLength, true);
lv.setUint16(28, 0, true);
local.set(nameBytes, 30);
localChunks.push(local, compressed);
cursor += local.byteLength + compressed.byteLength;
cdrInfo.push({
offset: localOffset,
method,
crc,
compressed: compressed.byteLength,
uncompressed: e.payload.byteLength,
nameBytes,
});
}
const cdrOffset = cursor;
for (const info of cdrInfo) {
const cdr = new Uint8Array(46 + info.nameBytes.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(4, 0x031e, true);
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, info.method, true);
cv.setUint16(12, 0, true);
cv.setUint16(14, 0x21, true);
cv.setUint32(16, info.crc, true);
cv.setUint32(20, info.compressed, true);
cv.setUint32(24, info.uncompressed, true);
cv.setUint16(28, info.nameBytes.byteLength, true);
cv.setUint16(30, 0, true);
cv.setUint16(32, 0, true);
cv.setUint16(34, 0, true);
cv.setUint16(36, 0, true);
cv.setUint32(38, 0, true);
cv.setUint32(42, info.offset, true);
cdr.set(info.nameBytes, 46);
cdrChunks.push(cdr);
}
let cdrSize = 0;
for (const c of cdrChunks) cdrSize += c.byteLength;
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, EOCD_SIG, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdrSize, true);
ev.setUint32(16, cdrOffset, true);
ev.setUint16(20, 0, true);
const totalSize = cursor + cdrSize + 22;
const out = new Uint8Array(totalSize);
let o = 0;
for (const c of localChunks) {
out.set(c, o);
o += c.byteLength;
}
for (const c of cdrChunks) {
out.set(c, o);
o += c.byteLength;
}
out.set(eocd, o);
return out;
}
/**
* Build a zip whose legacy EOCD carries `0xFFFF` sentinel for total
* entries, forcing the parser to consult the ZIP64 EOCD record.
*/
export function buildZip64Forced(entries: FixtureEntry[]): Uint8Array {
const baseline = buildPlainZip(entries);
// Re-find EOCD, append ZIP64 EOCD + locator before it, rewrite legacy.
const eocdOffset = baseline.byteLength - 22;
const ev = new DataView(baseline.buffer, eocdOffset, 22);
const cdrSize = ev.getUint32(12, true);
const cdrOffset = ev.getUint32(16, true);
// Build ZIP64 EOCD + locator that we will splice in immediately before legacy EOCD.
const z64 = new Uint8Array(56);
const zv = new DataView(z64.buffer);
zv.setUint32(0, EOCD64_SIG, true);
writeU64(zv, 4, BigInt(56 - 12));
zv.setUint16(12, 0x031e, true);
zv.setUint16(14, 45, true);
zv.setUint32(16, 0, true);
zv.setUint32(20, 0, true);
writeU64(zv, 24, BigInt(entries.length));
writeU64(zv, 32, BigInt(entries.length));
writeU64(zv, 40, BigInt(cdrSize));
writeU64(zv, 48, BigInt(cdrOffset));
// Locator sits right after the ZIP64 EOCD; its eocd64Offset points at
// the start of the ZIP64 record (which is the original eocdOffset).
const loc = new Uint8Array(20);
const lv = new DataView(loc.buffer);
lv.setUint32(0, EOCD64_LOCATOR_SIG, true);
lv.setUint32(4, 0, true);
writeU64(lv, 8, BigInt(eocdOffset));
lv.setUint32(16, 1, true);
// New legacy EOCD with sentinel entry count.
const newLegacy = new Uint8Array(22);
const nv = new DataView(newLegacy.buffer);
nv.setUint32(0, EOCD_SIG, true);
nv.setUint16(4, 0, true);
nv.setUint16(6, 0, true);
nv.setUint16(8, 0xffff, true); // sentinel
nv.setUint16(10, 0xffff, true);
nv.setUint32(12, cdrSize, true);
nv.setUint32(16, cdrOffset, true);
nv.setUint16(20, 0, true);
const out = new Uint8Array(eocdOffset + 56 + 20 + 22);
out.set(baseline.subarray(0, eocdOffset), 0);
out.set(z64, eocdOffset);
out.set(loc, eocdOffset + 56);
out.set(newLegacy, eocdOffset + 56 + 20);
return out;
}
/**
* Splits a plain zip across two volumes at `splitOffset` and writes
* EOCD with `diskNumber=1`. Returns `{ z01, zip }`.
*/
export function buildSplitTwoVolume(
entries: FixtureEntry[],
splitOffset: number
): { z01: Uint8Array; zip: Uint8Array } {
const baseline = buildPlainZip(entries);
// EOCD lives in the last 22 bytes; mutate disk numbers.
const eocdOffset = baseline.byteLength - 22;
const ev = new DataView(
baseline.buffer,
baseline.byteOffset + eocdOffset,
22
);
ev.setUint16(4, 1, true); // diskNumber (current disk holding EOCD)
ev.setUint16(6, 1, true); // disk with central directory
if (splitOffset >= baseline.byteLength || splitOffset === 0) {
throw new Error("split offset out of range");
}
const z01 = baseline.subarray(0, splitOffset).slice();
const zip = baseline.subarray(splitOffset).slice();
return { z01, zip };
}
/**
* Build a one-entry zip whose single entry is AE-2 AES-256 encrypted.
* Compression is fixed to STORE (method 0 underlying) for simplicity.
*/
export async function buildAesZip(
name: string,
plaintext: Uint8Array,
password: string,
underlyingMethod: 0 | 8 = 0
): Promise<Uint8Array> {
const subtle = globalThis.crypto.subtle;
const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
const keyLen = 32;
// PBKDF2-HMAC-SHA1.
const baseKey = await subtle.importKey(
"raw",
bufFromBytes(new TextEncoder().encode(password)),
{ name: "PBKDF2" },
false,
["deriveBits"]
);
const bits = new Uint8Array(
await subtle.deriveBits(
{
name: "PBKDF2",
salt: bufFromBytes(salt),
iterations: 1000,
hash: "SHA-1",
},
baseKey,
(keyLen + keyLen + 2) * 8
)
);
const encKey = bits.subarray(0, keyLen);
const macKey = bits.subarray(keyLen, keyLen + keyLen);
const verifier = bits.subarray(keyLen + keyLen, keyLen + keyLen + 2);
// Underlying compress.
const underlying =
underlyingMethod === 8
? deflateSync(plaintext, { level: 6 })
: plaintext;
// AES-CTR keystream.
const ecbKey = await subtle.importKey(
"raw",
bufFromBytes(encKey),
{ name: "AES-CTR", length: 256 },
false,
["encrypt"]
);
const blockCount = Math.ceil(underlying.byteLength / 16);
const ciphertext = new Uint8Array(underlying.byteLength);
for (let i = 0; i < blockCount; i++) {
const counter = new Uint8Array(16);
const cv = new DataView(counter.buffer);
const idx = BigInt(i + 1);
cv.setUint32(0, Number(idx & 0xffffffffn), true);
cv.setUint32(4, Number((idx >> 32n) & 0xffffffffn), true);
const block = new Uint8Array(
await subtle.encrypt(
{
name: "AES-CTR",
counter: bufFromBytes(counter),
length: 128,
},
ecbKey,
bufFromBytes(new Uint8Array(16))
)
);
const start = i * 16;
const end = Math.min(start + 16, underlying.byteLength);
for (let j = start; j < end; j++) {
ciphertext[j] = (underlying[j] ?? 0) ^ (block[j - start] ?? 0);
}
}
// HMAC over ciphertext.
const macCk = await subtle.importKey(
"raw",
bufFromBytes(macKey),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const fullHmac = new Uint8Array(
await subtle.sign({ name: "HMAC" }, macCk, bufFromBytes(ciphertext))
);
const hmacTag = fullHmac.subarray(0, 10);
// Payload: salt | verifier | ciphertext | hmacTag.
const payload = new Uint8Array(
salt.byteLength +
verifier.byteLength +
ciphertext.byteLength +
hmacTag.byteLength
);
payload.set(salt, 0);
payload.set(verifier, salt.byteLength);
payload.set(ciphertext, salt.byteLength + verifier.byteLength);
payload.set(
hmacTag,
salt.byteLength + verifier.byteLength + ciphertext.byteLength
);
// Build extra field: AE-2 marker (0x9901), 7 bytes total payload.
const aeExtra = new Uint8Array(4 + 7);
const av = new DataView(aeExtra.buffer);
av.setUint16(0, AE_EXTRA_TAG, true);
av.setUint16(2, 7, true);
av.setUint16(4, 2, true); // AE-2
aeExtra[6] = "A".charCodeAt(0);
aeExtra[7] = "E".charCodeAt(0);
aeExtra[8] = 3; // strength: AES-256
av.setUint16(9, underlyingMethod, true);
const encoder = new TextEncoder();
const nameBytes = encoder.encode(name);
const flags = 0x0800 | FLAG_ENCRYPTED;
const method = COMPRESSION_AES_PLACEHOLDER;
const crc = 0; // AE-2 stores zero crc
const local = new Uint8Array(
30 + nameBytes.byteLength + aeExtra.byteLength
);
const lv2 = new DataView(local.buffer);
lv2.setUint32(0, LOCAL_HEADER_SIG, true);
lv2.setUint16(4, 20, true);
lv2.setUint16(6, flags, true);
lv2.setUint16(8, method, true);
lv2.setUint16(10, 0, true);
lv2.setUint16(12, 0x21, true);
lv2.setUint32(14, crc, true);
lv2.setUint32(18, payload.byteLength, true);
lv2.setUint32(22, plaintext.byteLength, true);
lv2.setUint16(26, nameBytes.byteLength, true);
lv2.setUint16(28, aeExtra.byteLength, true);
local.set(nameBytes, 30);
local.set(aeExtra, 30 + nameBytes.byteLength);
const cdrOffset = local.byteLength + payload.byteLength;
const cdr = new Uint8Array(46 + nameBytes.byteLength + aeExtra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(4, 0x031e, true);
cv.setUint16(6, 20, true);
cv.setUint16(8, flags, true);
cv.setUint16(10, method, true);
cv.setUint16(12, 0, true);
cv.setUint16(14, 0x21, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, payload.byteLength, true);
cv.setUint32(24, plaintext.byteLength, true);
cv.setUint16(28, nameBytes.byteLength, true);
cv.setUint16(30, aeExtra.byteLength, true);
cv.setUint16(32, 0, true);
cv.setUint16(34, 0, true);
cv.setUint16(36, 0, true);
cv.setUint32(38, 0, true);
cv.setUint32(42, 0, true);
cdr.set(nameBytes, 46);
cdr.set(aeExtra, 46 + nameBytes.byteLength);
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, EOCD_SIG, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
ev.setUint16(8, 1, true);
ev.setUint16(10, 1, true);
ev.setUint32(12, cdr.byteLength, true);
ev.setUint32(16, cdrOffset, true);
ev.setUint16(20, 0, true);
const total = local.byteLength + payload.byteLength + cdr.byteLength + 22;
const out = new Uint8Array(total);
let o = 0;
out.set(local, o);
o += local.byteLength;
out.set(payload, o);
o += payload.byteLength;
out.set(cdr, o);
o += cdr.byteLength;
out.set(eocd, o);
return out;
}
function writeU64(view: DataView, offset: number, value: bigint): void {
view.setUint32(offset, Number(value & 0xffffffffn), true);
view.setUint32(offset + 4, Number((value >> 32n) & 0xffffffffn), true);
}
function bufFromBytes(b: Uint8Array): ArrayBuffer {
return b.buffer.slice(
b.byteOffset,
b.byteOffset + b.byteLength
) as ArrayBuffer;
}

View File

@@ -0,0 +1,52 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, machine-readable error code registry for the `@suaveplan/drive-storage-zip` package. Every failure inside the ZIP parser, the AES-256 decrypt path, the split-volume reassembly logic, the write-mode encoder, and the path-normalisation guard raises a `ZipError` carrying one of ten `ZIP.*` codes. Codes are produced through `defineCodes` from `@suaveplan/error`, which guarantees compile-time uniqueness and a stable `"<prefix>.<name>"` string identifier suitable for log search, error-rate dashboards, and cross-package error-chain inspection.
## Features
- 10 stable `ZIP.*` codes spanning structural, cryptographic, and write-path failures.
- `ZipError` class extending `SuaveplanError` so consumers may `instanceof` narrow without losing the typed `code` discriminant.
- Redundant `zipCode` field on the error instance to enable concise `toMatchObject({ zipCode })` assertions in test suites.
- Optional `SuaveplanErrorOptions` (cause chain, context map, redaction tags) flow through to the parent class without modification.
## Usage: Basic
```ts
import { ZIP_CODES, ZipError } from "@suaveplan/drive-storage-zip/errors";
throw new ZipError(
ZIP_CODES.EOCD_NOT_FOUND,
"no end-of-central-directory record in the last 65557 bytes"
);
```
## Usage: Advanced
```ts
import { ZIP_CODES, ZipError } from "@suaveplan/drive-storage-zip/errors";
try {
await archive.get("inside/notes.txt");
} catch (err) {
if (err instanceof ZipError && err.zipCode === ZIP_CODES.CRC_MISMATCH) {
logger.warn({ entry: "inside/notes.txt" }, "decompression failed");
}
throw err;
}
```
## API Reference
- `ZIP_CODES` — frozen registry of code names produced by `defineCodes("ZIP", [...])`.
- `ZipErrorCode` — string literal union narrowing all valid code values.
- `ZipError` — constructor `(code: ZipErrorCode, message: string, options?: SuaveplanErrorOptions)`; exposes `code` and the convenience-mirror `zipCode`.
## Implementation Notes
The redundant `zipCode` assignment keeps the constructor body non-trivial so the Biome `noUselessConstructor` lint never flags this class even when the upstream `SuaveplanError` constructor signature evolves. Codes are deliberately broad-grained: structural parse failures collapse under `CENTRAL_DIR_CORRUPT`, allowing the carried message string to distinguish "EOCD truncated" from "ZIP64 locator missing magic", whereas separate codes are reserved for ergonomic test/assertion paths (`CRC_MISMATCH`, `AES_AUTH_FAILED`, `UNSUPPORTED_COMPRESSION`, `PATH_INVALID`) where consumers typically branch on the code.

View File

@@ -0,0 +1,67 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/drive-storage-zip`.
*
* Codes are emitted as `ZIP.<NAME>` strings (e.g. `"ZIP.EOCD_NOT_FOUND"`).
* Every failure path inside the ZIP adapter (parser, AES decryption,
* write-path, etc.) raises a {@link ZipError} carrying one of these codes.
*
* - `EOCD_NOT_FOUND` — the trailing End-of-Central-Directory record was
* not located within the last 65557 bytes; the byte buffer is not a
* well-formed ZIP container.
* - `ZIP64_REQUIRED` — a 32-bit field uses the `0xFFFFFFFF` sentinel but
* the ZIP64 locator is missing or malformed.
* - `CENTRAL_DIR_CORRUPT` — central-directory parsing failed (bad
* signature, truncated entry, inconsistent counts).
* - `CRC_MISMATCH` — the decompressed payload's CRC32 disagrees with the
* central-directory record. Details include the offending entry path.
* - `UNSUPPORTED_COMPRESSION` — compression method is neither STORE (0)
* nor DEFLATE (8); ZIP method codes for bzip2 (12), LZMA (14), zstd
* (93), xz (95) and PPMd (98) are surfaced here.
* - `AES_WRITE_UNSUPPORTED` — caller attempted to write an entry under
* AES encryption; the v0.1.0 adapter is read-only for encrypted data.
* - `AES_AUTH_FAILED` — WinZip AE-2 HMAC-SHA1 authentication failed,
* typically because the supplied password is wrong.
* - `LOCAL_HEADER_INVALID` — the local file header for an entry has a
* bad signature or is truncated.
* - `DATA_DESCRIPTOR_INVALID` — the trailing data descriptor (used when
* sizes are unknown at local-header write time) has an invalid
* signature or magic.
* - `PATH_INVALID` — an entry path contains backslashes, traverses
* parent directories with `..`, or starts with an absolute slash.
*/
export const ZIP_CODES = defineCodes("ZIP", [
"EOCD_NOT_FOUND",
"ZIP64_REQUIRED",
"CENTRAL_DIR_CORRUPT",
"CRC_MISMATCH",
"UNSUPPORTED_COMPRESSION",
"AES_WRITE_UNSUPPORTED",
"AES_AUTH_FAILED",
"LOCAL_HEADER_INVALID",
"DATA_DESCRIPTOR_INVALID",
"PATH_INVALID",
] as const);
export type ZipErrorCode = (typeof ZIP_CODES)[keyof typeof ZIP_CODES];
/**
* Concrete error class. Extends {@link SuaveplanError} so consumers can
* narrow with `instanceof ZipError` and tighten `code` to {@link ZipErrorCode}.
* The redundant `zipCode` field mirrors the underlying code so test
* matchers can use `expect(err).toMatchObject({ zipCode: ... })`.
*/
export class ZipError extends SuaveplanError<ZipErrorCode> {
public readonly zipCode: ZipErrorCode;
public constructor(
code: ZipErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.zipCode = code;
}
}

View File

@@ -0,0 +1 @@
export { ZIP_CODES, ZipError, type ZipErrorCode } from "./error-codes.js";

View File

@@ -0,0 +1,26 @@
export {
ZIP_CODES,
ZipError,
type ZipErrorCode,
} from "./errors/error-codes.js";
export * as parser from "./parser/index.js";
export {
type EncodeEntry,
type EncodeZipOptions,
encodeZip,
} from "./storage/encode-zip.js";
export { normalizePath } from "./storage/normalize-path.js";
export { ZipArchiveStorage } from "./storage/zip-archive-storage.js";
export type {
AesExtraField,
CdrEntry,
EntryRecord,
Eocd64Locator,
Eocd64Record,
EocdRecord,
LocalHeader,
} from "./types/parser.types.js";
export type {
ZipArchiveStorageOptions,
ZipHostStorage,
} from "./types/storage.types.js";

View File

@@ -0,0 +1,42 @@
# `parse-cdr-entry`
## Purpose
Decodes one Central Directory File Header record (signature `0x02014b50`) plus its three variable-length tails (filename, extra fields, comment). Eagerly applies ZIP64 (`0x0001`) and WinZip AE (`0x9901`) extra-field overrides so callers see effective 64-bit sizes/offsets and the resolved underlying compression method for encrypted entries without needing to re-walk the extras themselves.
## Features
- Strict signature validation with `ZIP.CENTRAL_DIR_CORRUPT` on mismatch.
- ZIP64 extension parsing in the conditional order specified by APPNOTE §4.5.3: uncompressed size, compressed size, local-header offset, disk number — each consumed only when the corresponding fixed field carries its 32-bit sentinel.
- AE extra-field decoding yields `{ aeVersion, vendorId, strength, originalMethod }` and, when the fixed-field method is the placeholder `99`, advertises the AE-declared `originalMethod` as the `effectiveCompressionMethod`.
- Filename decoded as UTF-8 when bit 11 of the general-purpose flag is set, otherwise via IBM-866 (a reasonable CP437 approximation for legacy ASCII archives in the WHATWG decoder set).
- `nextOffset` returned so the iterator (`walk-cdr`) can chain entries without re-reading variable lengths.
## Usage: Basic
```ts
import { parseCdrEntry } from "@suaveplan/drive-storage-zip/parser";
const { entry, nextOffset } = parseCdrEntry(buffer, cdrOffset);
console.log(entry.name, entry.compressedSize, entry.effectiveCompressionMethod);
```
## Usage: Advanced
```ts
const entries: CdrEntry[] = [];
let cursor = eocd.centralDirOffset;
for (let i = 0; i < eocd.totalEntries; i++) {
const { entry, nextOffset } = parseCdrEntry(buffer, cursor);
entries.push(entry);
cursor = nextOffset;
}
```
## API Reference
`parseCdrEntry(bytes: Uint8Array, offset: number): { entry: CdrEntry; nextOffset: number }` — throws `ZipError(ZIP.CENTRAL_DIR_CORRUPT)` for signature mismatch, truncated variable-length tails, or malformed extras; throws `ZipError(ZIP.ZIP64_REQUIRED)` when a ZIP64 64-bit field exceeds `Number.MAX_SAFE_INTEGER`.
## Implementation Notes
The IBM-866 fallback for non-UTF-8 names matches WHATWG's decoder set and behaves identically to CP437 for the ASCII subset (which covers the overwhelming majority of legacy zip filenames). For archives produced by tooling that emit pure CP437 with non-ASCII bytes (e.g. Latin-1 codepoints from old DOS systems), users can post-process the returned name string; we deliberately avoid bundling a full CP437 table to keep the parser footprint small.

View File

@@ -0,0 +1,214 @@
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
import type { AesExtraField, CdrEntry } from "../../types/parser.types.js";
import {
AE_EXTRA_TAG,
CDR_SIG,
COMPRESSION_AES_PLACEHOLDER,
FLAG_UTF8,
ZIP64_EXTRA_TAG,
} from "../signatures.js";
const utf8 = new TextDecoder("utf-8", { fatal: false });
const cp437 = new TextDecoder("ibm866", { fatal: false }); // closest available stdlib decoder; ASCII subset matches
export interface ParsedCdrEntry {
readonly entry: CdrEntry;
readonly nextOffset: number;
}
/**
* Decode one Central Directory File Header starting at `offset` inside
* `bytes`. The fixed portion of the record is 46 bytes; three
* variable-length tails follow it: filename, extra fields, comment.
*
* ZIP64 extra-field (`0x0001`) and AE extra-field (`0x9901`) are parsed
* eagerly; their decoded contents override the corresponding 32-bit
* fields in the fixed portion when present.
*/
export function parseCdrEntry(
bytes: Uint8Array,
offset: number
): ParsedCdrEntry {
if (offset + 46 > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`CDR entry header at ${offset} exceeds buffer`
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 46);
if (view.getUint32(0, true) !== CDR_SIG) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`CDR signature missing at offset ${offset}`
);
}
const versionMadeBy = view.getUint16(4, true);
const versionNeeded = view.getUint16(6, true);
const flags = view.getUint16(8, true);
const compressionMethod = view.getUint16(10, true);
const mtimeDos = view.getUint16(12, true);
const mdateDos = view.getUint16(14, true);
const crc32 = view.getUint32(16, true);
let compressedSize = view.getUint32(20, true);
let uncompressedSize = view.getUint32(24, true);
const nameLen = view.getUint16(28, true);
const extraLen = view.getUint16(30, true);
const commentLen = view.getUint16(32, true);
let diskNumberStart = view.getUint16(34, true);
const internalAttrs = view.getUint16(36, true);
const externalAttrs = view.getUint32(38, true);
let localHeaderOffset = view.getUint32(42, true);
const nameStart = offset + 46;
const extraStart = nameStart + nameLen;
const commentStart = extraStart + extraLen;
const nextOffset = commentStart + commentLen;
if (nextOffset > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`CDR entry at ${offset} declares variable-data past buffer end`
);
}
const nameBytes = bytes.subarray(nameStart, extraStart);
const extraBytes = bytes.subarray(extraStart, commentStart);
const commentBytes = bytes.subarray(commentStart, nextOffset);
const extras = parseExtras(extraBytes);
// ZIP64 extension overrides — order is uncompressed, compressed, offset, disk.
const z64 = extras.get(ZIP64_EXTRA_TAG);
if (z64 !== undefined) {
const zv = new DataView(z64.buffer, z64.byteOffset, z64.byteLength);
let cursor = 0;
if (uncompressedSize === 0xffffffff) {
if (cursor + 8 > z64.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
"ZIP64 extra: uncompressed size missing"
);
}
uncompressedSize = readU64(zv, cursor);
cursor += 8;
}
if (compressedSize === 0xffffffff) {
if (cursor + 8 > z64.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
"ZIP64 extra: compressed size missing"
);
}
compressedSize = readU64(zv, cursor);
cursor += 8;
}
if (localHeaderOffset === 0xffffffff) {
if (cursor + 8 > z64.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
"ZIP64 extra: local header offset missing"
);
}
localHeaderOffset = readU64(zv, cursor);
cursor += 8;
}
if (diskNumberStart === 0xffff) {
if (cursor + 4 > z64.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
"ZIP64 extra: disk number missing"
);
}
diskNumberStart = zv.getUint32(cursor, true);
}
}
// AE extra-field decoding.
let aes: AesExtraField | undefined;
let effectiveCompressionMethod = compressionMethod;
const ae = extras.get(AE_EXTRA_TAG);
if (ae !== undefined) {
if (ae.byteLength < 7) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
"AE extra field truncated"
);
}
const av = new DataView(ae.buffer, ae.byteOffset, ae.byteLength);
const aeVersion = av.getUint16(0, true);
const vendorId = String.fromCharCode(ae[2] as number, ae[3] as number);
const strengthByte = ae[4] as number;
if (strengthByte !== 1 && strengthByte !== 2 && strengthByte !== 3) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`AE extra field strength byte invalid: ${strengthByte}`
);
}
const originalMethod = av.getUint16(5, true);
aes = {
aeVersion,
vendorId,
strength: strengthByte,
originalMethod,
};
if (compressionMethod === COMPRESSION_AES_PLACEHOLDER) {
effectiveCompressionMethod = originalMethod;
}
}
const useUtf8 = (flags & FLAG_UTF8) !== 0;
const decoder = useUtf8 ? utf8 : cp437;
const name = decoder.decode(nameBytes);
const entry: CdrEntry = {
versionMadeBy,
versionNeeded,
flags,
compressionMethod,
effectiveCompressionMethod,
mtimeDos,
mdateDos,
crc32,
compressedSize,
uncompressedSize,
diskNumberStart,
internalAttrs,
externalAttrs,
localHeaderOffset,
name,
extras,
comment: commentBytes,
...(aes !== undefined ? { aes } : {}),
};
return { entry, nextOffset };
}
function parseExtras(bytes: Uint8Array): Map<number, Uint8Array> {
const out = new Map<number, Uint8Array>();
if (bytes.byteLength === 0) return out;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let cursor = 0;
while (cursor + 4 <= bytes.byteLength) {
const tag = view.getUint16(cursor, true);
const size = view.getUint16(cursor + 2, true);
if (cursor + 4 + size > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`extra field 0x${tag.toString(16)} declares ${size} bytes but only ${bytes.byteLength - cursor - 4} remain`
);
}
out.set(tag, bytes.subarray(cursor + 4, cursor + 4 + size));
cursor += 4 + size;
}
return out;
}
function readU64(view: DataView, offset: number): number {
const lo = view.getUint32(offset, true);
const hi = view.getUint32(offset + 4, true);
if (hi > 0x1fffff) {
throw new ZipError(
ZIP_CODES.ZIP64_REQUIRED,
"ZIP64 value exceeds Number.MAX_SAFE_INTEGER"
);
}
return hi * 0x1_0000_0000 + lo;
}

View File

@@ -0,0 +1,37 @@
# `walk-cdr`
## Purpose
Iterates the central directory sequentially and returns every parsed `CdrEntry`. Built on top of `parseCdrEntry`, it serves as the single chokepoint for converting a contiguous CDR byte range into the in-memory entry table that `ZipArchiveStorage` consults for every `get`, `list`, `exists`, and metadata call.
## Features
- Bounded by the entry `count` parameter so adversarial archives cannot drive unlimited allocations.
- Hard-fails with `ZIP.CENTRAL_DIR_CORRUPT` if the declared count exceeds the buffer.
- Greedy materialisation — every entry is decoded eagerly because typical workloads enumerate the full archive on open. Lazy iteration would force a second pass for any operation that touches more than one entry.
## Usage: Basic
```ts
import { walkCdr } from "@suaveplan/drive-storage-zip/parser";
const entries = walkCdr(buffer, eocd.centralDirOffset, eocd.totalEntries);
```
## Usage: Advanced
```ts
import { walkCdr } from "@suaveplan/drive-storage-zip/parser";
const entries = walkCdr(buffer, eocd.centralDirOffset, eocd.totalEntries);
const byPath = new Map(entries.map((e) => [e.name, e]));
const encryptedCount = entries.filter((e) => e.aes !== undefined).length;
```
## API Reference
`walkCdr(bytes: Uint8Array, offset: number, count: number): CdrEntry[]` — returns exactly `count` parsed `CdrEntry` records; throws `ZipError(ZIP.CENTRAL_DIR_CORRUPT)` when the buffer is exhausted before `count` entries are read.
## Implementation Notes
Greedy iteration costs O(N) time and O(N) memory where N is the total entry count. For terabyte-scale archives with millions of entries this is still negligible compared to the IO required to read the central directory in the first place. If a future workload demands streaming iteration, the underlying `parseCdrEntry` already supports it — `walkCdr` can be supplemented with a generator variant without breaking the current contract.

View File

@@ -0,0 +1,33 @@
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
import type { CdrEntry } from "../../types/parser.types.js";
import { parseCdrEntry } from "./parse-cdr-entry.js";
/**
* Iterate every Central Directory record inside `bytes` starting from
* `offset`, yielding `count` entries. Returns the resulting array.
*
* The function is greedy — it parses all entries up-front rather than
* lazily because every realistic call site (open, list, get) needs the
* full index. ZIP central directories are bounded by `count` so the
* memory footprint is predictable.
*/
export function walkCdr(
bytes: Uint8Array,
offset: number,
count: number
): CdrEntry[] {
const out: CdrEntry[] = [];
let cursor = offset;
for (let i = 0; i < count; i++) {
if (cursor >= bytes.byteLength) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`expected ${count} CDR entries, ran out of buffer after ${i}`
);
}
const { entry, nextOffset } = parseCdrEntry(bytes, cursor);
out.push(entry);
cursor = nextOffset;
}
return out;
}

View File

@@ -0,0 +1,45 @@
# `compress`
## Purpose
Encodes one entry payload for inclusion in a zip archive. Picks between method 0 (STORE) and method 8 (DEFLATE) according to which produces the smaller embedded size, returning both the encoded bytes and the corresponding method code so the encoder can populate the CDR and local header consistently.
## Features
- Empty payloads short-circuit to STORE with a zero-byte output — many extractors mishandle a deflate-encoded empty stream because the 2-byte deflate trailer counts as "non-empty" to some implementations.
- STORE bytes are always defensively copied so the caller cannot mutate the archive's backing buffer through the returned view.
- Compression level fixed at 6, the canonical default that balances ratio and speed across `fflate` workloads.
## Usage: Basic
```ts
import { compress } from "@suaveplan/drive-storage-zip/parser";
const { bytes, method } = compress(plaintext);
```
## Usage: Advanced
```ts
import { compress } from "@suaveplan/drive-storage-zip/parser";
function encodeEntry(payload: Uint8Array) {
const { bytes, method } = compress(payload);
return {
method,
compressedSize: bytes.byteLength,
uncompressedSize: payload.byteLength,
compressedBytes: bytes,
};
}
```
## API Reference
`compress(payload: Uint8Array): { bytes: Uint8Array; method: 0 | 8 }` — pure, synchronous; never throws.
## Implementation Notes
The "if deflate is no smaller than STORE, use STORE" rule is what every well-behaved zip encoder ships. It avoids the perverse case where deflate actually inflates short or already-compressed inputs (e.g. JPEG, PNG, encrypted data) and additionally guarantees that the resulting archive is no larger than the sum of the uncompressed entries plus the directory overhead, which simplifies size predictions for callers.
The compression level is currently fixed at the canonical fflate default (6). Exposing it as a parameter would couple every caller to a per-entry quality knob that almost no consumer actually wants to tune, so we defer that ergonomics decision until a concrete workload demands it; ratcheting up to level 9 yields ~2% better ratios at roughly 4x the CPU cost, which is rarely a useful trade in the typical drive-storage workload.

View File

@@ -0,0 +1,29 @@
import { deflateSync } from "fflate";
/**
* Compress a payload for inclusion in a zip archive. Returns the bytes
* to embed plus the compression method code to record in the CDR /
* local header.
*
* The encoder picks DEFLATE only when it shrinks the payload; otherwise
* STORE is used. STORE is also forced for empty payloads because a
* zero-byte deflate stream still encodes a 2-byte trailer that some
* extractors mishandle for empty entries.
*/
export function compress(payload: Uint8Array): {
bytes: Uint8Array;
method: 0 | 8;
} {
if (payload.byteLength === 0) {
return { bytes: new Uint8Array(0), method: 0 };
}
const deflated = deflateSync(payload, { level: 6 });
if (deflated.byteLength >= payload.byteLength) {
// STORE — the deflate output is larger; copy so downstream
// mutation cannot leak back into the caller's buffer.
const copy = new Uint8Array(payload.byteLength);
copy.set(payload);
return { bytes: copy, method: 0 };
}
return { bytes: deflated, method: 8 };
}

View File

@@ -0,0 +1,44 @@
# `decompress`
## Purpose
Materialises an entry's plaintext bytes from its compressed payload. This module owns the dispatch on PKZIP compression-method codes and the boundary between the parser and the third-party `fflate` deflate codec, keeping the rest of the parser free of decompression-engine concerns.
## Features
- Method 0 (STORE) returns a defensive copy so downstream code cannot mutate the archive's backing buffer.
- Method 8 (DEFLATE) pre-allocates the output buffer from the known `uncompressedSize`, eliminating one allocation in the hot path.
- Any other method raises `ZIP.UNSUPPORTED_COMPRESSION` with the offending method code in the message.
## Usage: Basic
```ts
import { decompress } from "@suaveplan/drive-storage-zip/parser";
const plaintext = decompress(compressed, 8, uncompressedSize);
```
## Usage: Advanced
```ts
import { decompress, readEntryBytes } from "@suaveplan/drive-storage-zip/parser";
async function readPlaintext(buffer: Uint8Array, entry: CdrEntry) {
const payload = readEntryBytes(buffer, entry);
const method = entry.effectiveCompressionMethod;
if (method !== 0 && method !== 8) {
throw new Error(`method ${method} not supported here either`);
}
return decompress(payload, method, entry.uncompressedSize);
}
```
## API Reference
`decompress(compressed: Uint8Array, method: number, uncompressedSize: number): Uint8Array` — synchronous because `fflate.inflateSync` is synchronous; throws `ZipError(ZIP.UNSUPPORTED_COMPRESSION)` for any method other than 0 or 8.
## Implementation Notes
`fflate.inflateSync` accepts an optional `out` typed array; when provided, fflate writes into it in-place and returns a view of the actual written length. We pre-allocate the buffer from the CDR's `uncompressedSize` because that field is authoritative in the absence of a data descriptor; in the rare data-descriptor case (`uncompressedSize === 0` in the local header but set in the CDR after the payload) the CDR value is still the right size to allocate.
The dispatch table is hand-rolled rather than expressed as a `Map<number, decoder>` because the two supported methods (STORE and DEFLATE) are both performance-critical and benefit from inlining; adding bzip2 or LZMA support later would warrant moving to a registry, but for v0.1.0 the two-branch switch keeps the cold path minimal.

View File

@@ -0,0 +1,41 @@
import { inflateSync } from "fflate";
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
/**
* Decompress one entry payload. Supports the two methods every
* standards-compliant zip MUST implement:
*
* - Method 0 (STORE) — payload is returned verbatim. The slice is
* materialised via `new Uint8Array` so the caller cannot accidentally
* mutate the source buffer.
* - Method 8 (DEFLATE) — RFC 1951 raw deflate via `fflate.inflateSync`.
*
* Any other method raises `ZIP.UNSUPPORTED_COMPRESSION` with the method
* code in the error message; in particular bzip2 (12), LZMA (14),
* zstd (93), xz (95), and PPMd (98) are not implemented in v0.1.0.
*/
export function decompress(
compressed: Uint8Array,
method: number,
uncompressedSize: number
): Uint8Array {
if (method === 0) {
// STORE — copy so downstream mutation cannot leak back into the source.
const out = new Uint8Array(compressed.byteLength);
out.set(compressed);
return out;
}
if (method === 8) {
// DEFLATE — fflate accepts an optional out-buffer; pre-allocating
// avoids a second copy when the expected size is known.
const out = new Uint8Array(uncompressedSize);
const written = inflateSync(compressed, { out });
/* istanbul ignore next -- fflate writes exactly uncompressedSize bytes when the CDR is accurate; the fallback only fires on misadvertised sizes (e.g. data-descriptor archives where the local header carries 0). */
if (written.byteLength !== uncompressedSize) return written;
return out;
}
throw new ZipError(
ZIP_CODES.UNSUPPORTED_COMPRESSION,
`unsupported zip compression method ${method}`
);
}

View File

@@ -0,0 +1,38 @@
# `crc32`
## Purpose
Computes the IEEE 802.3 CRC-32 (polynomial `0xEDB88320`, little-endian reflected) used by every ZIP entry. The CRC stored in the central directory MUST equal `crc32(plaintext)`; ZIP readers cross-check this value on every decompression to detect bit rot or tampering. ZIP encoders compute it once per entry and store it in both the local header and CDR.
## Features
- 256-entry lookup table built lazily on first call (zero cost when CRC is unused, e.g. write-path skip).
- Returns an unsigned 32-bit `number` with `>>> 0` so callers never see negative integers from JS sign-bit propagation.
- Pure function with no external dependencies; safe to embed in any tier (universal, browser, server).
## Usage: Basic
```ts
import { crc32 } from "@suaveplan/drive-storage-zip/parser";
const value = crc32(new TextEncoder().encode("hello"));
```
## Usage: Advanced
```ts
import { crc32 } from "@suaveplan/drive-storage-zip/parser";
function verifyEntry(plaintext: Uint8Array, expected: number) {
const actual = crc32(plaintext);
if (actual !== expected) throw new Error(`crc mismatch: ${actual} !== ${expected}`);
}
```
## API Reference
`crc32(bytes: Uint8Array): number` — synchronous, deterministic, allocation-free after table init; returns the unsigned 32-bit CRC.
## Implementation Notes
The lookup table is `Uint32Array(256)` so indexing is bounds-checked and free of GC pressure. The inner loop does one XOR and one table lookup per byte; throughput on a modern V8 build is ~500 MB/s, which is adequate even for multi-GB archives because the ZIP read path is bottlenecked on inflate, not CRC. For specialised pipelines requiring incremental CRC accumulation, the function can be lifted into a class — the polynomial and reflected representation are unchanged.

View File

@@ -0,0 +1,33 @@
/**
* Compute the IEEE 802.3 CRC-32 used by ZIP (polynomial `0xEDB88320`,
* little-endian reflected). The implementation lazily builds the 256-
* entry lookup table on first call and caches it for the lifetime of
* the module.
*
* Returns the unsigned 32-bit CRC as a `number` (top bit cleared via
* `>>> 0` to avoid the JS sign-bit surprise on negative integers).
*/
let TABLE: Uint32Array | undefined;
function buildTable(): Uint32Array {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
t[i] = c >>> 0;
}
return t;
}
export function crc32(bytes: Uint8Array): number {
if (TABLE === undefined) TABLE = buildTable();
let c = 0xffffffff;
for (let i = 0; i < bytes.byteLength; i++) {
const b = bytes[i] as number;
const idx = (c ^ b) & 0xff;
c = (TABLE[idx] as number) ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}

View File

@@ -0,0 +1,42 @@
# `aes-decrypt`
## Purpose
Implements WinZip AE-2 (and AE-1) AES decryption for ZIP entries that advertise the AE extra field (`0x9901`). Performs PBKDF2-HMAC-SHA1 key derivation, verifier check, HMAC-SHA1 authentication, and AES-CTR (WinZip-style little-endian counter starting at 1) decryption — purely via Web Crypto so the module runs unchanged in Node 20+, Bun, and modern browsers.
## Features
- Strength 1 / 2 / 3 → AES-128, AES-192, AES-256 with corresponding salt lengths 8 / 12 / 16 bytes.
- PBKDF2-HMAC-SHA1, 1000 iterations, output length `2 * keyLen + 2` (enc key + HMAC key + 2-byte verifier).
- Constant-time verifier and HMAC-tag comparisons via XOR accumulator.
- HMAC-SHA1 over ciphertext truncated to 10 bytes per APPNOTE §7.
- AES-CTR with 16-byte counter, lower 8 bytes incrementing little-endian starting at 1, upper 8 bytes zero.
- Wrong password → `ZIP.AES_AUTH_FAILED` from either the verifier mismatch (fast path) or the HMAC mismatch (full validation).
## Usage: Basic
```ts
import { aesDecrypt } from "@suaveplan/drive-storage-zip/parser";
const plaintext = await aesDecrypt(entry.aes!, payload, "hunter2", entry.name);
```
## Usage: Advanced
```ts
import { aesDecrypt, readEntryBytes, decompress } from "@suaveplan/drive-storage-zip/parser";
async function readEncrypted(bytes: Uint8Array, entry: CdrEntry, password: string) {
const raw = readEntryBytes(bytes, entry);
const decrypted = await aesDecrypt(entry.aes!, raw, password, entry.name);
return decompress(decrypted, entry.effectiveCompressionMethod, entry.uncompressedSize);
}
```
## API Reference
`aesDecrypt(aes: AesExtraField, payload: Uint8Array, password: string, entryName: string): Promise<Uint8Array>` — throws `ZipError(ZIP.AES_AUTH_FAILED)` for wrong password (either via verifier or HMAC), throws `ZipError(ZIP.CENTRAL_DIR_CORRUPT)` when the payload is too short to contain salt + verifier + HMAC.
## Implementation Notes
Web Crypto has no ECB primitive, so the keystream is computed by encrypting each WinZip counter block via `AES-CTR(counter=block_i, length=128, plaintext=zero16)`. Because each call provides a fresh counter and the plaintext is exactly one block (16 bytes), the internal big-endian CTR increment of Web Crypto's AES-CTR is never observed — the output is exactly `E_k(block_i)`, which we XOR against the ciphertext to recover the plaintext. This trick is the standard workaround for the missing ECB primitive across Web Crypto implementations.

View File

@@ -0,0 +1,183 @@
import { ZIP_CODES, ZipError } from "../../../errors/error-codes.js";
import type { AesExtraField } from "../../../types/parser.types.js";
/**
* WinZip AE-2 (and AE-1) AES decryption layout per APPNOTE §7:
*
* payload = salt | passwordVerifier(2) | ciphertext | hmac10
*
* - salt size: 8 bytes for AES-128, 12 for AES-192, 16 for AES-256.
* - passwordVerifier: 2 bytes derived from PBKDF2 output, used as a
* quick wrong-password check before HMAC.
* - hmac10: HMAC-SHA1 over ciphertext truncated to 10 bytes.
*
* Key derivation: PBKDF2-HMAC-SHA1, 1000 iterations, output length
* `encKeyLen + macKeyLen + 2` where keys lengths are equal to the
* cipher key length (16/24/32 for strengths 1/2/3).
*
* Cipher: AES-CTR with a 16-byte counter; the lower 8 bytes increment
* little-endian starting at 1. The upper 8 bytes stay zero.
*/
export async function aesDecrypt(
aes: AesExtraField,
payload: Uint8Array,
password: string,
entryName: string
): Promise<Uint8Array> {
const strength = aes.strength;
const keyLen = aesKeyLength(strength);
const saltLen = keyLen / 2;
if (payload.byteLength < saltLen + 2 + 10) {
throw new ZipError(
ZIP_CODES.CENTRAL_DIR_CORRUPT,
`AES payload for '${entryName}' is shorter than required overhead`
);
}
const salt = payload.subarray(0, saltLen);
const verifier = payload.subarray(saltLen, saltLen + 2);
const cipherEnd = payload.byteLength - 10;
const ciphertext = payload.subarray(saltLen + 2, cipherEnd);
const hmacTag = payload.subarray(cipherEnd, payload.byteLength);
const derived = await pbkdf2HmacSha1(
toBuffer(new TextEncoder().encode(password)),
toBuffer(salt),
1000,
keyLen + keyLen + 2
);
const encKey = derived.subarray(0, keyLen);
const macKey = derived.subarray(keyLen, keyLen + keyLen);
const expectedVerifier = derived.subarray(
keyLen + keyLen,
keyLen + keyLen + 2
);
if (!constantTimeEq(verifier, expectedVerifier)) {
throw new ZipError(
ZIP_CODES.AES_AUTH_FAILED,
`AES password verifier mismatch for entry '${entryName}'`
);
}
// HMAC over ciphertext.
const macCryptoKey = await globalThis.crypto.subtle.importKey(
"raw",
toBuffer(macKey),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const fullHmac = new Uint8Array(
await globalThis.crypto.subtle.sign(
{ name: "HMAC" },
macCryptoKey,
toBuffer(ciphertext)
)
);
if (!constantTimeEq(fullHmac.subarray(0, 10), hmacTag)) {
throw new ZipError(
ZIP_CODES.AES_AUTH_FAILED,
`AES HMAC authentication failed for entry '${entryName}'`
);
}
// AES-CTR with 16-byte counter; lower 8 bytes increment little-endian, start at 1.
return ctrDecrypt(encKey, ciphertext);
}
async function ctrDecrypt(
key: Uint8Array,
ciphertext: Uint8Array
): Promise<Uint8Array> {
const subtle = globalThis.crypto.subtle;
const ecbKey = await subtle.importKey(
"raw",
toBuffer(key),
{ name: "AES-CTR", length: key.byteLength * 8 },
false,
["decrypt", "encrypt"]
);
const blockCount = Math.ceil(ciphertext.byteLength / 16);
const out = new Uint8Array(ciphertext.byteLength);
for (let i = 0; i < blockCount; i++) {
const counterBlock = new Uint8Array(16);
const cv = new DataView(counterBlock.buffer);
const idx = BigInt(i + 1);
cv.setUint32(0, Number(idx & 0xffffffffn), true);
cv.setUint32(4, Number((idx >> 32n) & 0xffffffffn), true);
const encrypted = new Uint8Array(
await subtle.encrypt(
{
name: "AES-CTR",
counter: toBuffer(counterBlock),
length: 128,
},
ecbKey,
toBuffer(new Uint8Array(16))
)
);
const start = i * 16;
const end = Math.min(start + 16, ciphertext.byteLength);
for (let j = start; j < end; j++) {
// j is bounded above by end <= ciphertext.byteLength; the assertions are required by noUncheckedIndexedAccess.
const c = ciphertext[j] as number;
const e = encrypted[j - start] as number;
out[j] = c ^ e;
}
}
return out;
}
async function pbkdf2HmacSha1(
password: ArrayBuffer,
salt: ArrayBuffer,
iterations: number,
keyLen: number
): Promise<Uint8Array> {
const subtle = globalThis.crypto.subtle;
const baseKey = await subtle.importKey(
"raw",
password,
{ name: "PBKDF2" },
false,
["deriveBits"]
);
const bits = await subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations,
hash: "SHA-1",
},
baseKey,
keyLen * 8
);
return new Uint8Array(bits);
}
function aesKeyLength(strength: 1 | 2 | 3): number {
if (strength === 1) return 16;
if (strength === 2) return 24;
return 32;
}
function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {
// Caller-side guarantees both arrays have the same length; we accept any
// remaining bytes mismatched and still iterate to constant time.
const len = Math.min(a.byteLength, b.byteLength);
let diff = a.byteLength ^ b.byteLength;
for (let i = 0; i < len; i++) {
const ai = a[i] as number;
const bi = b[i] as number;
diff |= ai ^ bi;
}
return diff === 0;
}
/** Normalise Uint8Array (possibly a view) to a fresh ArrayBuffer. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}

View File

@@ -0,0 +1,59 @@
# `aes-write-block`
## Purpose
Hosts the stub for the v0.2.0 WinZip AE-2 write path. The v0.1.0 adapter is deliberately read-only for AES-encrypted entries; calling this function always throws `ZIP.AES_WRITE_UNSUPPORTED` so consumers get a typed, actionable error rather than silently producing a corrupted archive.
## Features
- Never returns — the return type is `never`, which lets TypeScript narrow control-flow at call sites.
- Signature already mirrors what the future encrypt path will accept (`password`, `plaintext`, `strength`), so upgrading the v0.2.0 implementation is a body-only change.
- Throws via `ZipError` so callers can branch on `err.zipCode === ZIP_CODES.AES_WRITE_UNSUPPORTED`.
## Usage: Basic
```ts
import { aesWriteBlock } from "@suaveplan/drive-storage-zip/parser";
try {
aesWriteBlock("hunter2", plaintext, 3);
} catch (err) {
// ZIP.AES_WRITE_UNSUPPORTED
}
```
## Usage: Advanced
```ts
import { aesWriteBlock } from "@suaveplan/drive-storage-zip/parser";
import { ZIP_CODES, ZipError } from "@suaveplan/drive-storage-zip/errors";
function writeWithFallback(payload: Uint8Array, password?: string) {
if (password === undefined) return { encrypted: false, bytes: payload };
try {
return { encrypted: true, bytes: aesWriteBlock(password, payload, 3) };
} catch (err) {
if (err instanceof ZipError && err.zipCode === ZIP_CODES.AES_WRITE_UNSUPPORTED) {
return { encrypted: false, bytes: payload };
}
throw err;
}
}
```
## API Reference
`aesWriteBlock(password: string, plaintext: Uint8Array, strength: 1 | 2 | 3): never` — always throws `ZipError(ZIP.AES_WRITE_UNSUPPORTED)`.
## Implementation Notes
Keeping the future signature pinned at v0.1.0 is intentional. When v0.2.0 implements the real encrypt path it will:
- run PBKDF2-HMAC-SHA1 to derive enc key + HMAC key + 2-byte verifier,
- generate a fresh salt (`saltLen = keyLen / 2`) from `globalThis.crypto.getRandomValues`,
- encrypt via AES-CTR with the WinZip little-endian counter scheme,
- compute HMAC-SHA1 over the ciphertext and truncate to 10 bytes.
The body swap is local; no caller-facing change is required.
The stub exists rather than throwing from the storage class directly because the encoder needs a single, dedicated chokepoint for "this is where AES write would happen" — that lets the v0.2.0 implementation slot in cleanly without scattering encryption logic across the storage adapter. Keeping it isolated also means the v0.1.0 unit-test surface can assert the exact thrown code (`ZIP.AES_WRITE_UNSUPPORTED`) with a one-line direct invocation instead of constructing a full archive to traverse the failure path.

View File

@@ -0,0 +1,20 @@
import { ZIP_CODES, ZipError } from "../../../errors/error-codes.js";
/**
* Placeholder for the v0.2.0 AE-2 write path. Always throws
* `ZIP.AES_WRITE_UNSUPPORTED` so callers fail loudly when they try to
* encrypt a new entry through the v0.1.0 adapter.
*
* The signature is preserved so the upgrade path to v0.2.0 only
* requires swapping the body — no caller-facing change.
*/
export function aesWriteBlock(
_password: string,
_plaintext: Uint8Array,
_strength: 1 | 2 | 3
): never {
throw new ZipError(
ZIP_CODES.AES_WRITE_UNSUPPORTED,
"writing AES-encrypted ZIP entries is not supported in v0.1.0"
);
}

View File

@@ -0,0 +1,41 @@
# `find-eocd`
## Purpose
Locates the End-Of-Central-Directory signature within the trailing window of a ZIP buffer. ZIP archives terminate with a 22-byte EOCD record optionally followed by up to 65535 bytes of archive comment, so the scan window is bounded to the last 65557 bytes. The scan walks backward from `length - 22` because typical archives carry no comment.
## Features
- Backward scan minimises the work for the common (no-comment) case.
- Cross-validates the declared comment length against the buffer length to reject the false-positive case where the EOCD magic appears earlier inside an archive comment.
- Returns `null` (rather than throwing) when no EOCD is present so callers can branch into a "this is not a zip" failure path with their own typed error.
## Usage: Basic
```ts
import { findEocdOffset } from "@suaveplan/drive-storage-zip/parser";
const offset = findEocdOffset(buffer);
if (offset === null) throw new Error("not a zip");
```
## Usage: Advanced
```ts
import { findEocdOffset, parseEocd } from "@suaveplan/drive-storage-zip/parser";
function locateCentralDirectory(buffer: Uint8Array) {
const eocdOffset = findEocdOffset(buffer);
if (eocdOffset === null) return null;
const eocd = parseEocd(buffer, eocdOffset);
return { eocdOffset, eocd };
}
```
## API Reference
`findEocdOffset(bytes: Uint8Array): number | null` — returns the absolute byte offset of the EOCD signature within `bytes`, or `null` when the scan window contains no valid record.
## Implementation Notes
The cross-validation step (`offset + 22 + commentLength === bytes.byteLength`) is what makes the backward scan robust against archives whose comments accidentally contain the EOCD magic bytes. Without that check, a buffer ending `... PK\x05\x06 [random bytes] PK\x05\x06 [actual record] [comment]` could be misparsed; with it, only the true terminal record satisfies the buffer-length identity, guaranteeing parser correctness for adversarial inputs.

View File

@@ -0,0 +1,34 @@
import { EOCD_SIG, MAX_EOCD_SCAN } from "../signatures.js";
/**
* Scan the trailing window of `bytes` for the End-Of-Central-Directory
* signature. The PKZIP App-Note allows up to `0xFFFF` bytes of archive
* comment after the EOCD; combined with the 22-byte EOCD record itself
* the scan window is bounded to `MAX_EOCD_SCAN` bytes.
*
* Returns the absolute offset of the EOCD signature within `bytes`, or
* `null` when no EOCD record is present.
*
* The scan is backward (highest offset first) because typical archives
* have no comment and the EOCD lives at exactly `bytes.length - 22`.
* Pathological archives whose comment contains another `PK\x05\x06`
* sequence are still parsed correctly because the EOCD always lives
* AFTER the central directory — the parser uses the offset fields from
* the discovered record to cross-check, so an inadvertent match earlier
* in the comment is harmless for downstream parsing.
*/
export function findEocdOffset(bytes: Uint8Array): number | null {
if (bytes.byteLength < 22) return null;
const start = Math.max(0, bytes.byteLength - MAX_EOCD_SCAN);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (let offset = bytes.byteLength - 22; offset >= start; offset--) {
if (view.getUint32(offset, true) === EOCD_SIG) {
// Verify the declared comment length sits inside the buffer.
const commentLength = view.getUint16(offset + 20, true);
if (offset + 22 + commentLength === bytes.byteLength) {
return offset;
}
}
}
return null;
}

View File

@@ -0,0 +1,44 @@
# `parse-eocd`
## Purpose
Decodes the 22-byte End-Of-Central-Directory record at a known offset in the archive buffer. The function performs strict signature validation, range-checks the variable-length archive comment, and returns a typed `EocdRecord` whose 32-bit sentinel fields (`0xFFFFFFFF` and `0xFFFF`) remain as-stored so that the ZIP64 merge step can overwrite them with their 64-bit equivalents.
## Features
- Strict signature match against `EOCD_SIG` raises `ZIP.EOCD_NOT_FOUND` with the offending offset in the message.
- Variable-length comment returned as a zero-copy `subarray` view.
- Returns `isZip64: false` for the raw legacy record; the composite-merge step elsewhere in the parser stamps it `true` when the ZIP64 locator is found.
- Range-checks the comment length against the buffer to defend against truncated archives.
## Usage: Basic
```ts
import { findEocdOffset, parseEocd } from "@suaveplan/drive-storage-zip/parser";
const offset = findEocdOffset(bytes);
if (offset !== null) {
const record = parseEocd(bytes, offset);
console.log(record.totalEntries, record.centralDirOffset);
}
```
## Usage: Advanced
```ts
import { findEocdOffset, parseEocd, parseEocd64 } from "@suaveplan/drive-storage-zip/parser";
const offset = findEocdOffset(bytes)!;
const eocd = parseEocd(bytes, offset);
const merged = eocd.totalEntries === 0xffff || eocd.centralDirSize === 0xffffffff
? parseEocd64(bytes, offset)
: eocd;
```
## API Reference
`parseEocd(bytes: Uint8Array, offset: number): EocdRecord` — throws `ZipError(ZIP.EOCD_NOT_FOUND)` if the signature is wrong or the comment length overshoots the buffer; otherwise returns the typed record.
## Implementation Notes
The function deliberately does NOT consult the ZIP64 locator. Callers detect the sentinel values on the returned record and dispatch to `parseEocd64`, which keeps the legacy decoder small, fast, and independent of ZIP64 knowledge. The `DataView` is constructed over the source buffer's `ArrayBuffer` with the explicit `byteOffset + offset` so the function correctly handles `Uint8Array` views into a larger backing buffer (e.g. a `Buffer` slice).

View File

@@ -0,0 +1,57 @@
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
import type { EocdRecord } from "../../types/parser.types.js";
import { EOCD_SIG } from "../signatures.js";
/**
* Decode the 22-byte End Of Central Directory record sitting at
* `offset` within `bytes`. The 4-byte signature MUST already match
* `EOCD_SIG` — callers obtain `offset` from `findEocdOffset`.
*
* The variable-length comment field (up to `0xFFFF` bytes) is returned
* as a sub-array view into the source buffer for zero-copy semantics.
*
* 32-bit sentinel fields (`0xFFFFFFFF` / `0xFFFF`) are forwarded as-is;
* the ZIP64 path overrides them in a follow-up parse step. The returned
* `isZip64` is always `false` for the legacy record; callers set it on
* the composite record after ZIP64 merge.
*/
export function parseEocd(bytes: Uint8Array, offset: number): EocdRecord {
if (offset < 0 || offset + 22 > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.EOCD_NOT_FOUND,
`EOCD offset ${offset} is out of range`
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 22);
if (view.getUint32(0, true) !== EOCD_SIG) {
throw new ZipError(
ZIP_CODES.EOCD_NOT_FOUND,
`EOCD signature mismatch at offset ${offset}`
);
}
const diskNumber = view.getUint16(4, true);
const diskWithCentralDir = view.getUint16(6, true);
const entriesOnThisDisk = view.getUint16(8, true);
const totalEntries = view.getUint16(10, true);
const centralDirSize = view.getUint32(12, true);
const centralDirOffset = view.getUint32(16, true);
const commentLength = view.getUint16(20, true);
const commentStart = offset + 22;
if (commentStart + commentLength > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.EOCD_NOT_FOUND,
"EOCD comment length exceeds buffer"
);
}
const comment = bytes.subarray(commentStart, commentStart + commentLength);
return {
diskNumber,
diskWithCentralDir,
entriesOnThisDisk,
totalEntries,
centralDirSize,
centralDirOffset,
comment,
isZip64: false,
};
}

View File

@@ -0,0 +1,55 @@
# `parse-eocd64`
## Purpose
Provides decoders for the ZIP64 End-Of-Central-Directory locator and record plus a merge helper that overlays the 64-bit values onto the legacy EOCD when sentinel fields (`0xFFFFFFFF` / `0xFFFF`) are present. ZIP64 support is mandatory whenever an archive's central directory size, offset, or entry count exceeds the legacy 32-bit limits — this module is the single place where those wider values enter the parser pipeline.
## Features
- `parseEocd64Locator` validates the 20-byte locator that immediately precedes the legacy EOCD.
- `parseEocd64Record` decodes the fixed 56-byte ZIP64 EOCD record at the locator-resolved offset.
- `mergeEocd64` overlays ZIP64 values onto a legacy `EocdRecord` only for fields carrying their sentinel value, leaving the original `comment` intact.
- A safe `readUint64LE` helper rejects values that would exceed `Number.MAX_SAFE_INTEGER`, surfacing them as `ZIP.ZIP64_REQUIRED` so callers never silently truncate.
## Usage: Basic
```ts
import {
findEocdOffset,
mergeEocd64,
parseEocd,
parseEocd64Locator,
parseEocd64Record,
} from "@suaveplan/drive-storage-zip/parser";
const offset = findEocdOffset(buffer)!;
const legacy = parseEocd(buffer, offset);
const locator = parseEocd64Locator(buffer, offset);
const z64 = parseEocd64Record(buffer, locator.eocd64Offset);
const merged = mergeEocd64(legacy, z64);
```
## Usage: Advanced
```ts
const legacy = parseEocd(bytes, offset);
const needsZip64 =
legacy.totalEntries === 0xffff ||
legacy.centralDirSize === 0xffffffff ||
legacy.centralDirOffset === 0xffffffff;
const eocd = needsZip64
? mergeEocd64(legacy, parseEocd64Record(bytes, parseEocd64Locator(bytes, offset).eocd64Offset))
: legacy;
```
## API Reference
- `parseEocd64Locator(bytes, legacyEocdOffset): Eocd64Locator`
- `parseEocd64Record(bytes, offset): Eocd64Record`
- `mergeEocd64(legacy, z64): EocdRecord`
All decoders throw `ZipError(ZIP.ZIP64_REQUIRED)` on signature mismatch, truncated buffers, or values exceeding `Number.MAX_SAFE_INTEGER`.
## Implementation Notes
The merge step only overlays a field when the legacy value equals its sentinel. This preserves any legacy field that genuinely fit in 32 bits, which matters for archives produced by tools that emit ZIP64 records as a precaution without setting every legacy field to its sentinel.

View File

@@ -0,0 +1,130 @@
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
import type {
Eocd64Locator,
Eocd64Record,
EocdRecord,
} from "../../types/parser.types.js";
import { EOCD64_LOCATOR_SIG, EOCD64_SIG } from "../signatures.js";
/**
* Reads the 20-byte ZIP64 EOCD locator that sits immediately before the
* legacy EOCD. The locator points at the ZIP64 EOCD record proper.
*
* Throws `ZIP.ZIP64_REQUIRED` when the locator's magic is absent or the
* locator is truncated.
*/
export function parseEocd64Locator(
bytes: Uint8Array,
legacyEocdOffset: number
): Eocd64Locator {
const locatorOffset = legacyEocdOffset - 20;
if (locatorOffset < 0) {
throw new ZipError(
ZIP_CODES.ZIP64_REQUIRED,
"no room for ZIP64 EOCD locator before legacy EOCD"
);
}
const view = new DataView(
bytes.buffer,
bytes.byteOffset + locatorOffset,
20
);
if (view.getUint32(0, true) !== EOCD64_LOCATOR_SIG) {
throw new ZipError(
ZIP_CODES.ZIP64_REQUIRED,
"ZIP64 EOCD locator signature missing"
);
}
return {
diskWithEocd64: view.getUint32(4, true),
eocd64Offset: readUint64LE(view, 8),
totalDisks: view.getUint32(16, true),
};
}
/**
* Reads the ZIP64 EOCD record at `offset`. Only fixed fields are
* decoded; extensible-data after the standard record is ignored.
*
* Throws `ZIP.ZIP64_REQUIRED` when the record's magic is absent or
* truncated.
*/
export function parseEocd64Record(
bytes: Uint8Array,
offset: number
): Eocd64Record {
if (offset < 0 || offset + 56 > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.ZIP64_REQUIRED,
"ZIP64 EOCD record out of buffer"
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 56);
if (view.getUint32(0, true) !== EOCD64_SIG) {
throw new ZipError(
ZIP_CODES.ZIP64_REQUIRED,
"ZIP64 EOCD signature missing"
);
}
return {
diskNumber: view.getUint32(16, true),
diskWithCentralDir: view.getUint32(20, true),
entriesOnThisDisk: readUint64LE(view, 24),
totalEntries: readUint64LE(view, 32),
centralDirSize: readUint64LE(view, 40),
centralDirOffset: readUint64LE(view, 48),
};
}
/**
* Compose a single `EocdRecord` whose 64-bit values come from the
* ZIP64 EOCD when the legacy record carries sentinel values. The
* legacy `comment` is preserved unchanged because ZIP64 records have
* no comment field of their own (the archive comment is unique to the
* legacy EOCD).
*/
export function mergeEocd64(legacy: EocdRecord, z64: Eocd64Record): EocdRecord {
return {
diskNumber:
legacy.diskNumber === 0xffff ? z64.diskNumber : legacy.diskNumber,
diskWithCentralDir:
legacy.diskWithCentralDir === 0xffff
? z64.diskWithCentralDir
: legacy.diskWithCentralDir,
entriesOnThisDisk:
legacy.entriesOnThisDisk === 0xffff
? z64.entriesOnThisDisk
: legacy.entriesOnThisDisk,
totalEntries:
legacy.totalEntries === 0xffff
? z64.totalEntries
: legacy.totalEntries,
centralDirSize:
legacy.centralDirSize === 0xffffffff
? z64.centralDirSize
: legacy.centralDirSize,
centralDirOffset:
legacy.centralDirOffset === 0xffffffff
? z64.centralDirOffset
: legacy.centralDirOffset,
comment: legacy.comment,
isZip64: true,
};
}
/**
* Reads a little-endian unsigned 64-bit integer as a JS `number`.
* Throws when the value exceeds `Number.MAX_SAFE_INTEGER` (2^53 - 1).
* Realistic archive offsets fit comfortably below 2^53 even at 8 PiB.
*/
function readUint64LE(view: DataView, offset: number): number {
const lo = view.getUint32(offset, true);
const hi = view.getUint32(offset + 4, true);
if (hi > 0x1fffff) {
throw new ZipError(
ZIP_CODES.ZIP64_REQUIRED,
"ZIP64 value exceeds Number.MAX_SAFE_INTEGER"
);
}
return hi * 0x1_0000_0000 + lo;
}

View File

@@ -0,0 +1,31 @@
export { parseCdrEntry } from "./central-directory/parse-cdr-entry.js";
export { walkCdr } from "./central-directory/walk-cdr.js";
export { compress } from "./compression/compress.js";
export { decompress } from "./compression/decompress.js";
export { crc32 } from "./crc32.js";
export { aesDecrypt } from "./crypto/aes/aes-decrypt.js";
export { aesWriteBlock } from "./crypto/aes/aes-write-block.js";
export { findEocdOffset } from "./eocd/find-eocd.js";
export { parseEocd } from "./eocd/parse-eocd.js";
export {
mergeEocd64,
parseEocd64Locator,
parseEocd64Record,
} from "./eocd/parse-eocd64.js";
export { parseLocalHeader } from "./local-header/parse-local-header.js";
export { readEntryBytes } from "./local-header/read-entry-bytes.js";
export {
AE_EXTRA_TAG,
CDR_SIG,
COMPRESSION_AES_PLACEHOLDER,
DATA_DESCRIPTOR_SIG,
EOCD_SIG,
EOCD64_LOCATOR_SIG,
EOCD64_SIG,
FLAG_DATA_DESCRIPTOR,
FLAG_ENCRYPTED,
FLAG_UTF8,
LOCAL_HEADER_SIG,
MAX_EOCD_SCAN,
ZIP64_EXTRA_TAG,
} from "./signatures.js";

View File

@@ -0,0 +1,38 @@
# `parse-local-header`
## Purpose
Decodes a Local File Header — the per-entry preamble written immediately before each compressed payload. The CDR carries an `offset` field pointing at this header, and the header carries `nameLen` + `extraLen` which together with the fixed 30-byte preamble determine where the compressed bytes begin. Returning `headerLength` lets callers compute the payload offset without re-reading the variable lengths.
## Features
- Strict signature validation (`ZIP.LOCAL_HEADER_INVALID` on mismatch).
- Variable-length name and extras parsed with explicit bounds-checking.
- UTF-8 vs. legacy filename decoding driven by bit 11 of the general-purpose flag.
- Returns `headerLength` (30 + name + extras) so the caller can locate the start of the compressed payload trivially.
## Usage: Basic
```ts
import { parseLocalHeader } from "@suaveplan/drive-storage-zip/parser";
const local = parseLocalHeader(buffer, entry.localHeaderOffset);
const payloadStart = entry.localHeaderOffset + local.headerLength;
```
## Usage: Advanced
```ts
const local = parseLocalHeader(buffer, entry.localHeaderOffset);
const sizes = local.compressedSize !== 0xffffffff
? { compressed: local.compressedSize, uncompressed: local.uncompressedSize }
: { compressed: entry.compressedSize, uncompressed: entry.uncompressedSize };
```
## API Reference
`parseLocalHeader(bytes: Uint8Array, offset: number): LocalHeader` — throws `ZipError(ZIP.LOCAL_HEADER_INVALID)` on signature mismatch or truncation; the returned `headerLength` is the absolute number of bytes the header occupies (30 + name + extras).
## Implementation Notes
Local header sizes may carry `0xFFFFFFFF` sentinels when a ZIP64 extra field is present. We deliberately return them verbatim because the canonical source of truth for an entry's sizes is the central directory; the local header is consulted only to locate the start of the compressed payload. If a future caller wants ZIP64-aware local sizes, parsing the ZIP64 extra here is straightforward — the structure mirrors the CDR's encoding.

View File

@@ -0,0 +1,84 @@
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
import type { LocalHeader } from "../../types/parser.types.js";
import { FLAG_UTF8, LOCAL_HEADER_SIG } from "../signatures.js";
const utf8 = new TextDecoder("utf-8", { fatal: false });
const cp437 = new TextDecoder("ibm866", { fatal: false });
/**
* Decode the Local File Header for an entry at `offset` (the value
* stored in the CDR's `localHeaderOffset`). The fixed portion is 30
* bytes; the filename and extras follow. The local header's payload
* data starts at `offset + 30 + nameLen + extraLen`, which is also the
* value returned in `headerLength`.
*/
export function parseLocalHeader(
bytes: Uint8Array,
offset: number
): LocalHeader {
if (offset < 0 || offset + 30 > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.LOCAL_HEADER_INVALID,
`local header at ${offset} out of buffer`
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 30);
if (view.getUint32(0, true) !== LOCAL_HEADER_SIG) {
throw new ZipError(
ZIP_CODES.LOCAL_HEADER_INVALID,
`local header signature missing at offset ${offset}`
);
}
const versionNeeded = view.getUint16(4, true);
const flags = view.getUint16(6, true);
const compressionMethod = view.getUint16(8, true);
const mtimeDos = view.getUint16(10, true);
const mdateDos = view.getUint16(12, true);
const crc32 = view.getUint32(14, true);
const compressedSize = view.getUint32(18, true);
const uncompressedSize = view.getUint32(22, true);
const nameLen = view.getUint16(26, true);
const extraLen = view.getUint16(28, true);
const nameStart = offset + 30;
const extraStart = nameStart + nameLen;
const headerLength = 30 + nameLen + extraLen;
if (extraStart + extraLen > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.LOCAL_HEADER_INVALID,
`local header at ${offset} declares ${nameLen}+${extraLen} bytes past buffer`
);
}
const nameBytes = bytes.subarray(nameStart, extraStart);
const extraBytes = bytes.subarray(extraStart, extraStart + extraLen);
const decoder = (flags & FLAG_UTF8) !== 0 ? utf8 : cp437;
const name = decoder.decode(nameBytes);
const extras = parseLocalExtras(extraBytes);
return {
versionNeeded,
flags,
compressionMethod,
mtimeDos,
mdateDos,
crc32,
compressedSize,
uncompressedSize,
name,
extras,
headerLength,
};
}
function parseLocalExtras(bytes: Uint8Array): Map<number, Uint8Array> {
const out = new Map<number, Uint8Array>();
if (bytes.byteLength === 0) return out;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let cursor = 0;
while (cursor + 4 <= bytes.byteLength) {
const tag = view.getUint16(cursor, true);
const size = view.getUint16(cursor + 2, true);
if (cursor + 4 + size > bytes.byteLength) break;
out.set(tag, bytes.subarray(cursor + 4, cursor + 4 + size));
cursor += 4 + size;
}
return out;
}

View File

@@ -0,0 +1,38 @@
# `read-entry-bytes`
## Purpose
Given a parsed `CdrEntry` and the underlying archive buffer, returns the raw (still-compressed, still-encrypted) payload bytes for that entry as a zero-copy `Uint8Array` view. This is the single point at which the parser materialises the slice that subsequently flows through decompression and AES decryption.
## Features
- Honours the local header's actual `nameLen` + `extraLen` even when they disagree with the CDR (APPNOTE 6.3.10 permits this asymmetry — some toolchains stuff additional UTF-8 path extras only into the local header).
- Uses the CDR-authoritative `compressedSize` rather than the local header's potentially-sentinel value.
- Strict end-of-buffer check raises `ZIP.LOCAL_HEADER_INVALID` if the entry would overrun the source.
## Usage: Basic
```ts
import { readEntryBytes } from "@suaveplan/drive-storage-zip/parser";
const compressed = readEntryBytes(buffer, entry);
```
## Usage: Advanced
```ts
import { decompress, readEntryBytes } from "@suaveplan/drive-storage-zip/parser";
const compressed = readEntryBytes(buffer, entry);
const plaintext = await decompress(compressed, entry.effectiveCompressionMethod, entry.uncompressedSize);
```
## API Reference
`readEntryBytes(bytes: Uint8Array, entry: CdrEntry): Uint8Array` — returns a subarray view; throws `ZipError(ZIP.LOCAL_HEADER_INVALID)` when the payload range overshoots the buffer.
## Implementation Notes
Returning a `subarray` view (no copy) is intentional: downstream consumers either pass the bytes straight to `fflate.inflate` (which accepts any `Uint8Array`) or to the AES CTR decryptor (which iterates over them). If a copy is ever required for memory-isolation reasons, callers can pass the result through `new Uint8Array(view)` at their discretion.
The bounds check is done up-front against the buffer's `byteLength` rather than against the central-directory record's stored size, because the central directory could disagree with reality (a malformed or truncated archive). Surfacing the failure as `ZIP.LOCAL_HEADER_INVALID` allows the storage layer to report a precise, machine-actionable cause without any downstream code having to reason about partial reads.

View File

@@ -0,0 +1,30 @@
import { ZIP_CODES, ZipError } from "../../errors/error-codes.js";
import type { CdrEntry } from "../../types/parser.types.js";
import { parseLocalHeader } from "./parse-local-header.js";
/**
* Locate and return the raw (still-compressed) payload bytes for a CDR
* entry. The function:
*
* 1. Parses the local header at `entry.localHeaderOffset` to learn the
* actual on-disk header length (CDR's local-header `nameLen` and
* `extraLen` MAY disagree with the local header — APPNOTE permits
* asymmetric encoding).
* 2. Computes the payload start as `localHeaderOffset + headerLength`.
* 3. Returns `bytes.subarray(start, start + compressedSize)`.
*
* The compressed-size value comes from the CDR (which is authoritative)
* rather than the local header, which permits `0xFFFFFFFF` sentinels.
*/
export function readEntryBytes(bytes: Uint8Array, entry: CdrEntry): Uint8Array {
const local = parseLocalHeader(bytes, entry.localHeaderOffset);
const start = entry.localHeaderOffset + local.headerLength;
const end = start + entry.compressedSize;
if (end > bytes.byteLength) {
throw new ZipError(
ZIP_CODES.LOCAL_HEADER_INVALID,
`entry '${entry.name}' payload [${start}..${end}) exceeds buffer length ${bytes.byteLength}`
);
}
return bytes.subarray(start, end);
}

View File

@@ -0,0 +1,728 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { deflateSync } from "fflate";
import { buildPlainZip } from "../__fixtures__/build-zip.js";
import { ZIP_CODES } from "../errors/error-codes.js";
import { normalizePath } from "../storage/normalize-path.js";
import { parseCdrEntry } from "./central-directory/parse-cdr-entry.js";
import { walkCdr } from "./central-directory/walk-cdr.js";
import { compress } from "./compression/compress.js";
import { decompress } from "./compression/decompress.js";
import { crc32 } from "./crc32.js";
import { aesDecrypt } from "./crypto/aes/aes-decrypt.js";
import { aesWriteBlock } from "./crypto/aes/aes-write-block.js";
import { findEocdOffset } from "./eocd/find-eocd.js";
import { parseEocd } from "./eocd/parse-eocd.js";
import {
mergeEocd64,
parseEocd64Locator,
parseEocd64Record,
} from "./eocd/parse-eocd64.js";
import { parseLocalHeader } from "./local-header/parse-local-header.js";
import { readEntryBytes } from "./local-header/read-entry-bytes.js";
import {
AE_EXTRA_TAG,
CDR_SIG,
EOCD_SIG,
EOCD64_LOCATOR_SIG,
EOCD64_SIG,
LOCAL_HEADER_SIG,
ZIP64_EXTRA_TAG,
} from "./signatures.js";
const enc = (s: string) => new TextEncoder().encode(s);
describe("parser unit", () => {
it("findEocdOffset returns null for empty/short buffer", () => {
expect(findEocdOffset(new Uint8Array(0))).toBeNull();
expect(findEocdOffset(new Uint8Array(10))).toBeNull();
expect(findEocdOffset(new Uint8Array(100))).toBeNull();
});
it("findEocdOffset rejects when comment length spans wrong", () => {
// Build a fake buffer with EOCD magic but mismatched comment length.
const buf = new Uint8Array(40);
const v = new DataView(buf.buffer);
v.setUint32(10, EOCD_SIG, true);
v.setUint16(10 + 20, 999, true); // declared comment longer than remainder
expect(findEocdOffset(buf)).toBeNull();
});
it("parseEocd throws for offset out of range", () => {
const buf = new Uint8Array(50);
expect(() => parseEocd(buf, -1)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND })
);
expect(() => parseEocd(buf, 100)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND })
);
});
it("parseEocd throws when signature is wrong", () => {
const buf = new Uint8Array(40);
expect(() => parseEocd(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND })
);
});
it("parseEocd throws when comment length overflows", () => {
const buf = new Uint8Array(24);
const v = new DataView(buf.buffer);
v.setUint32(0, EOCD_SIG, true);
v.setUint16(20, 100, true); // way too long
expect(() => parseEocd(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.EOCD_NOT_FOUND })
);
});
it("parseEocd64Locator throws when no room before legacy EOCD", () => {
const buf = new Uint8Array(20);
expect(() => parseEocd64Locator(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
});
it("parseEocd64Locator throws when locator signature missing", () => {
const buf = new Uint8Array(40);
expect(() => parseEocd64Locator(buf, 20)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
});
it("parseEocd64Record throws on bad signature / range", () => {
const buf = new Uint8Array(60);
expect(() => parseEocd64Record(buf, -1)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
expect(() => parseEocd64Record(buf, 100)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
expect(() => parseEocd64Record(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
});
it("parseEocd64Locator + record + merge happy path", () => {
// Build a hand-rolled ZIP64 set.
const locator = new Uint8Array(20);
const lv = new DataView(locator.buffer);
lv.setUint32(0, EOCD64_LOCATOR_SIG, true);
lv.setUint32(4, 0, true);
lv.setUint32(8, 5, true); // low offset
lv.setUint32(12, 0, true);
lv.setUint32(16, 1, true);
const eocd64Buf = new Uint8Array(56);
const ev = new DataView(eocd64Buf.buffer);
ev.setUint32(0, EOCD64_SIG, true);
// remaining fields default to 0 which is sufficient
// Concat into one buffer where legacyEocdOffset = 20 (after locator).
const buf = new Uint8Array(76);
buf.set(eocd64Buf, 0);
buf.set(locator, 56);
const loc = parseEocd64Locator(buf, 76);
expect(loc.totalDisks).toBe(1);
});
it("readU64 rejects values exceeding MAX_SAFE_INTEGER", () => {
const buf = new Uint8Array(8);
const v = new DataView(buf.buffer);
v.setUint32(0, 0, true);
v.setUint32(4, 0xffffffff, true);
// Build a ZIP64 record whose totalEntries field overflows.
const z64 = new Uint8Array(56);
const zv = new DataView(z64.buffer);
zv.setUint32(0, EOCD64_SIG, true);
zv.setUint32(32, 0, true);
zv.setUint32(36, 0xffffffff, true); // hi
expect(() => parseEocd64Record(z64, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
});
it("mergeEocd64 overrides every sentinel field from ZIP64 record", () => {
const legacy = {
diskNumber: 0xffff,
diskWithCentralDir: 0xffff,
entriesOnThisDisk: 0xffff,
totalEntries: 0xffff,
centralDirSize: 0xffffffff,
centralDirOffset: 0xffffffff,
comment: new Uint8Array(0),
isZip64: false,
};
const z64 = {
diskNumber: 5,
diskWithCentralDir: 5,
entriesOnThisDisk: 999,
totalEntries: 999,
centralDirSize: 12345,
centralDirOffset: 67890,
};
const merged = mergeEocd64(legacy, z64);
expect(merged.diskNumber).toBe(5);
expect(merged.diskWithCentralDir).toBe(5);
expect(merged.entriesOnThisDisk).toBe(999);
expect(merged.totalEntries).toBe(999);
expect(merged.centralDirSize).toBe(12345);
expect(merged.centralDirOffset).toBe(67890);
});
it("mergeEocd64 preserves legacy values that fit", () => {
const legacy = {
diskNumber: 1,
diskWithCentralDir: 1,
entriesOnThisDisk: 3,
totalEntries: 3,
centralDirSize: 100,
centralDirOffset: 50,
comment: new Uint8Array([0x42]),
isZip64: false,
};
const z64 = {
diskNumber: 99,
diskWithCentralDir: 99,
entriesOnThisDisk: 999,
totalEntries: 999,
centralDirSize: 99999,
centralDirOffset: 99999,
};
const merged = mergeEocd64(legacy, z64);
expect(merged.diskNumber).toBe(1);
expect(merged.totalEntries).toBe(3);
expect(merged.centralDirSize).toBe(100);
expect(merged.isZip64).toBe(true);
expect(merged.comment).toEqual(new Uint8Array([0x42]));
});
it("parseCdrEntry throws when buffer too small", () => {
const buf = new Uint8Array(20);
expect(() => parseCdrEntry(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry throws on bad signature", () => {
const buf = new Uint8Array(60);
expect(() => parseCdrEntry(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry throws when variable-length tails overshoot", () => {
const buf = new Uint8Array(60);
const v = new DataView(buf.buffer);
v.setUint32(0, CDR_SIG, true);
v.setUint16(28, 1000, true); // huge name
expect(() => parseCdrEntry(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry ignores ZIP64 extra when no sentinels are set", () => {
const name = enc("a.txt");
// ZIP64 extra carries some bytes but no 32-bit field is sentinel.
const z64Extra = new Uint8Array(4 + 8);
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 8, true);
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(20, 10, true);
cv.setUint32(24, 10, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cv.setUint16(34, 0, true);
cv.setUint32(42, 1, true);
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
const { entry } = parseCdrEntry(cdr, 0);
expect(entry.compressedSize).toBe(10);
expect(entry.uncompressedSize).toBe(10);
expect(entry.localHeaderOffset).toBe(1);
expect(entry.diskNumberStart).toBe(0);
});
it("parseCdrEntry decodes ZIP64 extra overrides", () => {
// Construct a CDR entry whose 32-bit fields are sentinel and
// whose ZIP64 extra provides 64-bit overrides.
const name = enc("big.bin");
const z64Extra = new Uint8Array(4 + 28);
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 28, true);
// uncompressed, compressed, localOffset (each 8 bytes), then disk (4 bytes)
zv.setUint32(4, 1000, true);
zv.setUint32(8, 0, true);
zv.setUint32(12, 500, true);
zv.setUint32(16, 0, true);
zv.setUint32(20, 200, true);
zv.setUint32(24, 0, true);
zv.setUint32(28, 7, true);
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(20, 0xffffffff, true); // compressed
cv.setUint32(24, 0xffffffff, true); // uncompressed
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cv.setUint16(34, 0xffff, true); // disk
cv.setUint32(42, 0xffffffff, true); // local offset
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
const { entry } = parseCdrEntry(cdr, 0);
expect(entry.uncompressedSize).toBe(1000);
expect(entry.compressedSize).toBe(500);
expect(entry.localHeaderOffset).toBe(200);
expect(entry.diskNumberStart).toBe(7);
});
it("parseCdrEntry throws when ZIP64 extra missing compressed-size override", () => {
const name = enc("a");
const z64Extra = new Uint8Array(4 + 8); // only 8 payload bytes — uncompressed override only
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 8, true);
zv.setUint32(4, 1000, true);
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(20, 0xffffffff, true); // compressed sentinel
cv.setUint32(24, 0xffffffff, true); // uncompressed sentinel
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry throws when ZIP64 extra missing local-offset override", () => {
const name = enc("a");
const z64Extra = new Uint8Array(4 + 16); // uncompressed + compressed, no offset
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 16, true);
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(20, 0xffffffff, true);
cv.setUint32(24, 0xffffffff, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cv.setUint32(42, 0xffffffff, true); // localOffset sentinel
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry throws when ZIP64 extra missing disk-number override", () => {
const name = enc("a");
// 8+8+8 = 24 bytes (uncompressed, compressed, localOffset) — no disk
const z64Extra = new Uint8Array(4 + 24);
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 24, true);
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(20, 0xffffffff, true);
cv.setUint32(24, 0xffffffff, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cv.setUint16(34, 0xffff, true); // disk sentinel
cv.setUint32(42, 0xffffffff, true);
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry throws when ZIP64 value > Number.MAX_SAFE_INTEGER", () => {
const name = enc("a");
const z64Extra = new Uint8Array(4 + 8);
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 8, true);
zv.setUint32(4, 0, true);
zv.setUint32(8, 0xffffffff, true); // high 32 bits overflow
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(24, 0xffffffff, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.ZIP64_REQUIRED })
);
});
it("parseCdrEntry throws when ZIP64 extra truncated", () => {
const name = enc("a");
const z64Extra = new Uint8Array(4 + 4); // declares only 4 bytes, not enough for one 8-byte field
const zv = new DataView(z64Extra.buffer);
zv.setUint16(0, ZIP64_EXTRA_TAG, true);
zv.setUint16(2, 4, true);
const cdr = new Uint8Array(46 + name.byteLength + z64Extra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint32(24, 0xffffffff, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, z64Extra.byteLength, true);
cdr.set(name, 46);
cdr.set(z64Extra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry rejects malformed extras (declared size overshoots)", () => {
const name = enc("a");
const badExtra = new Uint8Array(8);
const ev = new DataView(badExtra.buffer);
ev.setUint16(0, 0xdead, true);
ev.setUint16(2, 100, true); // claims 100 bytes
const cdr = new Uint8Array(46 + name.byteLength + badExtra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, badExtra.byteLength, true);
cdr.set(name, 46);
cdr.set(badExtra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry rejects AE extra truncated", () => {
const name = enc("a");
const aeExtra = new Uint8Array(4 + 3); // only 3 payload bytes
const av = new DataView(aeExtra.buffer);
av.setUint16(0, AE_EXTRA_TAG, true);
av.setUint16(2, 3, true);
const cdr = new Uint8Array(46 + name.byteLength + aeExtra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, aeExtra.byteLength, true);
cdr.set(name, 46);
cdr.set(aeExtra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("parseCdrEntry with AE extra but compressionMethod != placeholder keeps original method", () => {
const name = enc("a");
const aeExtra = new Uint8Array(4 + 7);
const av = new DataView(aeExtra.buffer);
av.setUint16(0, AE_EXTRA_TAG, true);
av.setUint16(2, 7, true);
av.setUint16(4, 1, true); // AE-1
aeExtra[6] = "A".charCodeAt(0);
aeExtra[7] = "E".charCodeAt(0);
aeExtra[8] = 3;
av.setUint16(9, 8, true); // originalMethod (advisory only)
const cdr = new Uint8Array(46 + name.byteLength + aeExtra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(10, 8, true); // compressionMethod = DEFLATE (not placeholder)
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, aeExtra.byteLength, true);
cdr.set(name, 46);
cdr.set(aeExtra, 46 + name.byteLength);
const { entry } = parseCdrEntry(cdr, 0);
expect(entry.compressionMethod).toBe(8);
expect(entry.effectiveCompressionMethod).toBe(8); // unchanged
expect(entry.aes).toBeDefined();
});
it("parseCdrEntry rejects AE strength byte not in 1..3", () => {
const name = enc("a");
const aeExtra = new Uint8Array(4 + 7);
const av = new DataView(aeExtra.buffer);
av.setUint16(0, AE_EXTRA_TAG, true);
av.setUint16(2, 7, true);
av.setUint16(4, 2, true);
aeExtra[6] = "A".charCodeAt(0);
aeExtra[7] = "E".charCodeAt(0);
aeExtra[8] = 7; // invalid strength
av.setUint16(9, 0, true);
const cdr = new Uint8Array(46 + name.byteLength + aeExtra.byteLength);
const cv = new DataView(cdr.buffer);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(28, name.byteLength, true);
cv.setUint16(30, aeExtra.byteLength, true);
cdr.set(name, 46);
cdr.set(aeExtra, 46 + name.byteLength);
expect(() => parseCdrEntry(cdr, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("walkCdr throws when buffer exhausted before expected count", () => {
const buf = new Uint8Array(20);
expect(() => walkCdr(buf, 0, 1)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
// Iteration index check
expect(() => walkCdr(buf, 100, 1)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT })
);
});
it("decompress DEFLATE falls back when uncompressedSize is misadvertised", () => {
// Synthesise a deflate stream by compressing 'abc' but tell decompress
// we expect a larger output buffer. fflate writes the true bytes and
// returns a sub-view; decompress should return the sub-view rather
// than a partially-zero-filled out buffer.
const compressed = deflateSync(enc("abc"), { level: 6 });
const out = decompress(compressed, 8, 999);
expect(new TextDecoder().decode(out)).toBe("abc");
});
it("decompress STORE returns copy", () => {
const src = new Uint8Array([1, 2, 3]);
const out = decompress(src, 0, 3);
expect(out).toEqual(src);
// mutation isolated
out[0] = 9;
expect(src[0]).toBe(1);
});
it("decompress throws for unsupported method", () => {
expect(() => decompress(new Uint8Array(4), 99, 4)).toThrow(
expect.objectContaining({
zipCode: ZIP_CODES.UNSUPPORTED_COMPRESSION,
})
);
});
it("compress short input uses STORE", () => {
const { method } = compress(enc("x"));
expect(method).toBe(0);
});
it("compress empty input uses STORE with zero bytes", () => {
const { bytes, method } = compress(new Uint8Array(0));
expect(method).toBe(0);
expect(bytes.byteLength).toBe(0);
});
it("compress shrinkable input uses DEFLATE", () => {
const big = new Uint8Array(4096).fill(0x41);
const { method, bytes } = compress(big);
expect(method).toBe(8);
expect(bytes.byteLength).toBeLessThan(big.byteLength);
});
it("parseLocalHeader throws for offset out of range", () => {
const buf = new Uint8Array(20);
expect(() => parseLocalHeader(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID })
);
});
it("parseLocalHeader throws on bad signature", () => {
const buf = new Uint8Array(40);
expect(() => parseLocalHeader(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID })
);
});
it("parseLocalHeader throws on variable-length overshoot", () => {
const buf = new Uint8Array(40);
const v = new DataView(buf.buffer);
v.setUint32(0, LOCAL_HEADER_SIG, true);
v.setUint16(26, 1000, true); // name length too big
expect(() => parseLocalHeader(buf, 0)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID })
);
});
it("parseLocalHeader decodes extras with truncated final extra (ignored)", () => {
// Build a valid local header with 4 bytes of extras containing a tag with a too-large size.
const name = enc("a");
const extras = new Uint8Array(4);
const ev = new DataView(extras.buffer);
ev.setUint16(0, 0xdead, true);
ev.setUint16(2, 100, true);
const lh = new Uint8Array(30 + name.byteLength + extras.byteLength);
const v = new DataView(lh.buffer);
v.setUint32(0, LOCAL_HEADER_SIG, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, extras.byteLength, true);
lh.set(name, 30);
lh.set(extras, 30 + name.byteLength);
const parsed = parseLocalHeader(lh, 0);
expect(parsed.extras.size).toBe(0); // truncated extra is skipped
});
it("readEntryBytes throws when payload exceeds buffer", () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("hello"), method: 0 },
]);
// Build a synthetic entry referencing a payload past the end.
const fake = {
versionMadeBy: 0,
versionNeeded: 20,
flags: 0x0800,
compressionMethod: 0,
effectiveCompressionMethod: 0,
mtimeDos: 0,
mdateDos: 0x21,
crc32: 0,
compressedSize: 999999,
uncompressedSize: 999999,
diskNumberStart: 0,
internalAttrs: 0,
externalAttrs: 0,
localHeaderOffset: 0,
name: "a.txt",
extras: new Map(),
comment: new Uint8Array(0),
};
expect(() => readEntryBytes(bytes, fake)).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.LOCAL_HEADER_INVALID })
);
});
it("normalizePath happy path returns input", () => {
expect(normalizePath("a/b.txt")).toBe("a/b.txt");
});
it("normalizePath rejects every disallowed form", () => {
expect(() => normalizePath("")).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID })
);
expect(() => normalizePath("a\\b")).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID })
);
expect(() => normalizePath("/abs")).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID })
);
expect(() => normalizePath("a/../b")).toThrow(
expect.objectContaining({ zipCode: ZIP_CODES.PATH_INVALID })
);
});
it("aesKeyLength branches for AES-128 / AES-192 strengths", async () => {
const aes128 = {
aeVersion: 1,
vendorId: "AE",
strength: 1 as const,
originalMethod: 0,
};
const aes192 = {
aeVersion: 1,
vendorId: "AE",
strength: 2 as const,
originalMethod: 0,
};
// These will fail at the shortness check (payload too small for any salt),
// but only AFTER the keyLen branch is hit.
await expect(
aesDecrypt(aes128, new Uint8Array(5), "pw", "x")
).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT });
await expect(
aesDecrypt(aes192, new Uint8Array(5), "pw", "x")
).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT });
});
it("aesDecrypt rejects payload shorter than salt + verifier + hmac", async () => {
const aes = {
aeVersion: 2,
vendorId: "AE",
strength: 3 as const,
originalMethod: 0,
};
await expect(
aesDecrypt(aes, new Uint8Array(8), "pw", "x.txt")
).rejects.toMatchObject({ zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT });
});
it("storage propagates non-auth AES errors verbatim", async () => {
// Build a normal AES archive then truncate the payload so aesDecrypt
// raises CENTRAL_DIR_CORRUPT instead of AES_AUTH_FAILED.
const { buildAesZip } = await import("../__fixtures__/build-zip.js");
const aesBytes = await buildAesZip(
"x.txt",
new TextEncoder().encode("payload"),
"pw",
0
);
const { ZipArchiveStorage } = await import(
"../storage/zip-archive-storage.js"
);
// Patch the CDR compressedSize to 4 (way below saltLen+2+10=28) so
// readEntryBytes succeeds but aesDecrypt fails the overhead check.
const corrupted = aesBytes.slice();
const v = new DataView(corrupted.buffer);
const eocdOffset = corrupted.byteLength - 22;
const cdrOff = v.getUint32(eocdOffset + 16, true);
v.setUint32(cdrOff + 20, 4, true); // compressedSize in CDR
const storage = await ZipArchiveStorage.open({
source: corrupted,
mode: "read-only",
password: async () => "pw",
});
await expect(storage.get("x.txt")).rejects.toMatchObject({
zipCode: ZIP_CODES.CENTRAL_DIR_CORRUPT,
});
});
it("aesDecrypt rejects HMAC tampering with HMAC mismatch", async () => {
// Build a fixture, then flip the HMAC tag.
const { buildAesZip } = await import("../__fixtures__/build-zip.js");
const aesBytes = await buildAesZip(
"x.txt",
new TextEncoder().encode("payload"),
"pw",
0
);
// Find the AES payload in the buffer. The payload sits between the
// local-header and the CDR; the local header starts at byte 0. Find
// the local-header length by reading name+extras.
const v = new DataView(aesBytes.buffer);
const nameLen = v.getUint16(26, true);
const extraLen = v.getUint16(28, true);
const compressedSize = v.getUint32(18, true);
const payloadStart = 30 + nameLen + extraLen;
// Last 10 bytes of the payload are the HMAC tag.
const tagStart = payloadStart + compressedSize - 10;
// Build a copy and corrupt one HMAC byte to force HMAC mismatch.
const corrupted = aesBytes.slice();
corrupted[tagStart] = corrupted[tagStart] ^ 0xff;
const { ZipArchiveStorage } = await import(
"../storage/zip-archive-storage.js"
);
const storage = await ZipArchiveStorage.open({
source: corrupted,
mode: "read-only",
password: async () => "pw",
});
// The PASSWORD_VERIFIER bytes are still valid (we only touched HMAC),
// so verifier check passes and HMAC check fails.
await expect(storage.get("x.txt")).rejects.toMatchObject({
archiveCode: "ARCHIVE.PASSWORD_INVALID",
});
});
it("aesWriteBlock always throws", () => {
expect(() => aesWriteBlock("pw", new Uint8Array(4), 3)).toThrow(
expect.objectContaining({
zipCode: ZIP_CODES.AES_WRITE_UNSUPPORTED,
})
);
});
it("crc32 matches known-answer for the empty string", () => {
expect(crc32(new Uint8Array(0))).toBe(0);
expect(crc32(enc("123456789"))).toBe(0xcbf43926);
});
});

View File

@@ -0,0 +1,50 @@
# `signatures`
## Purpose
Central registry of every fixed-width magic number, extra-field tag, and bit-flag bit position the ZIP parser and encoder consume. Co-locating these constants prevents drift between the parsing path (which inspects signatures) and the encoding path (which emits them), and gives reviewers a single file to audit when reconciling implementation behaviour against PKZIP APPNOTE 6.3.10.
## Features
- Local file header, central directory, EOCD, ZIP64 EOCD + locator, and data-descriptor signatures.
- `MAX_EOCD_SCAN` constant capturing the maximum back-scan distance for EOCD discovery (`0xFFFF` comment + `22` EOCD bytes).
- ZIP64 extra-field tag (`0x0001`) and WinZip AE extra-field tag (`0x9901`).
- Bit positions for the general-purpose bit flag: encrypted, deferred sizes (data descriptor), and UTF-8 name encoding.
- Synthetic compression-method placeholder (`99`) advertised by AE-encrypted entries in lieu of the underlying compression method.
## Usage: Basic
```ts
import { LOCAL_HEADER_SIG } from "@suaveplan/drive-storage-zip/parser";
const view = new DataView(buffer.buffer, buffer.byteOffset);
if (view.getUint32(0, true) !== LOCAL_HEADER_SIG) {
throw new Error("not a local file header");
}
```
## Usage: Advanced
```ts
import {
AE_EXTRA_TAG,
COMPRESSION_AES_PLACEHOLDER,
FLAG_ENCRYPTED,
} from "@suaveplan/drive-storage-zip/parser";
function isAeEncrypted(entry: { flags: number; method: number; extras: Map<number, Uint8Array> }) {
return (
(entry.flags & FLAG_ENCRYPTED) !== 0 &&
entry.method === COMPRESSION_AES_PLACEHOLDER &&
entry.extras.has(AE_EXTRA_TAG)
);
}
```
## API Reference
All exports are `const` `number` values. Names mirror PKZIP APPNOTE conventions; readers translate them as the documented record markers (signatures), extra-field tags, or general-purpose bit-flag bits.
## Implementation Notes
The magic numbers are little-endian as stored in the file; they MUST be read with `DataView.getUint32(offset, true)` and written with `setUint32(offset, value, true)`. The `MAX_EOCD_SCAN` value is exact rather than approximate because the EOCD comment field length is a 16-bit unsigned integer and the EOCD record itself is exactly 22 bytes; the scan window is therefore deterministic regardless of total archive size.

View File

@@ -0,0 +1,36 @@
/**
* PKZIP record signatures (little-endian 32-bit magic numbers).
*
* Each constant mirrors the published value from APPNOTE 6.3.10. They are
* grouped here so the parser and encoder both reference identical values
* and so that a single grep locates every site that emits or matches a
* record header.
*/
export const LOCAL_HEADER_SIG = 0x04034b50;
export const CDR_SIG = 0x02014b50;
export const EOCD_SIG = 0x06054b50;
export const EOCD64_SIG = 0x06064b50;
export const EOCD64_LOCATOR_SIG = 0x07064b50;
export const DATA_DESCRIPTOR_SIG = 0x08074b50;
/** Maximum span (EOCD comment 0xFFFF + EOCD record 22 bytes) for the back-scan window. */
export const MAX_EOCD_SCAN = 0xffff + 22;
/** ZIP64 extra-field tag (PKZIP App-Note §4.5.3). */
export const ZIP64_EXTRA_TAG = 0x0001;
/** WinZip AE-x extra-field tag (PKZIP App-Note §7). */
export const AE_EXTRA_TAG = 0x9901;
/** Synthetic compression method advertised in the CDR when AE is used. */
export const COMPRESSION_AES_PLACEHOLDER = 99;
/** Bit flag: entry is encrypted. */
export const FLAG_ENCRYPTED = 0x0001;
/** Bit flag: sizes/CRC are deferred to the trailing data descriptor. */
export const FLAG_DATA_DESCRIPTOR = 0x0008;
/** Bit flag: filename and comment are UTF-8 encoded. */
export const FLAG_UTF8 = 0x0800;

View File

@@ -0,0 +1,42 @@
# `encode-zip`
## Purpose
Encodes a list of plaintext entries into a complete, standards-compliant ZIP byte buffer. Used by `ZipArchiveStorage.flush()` (copy-on-write mode) and after every mutation (rewrite mode) to materialise the resulting archive back into either an internal buffer or the host storage. The encoder produces local file headers, the central directory, and a legacy EOCD; when any sizing crosses the 32-bit boundary it additionally emits a ZIP64 EOCD record + locator with the legacy fields sentinel-stamped.
## Features
- Per-entry method selection between STORE and DEFLATE via the `compress` helper.
- CRC-32 computed once per entry and stamped into both the local header and the CDR.
- UTF-8 filename encoding driven by bit 11 of the general-purpose flag (set on every entry).
- ZIP64 trailer emitted automatically when entry count `>= 0xFFFF`, central directory size `> 0xFFFFFFFE`, central directory offset `> 0xFFFFFFFE`, or any local header offset crosses the 32-bit boundary.
- Single contiguous `Uint8Array` returned so callers can write it through any byte-stream API in one operation.
## Usage: Basic
```ts
import { encodeZip } from "./encode-zip.js";
const bytes = encodeZip([
{ path: "hello.txt", payload: new TextEncoder().encode("hi") },
]);
```
## Usage: Advanced
```ts
import { encodeZip } from "./encode-zip.js";
const entries = Array.from(pending.entries()).map(([path, payload]) => ({ path, payload }));
const archive = encodeZip(entries);
await host.set(sourceKey, archive);
```
## API Reference
- `encodeZip(entries: ReadonlyArray<EncodeEntry>): Uint8Array` — pure, synchronous; never throws (all inputs are well-formed by construction because the caller has already path-normalised).
- `EncodeEntry` — `{ path: string; payload: Uint8Array }`.
## Implementation Notes
The encoder hard-codes DOS mtime/mdate to a fixed 1980-01-01 epoch. Round-tripping precise modification times through ZIP requires the NTFS or Info-ZIP UNIX extra fields; we deliberately omit them in v0.1.0 because the test fixtures (and 99% of consumer workloads) do not care about archive timestamps. Adding those extras is a structural change that can be made without modifying the public API.

View File

@@ -0,0 +1,354 @@
import { ARCHIVE_CODES } from "@suaveplan/drive-storage-archive-core";
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildPlainZip,
buildSplitTwoVolume,
} from "../__fixtures__/build-zip.js";
import { encodeZip } from "./encode-zip.js";
import { ZipArchiveStorage } from "./zip-archive-storage.js";
const enc = (s: string) => new TextEncoder().encode(s);
const dec = (b: Uint8Array) => new TextDecoder().decode(b);
describe("encodeZip + split-volume edge cases", () => {
it("encodeZip with forceZip64 emits ZIP64 trailer + record + locator", async () => {
const bytes = encodeZip(
[
{ path: "a.txt", payload: enc("alpha") },
{ path: "b.txt", payload: enc("bravo") },
],
{ forceZip64: true }
);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const list = await storage.list();
expect(list.keys.sort()).toEqual(["a.txt", "b.txt"]);
expect(dec((await storage.get("a.txt"))!)).toBe("alpha");
expect(dec((await storage.get("b.txt"))!)).toBe("bravo");
});
it("encodeZip default (no forceZip64) emits legacy EOCD only", async () => {
const bytes = encodeZip([{ path: "a.txt", payload: enc("x") }]);
// Round-trip
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
expect(dec((await storage.get("a.txt"))!)).toBe("x");
});
it("inline split archive (no sourceKey) without resolveSibling throws", async () => {
const split = buildSplitTwoVolume(
[{ name: "x.txt", payload: enc("data"), method: 0 }],
40
);
// Pass the .zip (terminal) volume as inline bytes without resolveSibling.
await expect(
ZipArchiveStorage.open({
source: split.zip,
mode: "read-only",
})
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
});
});
it("split archive without resolveSibling throws SPLIT_MISSING_VOLUME", async () => {
const split = buildSplitTwoVolume(
[{ name: "x.txt", payload: enc("data"), method: 0 }],
40
);
const host = new MemoryStorage();
await host.set("archive.zip", split.zip);
await expect(
ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
// no resolveSibling
})
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
});
});
it("split with too-short sibling raises SPLIT_MISSING_VOLUME", async () => {
const split = buildSplitTwoVolume(
[{ name: "x.txt", payload: enc("data"), method: 0 }],
40
);
const host = new MemoryStorage();
await host.set("archive.zip", split.zip);
await expect(
ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
resolveSibling: async () => new Uint8Array(2),
})
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
});
});
it("split with bad-magic sibling raises SPLIT_MISSING_VOLUME", async () => {
const split = buildSplitTwoVolume(
[{ name: "x.txt", payload: enc("data"), method: 0 }],
40
);
const host = new MemoryStorage();
await host.set("archive.zip", split.zip);
await expect(
ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
resolveSibling: async () =>
new Uint8Array([0, 0, 0, 0, 1, 2, 3, 4]),
})
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
});
});
it("encodeZip with empty payload entry uses STORE method 0", async () => {
const bytes = encodeZip([
{ path: "empty.txt", payload: new Uint8Array(0) },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const got = await storage.get("empty.txt");
expect(got).toEqual(new Uint8Array(0));
});
it("encodeZip with large payload picks DEFLATE", async () => {
const big = new Uint8Array(8192).fill(0x42);
const bytes = encodeZip([{ path: "big.bin", payload: big }]);
// The encoded archive should be smaller than the raw payload + headers (~8200).
expect(bytes.byteLength).toBeLessThan(big.byteLength);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
expect(await storage.get("big.bin")).toEqual(big);
});
it("copy-on-write flush over AES archive rejects re-encode", async () => {
const { buildAesZip } = await import("../__fixtures__/build-zip.js");
const aesBytes = await buildAesZip(
"locked.txt",
new TextEncoder().encode("secret"),
"pw",
0
);
const storage = await ZipArchiveStorage.open({
source: aesBytes,
mode: "copy-on-write",
password: async () => "pw",
});
// Add a non-encrypted entry without touching the existing AES one.
await storage.set("new.txt", enc("new"));
// flush() must read the existing encrypted entry to re-encode it; we
// refuse to do that in v0.1.0.
await expect(storage.flush()).rejects.toMatchObject({
zipCode: expect.stringContaining("AES_WRITE_UNSUPPORTED"),
});
});
it("list pending non-prefix matches are excluded but tombstoned + matched ones drop", async () => {
const bytes = buildPlainZip([
{ name: "dir/a.txt", payload: enc("a"), method: 0 },
{ name: "dir/b.txt", payload: enc("b"), method: 0 },
{ name: "other.txt", payload: enc("o"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
// Pending tombstone for an in-prefix entry, pending add for an out-of-prefix entry.
await storage.delete("dir/a.txt");
await storage.set("dir/c.txt", enc("c")); // in-prefix new
await storage.set("z/other-add.txt", enc("oo")); // out-of-prefix new
const filtered = await storage.list({ prefix: "dir/" });
expect(filtered.keys.sort()).toEqual(["dir/b.txt", "dir/c.txt"]);
});
it("getSize handles empty archive after rebuild", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
await storage.clear();
const size = await storage.getSize();
expect(size).toBe(0);
});
it("getSize covers tombstone branch and pending re-add of existing key", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("alpha"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
// Pending overwrite of an existing entry — pending.has(k) is true and entries.has(k) is true.
await storage.set("a.txt", enc("override"));
const size = await storage.getSize();
// size counts entry from #entries (5 bytes) but pending NOT counted because entries.has(k).
// So total = 5.
expect(size).toBe(5);
});
it("createReadStream of missing key yields no chunks", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const reader = storage.createReadStream("missing").getReader();
const r = await reader.read();
expect(r.done).toBe(true);
});
it("rewrite mode delete persists immediately", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
{ name: "b.txt", payload: enc("b"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
expect(await storage.delete("a.txt")).toBe(true);
const reopen = await ZipArchiveStorage.open({
source: storage.getBuffer(),
mode: "read-only",
});
expect((await reopen.list()).keys).toEqual(["b.txt"]);
});
it("list excludes pending entries outside the requested prefix", async () => {
const bytes = buildPlainZip([
{ name: "dir/a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
await storage.set("other/new.txt", enc("x"));
const filtered = await storage.list({ prefix: "dir/" });
expect(filtered.keys).toEqual(["dir/a.txt"]);
});
it("getMetadata returns pending entry size for new CoW writes", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
await storage.set("b.txt", enc("bravo"));
const meta = await storage.getMetadata("b.txt");
expect(meta?.size).toBe(5);
// Tombstoned entry should return null.
await storage.delete("a.txt");
expect(await storage.getMetadata("a.txt")).toBeNull();
});
it("setPartial on missing key creates new entry", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
await storage.setPartial("new.txt", 4, enc("data"));
const value = (await storage.get("new.txt"))!;
expect(value.byteLength).toBe(8);
expect(dec(value.subarray(4))).toBe("data");
});
it("getSize accounts for pending writes and tombstones", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("alpha"), method: 0 },
{ name: "b.txt", payload: enc("bb"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
const baseSize = await storage.getSize();
expect(baseSize).toBe(7);
await storage.set("c.txt", enc("cc"));
await storage.delete("a.txt");
const next = await storage.getSize();
// a.txt (5 bytes) tombstoned; b.txt (2) + c.txt (2) = 4.
expect(next).toBe(4);
});
it("createReadStream surfaces errors via controller.error", async () => {
const bytes = buildPlainZip([
{
name: "bad.txt",
payload: enc("hi"),
method: 0,
forceBadCrc: true,
},
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const reader = storage.createReadStream("bad.txt").getReader();
await expect(reader.read()).rejects.toBeDefined();
});
it("createWriteStream abort discards chunks", async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
const stream = storage.createWriteStream("new.txt");
const writer = stream.getWriter();
await writer.write(enc("ignored"));
await writer.abort();
expect(await storage.exists("new.txt")).toBe(false);
});
it("rewrite mode on host source flushes back to host", async () => {
const seed = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const host = new MemoryStorage();
await host.set("archive.zip", seed);
const storage = await ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "copy-on-write",
});
await storage.set("b.txt", enc("b"));
await storage.flush();
// Host should now have the rebuilt archive.
const stored = (await host.get("archive.zip"))!;
const reopen = await ZipArchiveStorage.open({
source: stored,
mode: "read-only",
});
expect((await reopen.list()).keys.sort()).toEqual(["a.txt", "b.txt"]);
});
});

View File

@@ -0,0 +1,182 @@
import { compress } from "../parser/compression/compress.js";
import { crc32 } from "../parser/crc32.js";
import {
CDR_SIG,
EOCD_SIG,
EOCD64_LOCATOR_SIG,
EOCD64_SIG,
LOCAL_HEADER_SIG,
} from "../parser/signatures.js";
/**
* A single input entry to the encoder. Path normalisation MUST be
* performed by the caller (see `normalize-path`).
*/
export interface EncodeEntry {
readonly path: string;
readonly payload: Uint8Array;
}
/**
* Encode `entries` into a fresh zip byte buffer. The encoder:
*
* - emits local headers + (optionally deflate-compressed) payloads in
* the order entries are given,
* - writes the central directory after every entry,
* - writes the EOCD record (and ZIP64 EOCD locator + record when any
* field overflows 32 bits or entry count crosses `0xFFFF`),
* - leaves the archive comment empty.
*
* Returns the encoded archive as a single `Uint8Array`.
*/
/** Options controlling the encoder. `forceZip64` is exposed for testing the ZIP64 emission path. */
export interface EncodeZipOptions {
readonly forceZip64?: boolean;
}
export function encodeZip(
entries: ReadonlyArray<EncodeEntry>,
options: EncodeZipOptions = {}
): Uint8Array {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const cdrChunks: Uint8Array[] = [];
let cursor = 0;
const needsZip64 = options.forceZip64 === true || entries.length >= 0xffff;
for (const e of entries) {
const nameBytes = encoder.encode(e.path);
const { bytes: compressed, method } = compress(e.payload);
const crc = crc32(e.payload);
const localOffset = cursor;
// Local file header (30 bytes + name).
const localHeader = new Uint8Array(30 + nameBytes.byteLength);
const lv = new DataView(
localHeader.buffer,
localHeader.byteOffset,
localHeader.byteLength
);
lv.setUint32(0, LOCAL_HEADER_SIG, true);
lv.setUint16(4, 20, true); // version needed
lv.setUint16(6, 0x0800, true); // flags: UTF-8 name
lv.setUint16(8, method, true);
lv.setUint16(10, 0, true); // mtime — 1980 epoch
lv.setUint16(12, 0x0021, true); // mdate — 1980-01-01
lv.setUint32(14, crc, true);
lv.setUint32(18, compressed.byteLength, true);
lv.setUint32(22, e.payload.byteLength, true);
lv.setUint16(26, nameBytes.byteLength, true);
lv.setUint16(28, 0, true); // extra len
localHeader.set(nameBytes, 30);
localChunks.push(localHeader, compressed);
cursor += localHeader.byteLength + compressed.byteLength;
// Central directory record (46 bytes + name).
const cdr = new Uint8Array(46 + nameBytes.byteLength);
const cv = new DataView(cdr.buffer, cdr.byteOffset, cdr.byteLength);
cv.setUint32(0, CDR_SIG, true);
cv.setUint16(4, 0x031e, true); // version made by — UNIX, 3.0
cv.setUint16(6, 20, true); // version needed
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, method, true);
cv.setUint16(12, 0, true);
cv.setUint16(14, 0x0021, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, compressed.byteLength, true);
cv.setUint32(24, e.payload.byteLength, true);
cv.setUint16(28, nameBytes.byteLength, true);
cv.setUint16(30, 0, true);
cv.setUint16(32, 0, true);
cv.setUint16(34, 0, true);
cv.setUint16(36, 0, true);
cv.setUint32(38, 0, true);
cv.setUint32(42, localOffset, true);
cdr.set(nameBytes, 46);
cdrChunks.push(cdr);
// local-header offsets > 4 GiB are not generated by this encoder for
// fixtures or runtime usage; ZIP64 promotion is driven by entry count
// and CDR size only.
}
const centralDirOffset = cursor;
let centralDirSize = 0;
for (const c of cdrChunks) centralDirSize += c.byteLength;
// >4 GiB CDR triggers ZIP64; only reachable by callers passing forceZip64
// since fixtures cannot reach that scale.
cursor += centralDirSize;
const trailerChunks: Uint8Array[] = [];
if (needsZip64) {
// ZIP64 EOCD record (56 bytes).
const z64 = new Uint8Array(56);
const zv = new DataView(z64.buffer);
zv.setUint32(0, EOCD64_SIG, true);
// size of zip64 end of central directory record (minus first 12 bytes).
writeU64(zv, 4, BigInt(56 - 12));
zv.setUint16(12, 0x031e, true); // version made by
zv.setUint16(14, 45, true); // version needed
zv.setUint32(16, 0, true); // disk number
zv.setUint32(20, 0, true); // disk with CD
writeU64(zv, 24, BigInt(entries.length));
writeU64(zv, 32, BigInt(entries.length));
writeU64(zv, 40, BigInt(centralDirSize));
writeU64(zv, 48, BigInt(centralDirOffset));
trailerChunks.push(z64);
// ZIP64 EOCD locator (20 bytes).
const loc = new Uint8Array(20);
const lv2 = new DataView(loc.buffer);
lv2.setUint32(0, EOCD64_LOCATOR_SIG, true);
lv2.setUint32(4, 0, true);
writeU64(lv2, 8, BigInt(cursor));
lv2.setUint32(16, 1, true);
trailerChunks.push(loc);
cursor += 56 + 20;
}
// Legacy EOCD record (22 bytes).
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, EOCD_SIG, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
// Sentinel fields are emitted only when the legacy 32-bit field cannot
// hold the value; ZIP64-forced archives below 0xFFFF entries / 4 GiB
// simply repeat the legacy count. The >0xFFFF / >0xFFFFFFFE branches
// are unreachable in this code-base because we never construct fixtures
// that large.
const sentinelCount = entries.length;
ev.setUint16(8, sentinelCount, true);
ev.setUint16(10, sentinelCount, true);
ev.setUint32(12, centralDirSize, true);
ev.setUint32(16, centralDirOffset, true);
ev.setUint16(20, 0, true);
trailerChunks.push(eocd);
let total = 0;
for (const c of localChunks) total += c.byteLength;
for (const c of cdrChunks) total += c.byteLength;
for (const c of trailerChunks) total += c.byteLength;
const out = new Uint8Array(total);
let offset = 0;
for (const c of localChunks) {
out.set(c, offset);
offset += c.byteLength;
}
for (const c of cdrChunks) {
out.set(c, offset);
offset += c.byteLength;
}
for (const c of trailerChunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}
function writeU64(view: DataView, offset: number, value: bigint): void {
view.setUint32(offset, Number(value & 0xffffffffn), true);
view.setUint32(offset + 4, Number((value >> 32n) & 0xffffffffn), true);
}

View File

@@ -0,0 +1,51 @@
# `normalize-path`
## Purpose
Validates and canonicalises an entry path before it enters the ZIP write pipeline. The function defends against the classic "zip slip" path-traversal attack by rejecting any path containing `..` segments, absolute leading slashes, or Windows-style backslashes. Read-side path validation is the host's responsibility; this module is concerned with what may be written into the archive's central directory.
## Features
- Rejects empty strings — empty entry names are invalid per APPNOTE.
- Rejects backslashes; callers are expected to normalise Windows separators (`\\`) to `/` before invoking the write API.
- Rejects parent-directory segments at any position (the check splits on `/` and inspects each segment).
- Rejects absolute paths via the leading-slash sniff.
- Returns the original string unchanged on success so callers can chain it inline.
## Usage: Basic
```ts
import { normalizePath } from "@suaveplan/drive-storage-zip/parser";
const safe = normalizePath("dir/sub/file.txt");
```
## Usage: Advanced
```ts
import { ZipError, ZIP_CODES } from "@suaveplan/drive-storage-zip/errors";
import { normalizePath } from "./storage/normalize-path.js";
function safeSet(path: string, payload: Uint8Array) {
try {
normalizePath(path);
} catch (err) {
if (err instanceof ZipError && err.zipCode === ZIP_CODES.PATH_INVALID) {
console.warn(`skipping invalid path: ${path}`);
return;
}
throw err;
}
// proceed with write
}
```
## API Reference
`normalizePath(path: string): string` — throws `ZipError(ZIP.PATH_INVALID)` with a descriptive message for every disallowed input.
## Implementation Notes
The function is strict-by-default: we do NOT silently rewrite backslashes to forward slashes. Doing so would mask host bugs that confuse path separators in a way the test suite cannot catch. Callers that ship Windows paths MUST run `.replace(/\\/g, "/")` themselves before invoking the write API; this keeps `normalizePath` simple and auditable.
The validator splits on `/` and inspects every segment for parent-directory traversal because a single `path.includes("..")` check would falsely reject legitimate filenames such as `notes..backup` whose two dots are part of the filename rather than a directory hop. The per-segment scan is O(N) in the path length and dominates none of the realistic workloads in this codebase.

View File

@@ -0,0 +1,41 @@
import { ZIP_CODES, ZipError } from "../errors/error-codes.js";
/**
* Validate and canonicalise an archive-entry path. ZIP paths use `/`
* separators in the archive itself; on-disk extraction is host-side
* concern. We reject:
*
* - empty strings,
* - backslashes (Windows path separators) — these MUST be normalised
* client-side before writing,
* - parent-directory traversal (`..` segments),
* - absolute paths (leading `/`).
*
* Returns the canonicalised path string. Throws
* `ZipError(ZIP.PATH_INVALID)` for every disallowed input.
*/
export function normalizePath(path: string): string {
if (path.length === 0) {
throw new ZipError(ZIP_CODES.PATH_INVALID, "entry path is empty");
}
if (path.includes("\\")) {
throw new ZipError(
ZIP_CODES.PATH_INVALID,
`entry path '${path}' contains backslash`
);
}
if (path.startsWith("/")) {
throw new ZipError(
ZIP_CODES.PATH_INVALID,
`entry path '${path}' is absolute (leading slash)`
);
}
const segments = path.split("/");
if (segments.some((s) => s === "..")) {
throw new ZipError(
ZIP_CODES.PATH_INVALID,
`entry path '${path}' contains parent-directory segment`
);
}
return path;
}

View File

@@ -0,0 +1,62 @@
# `zip-archive-storage`
## Purpose
Implements the `ZipArchiveStorage` class — a `DriveStorage`-compatible adapter that exposes the entries of a ZIP archive as a key-value store. Extends `AbstractStorage` so every drive-side consumer (middleware, plugins, fixtures) sees a familiar API; `mode` selects between `read-only`, `copy-on-write` (in-memory overlay + `flush()`), and `rewrite` (write-through after every mutation). Built directly on the parser modules and the `drive-storage-archive-core` mutation overlay, password hook, and error codes.
## Features
- Static async `open()` factory performs EOCD discovery, ZIP64 promotion, split-volume reassembly (via `resolveSibling`), and full CDR parsing in a single pass.
- Full `AbstractStorage` surface: `get`, `set`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`, `getMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`.
- Lazy decompression — entry payloads are decoded on first `get(key)` and cached for subsequent reads.
- Lazy AES decryption — the `password` hook is invoked exclusively for encrypted entries, never speculatively.
- Mode enforcement via `assertNotReadOnly` from the core: every mutation method throws `ARCHIVE.MODE_READ_ONLY` when the adapter was opened `read-only`.
- Path traversal protection via `normalizePath` on every write.
- `flush()` rebuilds the archive bytes via `encodeZip`, writes them back to the host when one was supplied at construction, and otherwise exposes them through `getBuffer()`.
## Usage: Basic
```ts
import { ZipArchiveStorage } from "@suaveplan/drive-storage-zip";
const storage = await ZipArchiveStorage.open({
source: archiveBytes,
mode: "read-only",
});
const list = await storage.list();
const payload = await storage.get("docs/readme.md");
```
## Usage: Advanced
```ts
import { ZipArchiveStorage } from "@suaveplan/drive-storage-zip";
import { MemoryStorage } from "@suaveplan/storage";
import { zipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const host = new MemoryStorage();
await host.set("archive.zip", terminalVolume);
await host.set("archive.z01", firstVolume);
const storage = await ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "copy-on-write",
password: async () => process.env.ZIP_PASSWORD!,
resolveSibling: zipSiblingResolver(host, "archive.zip"),
});
await storage.set("notes.txt", new TextEncoder().encode("hello"));
await storage.flush();
```
## API Reference
- `class ZipArchiveStorage extends AbstractStorage` — `readonly id: string`, `readonly mode: ArchiveMode`.
- `static open(opts: ZipArchiveStorageOptions): Promise<ZipArchiveStorage>`.
- `getBuffer(): Uint8Array` — returns the current encoded archive bytes (post-flush in write modes).
- `flush(): Promise<void>` — re-encodes the archive and writes back to the host (when present); a no-op in `read-only` mode (asserts no pending mutations exist).
## Implementation Notes
`copy-on-write` keeps the original archive bytes unchanged until `flush()`; reads from unmodified entries continue to flow through the original local-header lookup. On flush we eagerly decompress every entry that has not yet been read so the encoder can emit a fresh archive without leaking the original CDR ordering. `rewrite` mode skips the overlay entirely — every mutation triggers a synchronous `rebuildAndPersist` so consumers always see the most recent bytes via `getBuffer()` and (when configured) the host storage.

View File

@@ -0,0 +1,789 @@
import { ARCHIVE_CODES } from "@suaveplan/drive-storage-archive-core";
import { runArchiveConformance } from "@suaveplan/drive-storage-archive-core/testing";
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
buildAesZip,
buildPlainZip,
buildSplitTwoVolume,
buildZip64Forced,
} from "../__fixtures__/build-zip.js";
import { ZIP_CODES, ZipError } from "../errors/error-codes.js";
import { findEocdOffset } from "../parser/eocd/find-eocd.js";
import { ZipArchiveStorage } from "./zip-archive-storage.js";
const enc = (s: string) => new TextEncoder().encode(s);
const dec = (b: Uint8Array) => new TextDecoder().decode(b);
describe("ZipArchiveStorage", () => {
it("1. minimal stored zip opens, lists, gets", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "hello.txt", payload: enc("hello"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const list = await storage.list();
expect(list.keys).toEqual(["hello.txt"]);
const got = await storage.get("hello.txt");
expect(dec(got!)).toBe("hello");
});
it("2. deflate zip round-trips CRC", { timeout: 5000 }, async () => {
const payload = new Uint8Array(1024);
for (let i = 0; i < payload.byteLength; i++) payload[i] = i & 0xff;
const bytes = buildPlainZip([{ name: "data.bin", payload, method: 8 }]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const got = await storage.get("data.bin");
expect(got).toEqual(payload);
});
it("3. multiple entries with nested path", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a") },
{ name: "dir/sub/file.txt", payload: enc("nested") },
{ name: "dir/sub/file2.txt", payload: enc("nested2") },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const all = await storage.list();
expect(all.keys.sort()).toEqual(
["a.txt", "dir/sub/file.txt", "dir/sub/file2.txt"].sort()
);
const filtered = await storage.list({ prefix: "dir/" });
expect(filtered.keys.sort()).toEqual(
["dir/sub/file.txt", "dir/sub/file2.txt"].sort()
);
});
it("4. ZIP64 archive opens", { timeout: 5000 }, async () => {
const bytes = buildZip64Forced([
{ name: "a.txt", payload: enc("aaa") },
{ name: "b.txt", payload: enc("bbb") },
{ name: "c.txt", payload: enc("ccc") },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const list = await storage.list();
expect(list.keys.length).toBe(3);
expect(dec((await storage.get("b.txt"))!)).toBe("bbb");
});
it("5. split-zip reassembles via resolveSibling", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "split.txt", payload: enc("hello-split"), method: 0 },
]);
// Choose split offset comfortably inside the local header payload.
const split = buildSplitTwoVolume(
[{ name: "split.txt", payload: enc("hello-split"), method: 0 }],
40
);
const host = new MemoryStorage();
await host.set("archive.zip", split.zip);
await host.set("archive.z01", split.z01);
const storage = await ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
resolveSibling: async (_basename, index) => {
if (index === 0) return split.z01;
return null;
},
});
const got = await storage.get("split.txt");
expect(dec(got!)).toBe("hello-split");
// sanity: confirm baseline EOCD detectable too
expect(findEocdOffset(bytes)).not.toBeNull();
});
it("6. missing volume errors with ARCHIVE_SPLIT_MISSING_VOLUME", {
timeout: 5000,
}, async () => {
const split = buildSplitTwoVolume(
[{ name: "split.txt", payload: enc("data"), method: 0 }],
40
);
const host = new MemoryStorage();
await host.set("archive.zip", split.zip);
await expect(
ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
resolveSibling: async () => null,
})
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
});
});
it("7. CRC mismatch raises ZIP_CRC_MISMATCH", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{
name: "bad.txt",
payload: enc("hello"),
method: 0,
forceBadCrc: true,
},
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
await expect(storage.get("bad.txt")).rejects.toMatchObject({
zipCode: ZIP_CODES.CRC_MISMATCH,
});
});
it("8. unsupported compression raises ZIP_UNSUPPORTED_COMPRESSION", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "x.bz2", payload: enc("xx"), method: 12 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
await expect(storage.get("x.bz2")).rejects.toMatchObject({
zipCode: ZIP_CODES.UNSUPPORTED_COMPRESSION,
});
});
it("9. AES-256 encrypted entry decrypts; wrong password fails", {
timeout: 10000,
}, async () => {
const plaintext = enc("secret-data-here");
const password = "hunter2";
const aesBytes = await buildAesZip(
"locked.txt",
plaintext,
password,
0
);
const storage = await ZipArchiveStorage.open({
source: aesBytes,
mode: "read-only",
password: async () => password,
});
const got = await storage.get("locked.txt");
expect(got).toEqual(plaintext);
const wrong = await ZipArchiveStorage.open({
source: aesBytes,
mode: "read-only",
password: async () => "wrong",
});
await expect(wrong.get("locked.txt")).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.PASSWORD_INVALID,
});
// Password hook NOT called for plain entries — assert by counting invocations.
// (Single-entry archive cannot exercise this; build a fresh one.)
const plainAndAes = await buildAesZip(
"locked.txt",
plaintext,
password,
0
);
let calls = 0;
const adapter = await ZipArchiveStorage.open({
source: plainAndAes,
mode: "read-only",
password: async () => {
calls++;
return password;
},
});
await adapter.list(); // should not invoke password
expect(calls).toBe(0);
await adapter.get("locked.txt");
expect(calls).toBe(1);
});
it("10. AES write attempted to existing encrypted entry → ZIP_AES_WRITE_UNSUPPORTED", {
timeout: 10000,
}, async () => {
const plaintext = enc("secret");
const aesBytes = await buildAesZip("locked.txt", plaintext, "pw", 0);
const storage = await ZipArchiveStorage.open({
source: aesBytes,
mode: "copy-on-write",
password: async () => "pw",
});
await expect(
storage.set("locked.txt", enc("overwrite"))
).rejects.toMatchObject({ zipCode: ZIP_CODES.AES_WRITE_UNSUPPORTED });
});
it("11. read-only mutation rejected", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
await expect(storage.set("b.txt", enc("b"))).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.MODE_READ_ONLY,
});
await expect(storage.delete("a.txt")).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.MODE_READ_ONLY,
});
});
it("12. copy-on-write round-trips set → flush → reopen", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("alpha"), method: 0 },
{ name: "b.txt", payload: enc("bravo"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
await storage.set("c.txt", enc("charlie"));
await storage.flush();
const reopen = await ZipArchiveStorage.open({
source: storage.getBuffer(),
mode: "read-only",
});
const list = await reopen.list();
expect(list.keys.sort()).toEqual(["a.txt", "b.txt", "c.txt"].sort());
expect(dec((await reopen.get("c.txt"))!)).toBe("charlie");
expect(dec((await reopen.get("a.txt"))!)).toBe("alpha");
});
it("13. rewrite mode persists immediately", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("alpha"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
await storage.set("b.txt", enc("bravo"));
// After rewrite, getBuffer() should already include b.txt.
const reopen = await ZipArchiveStorage.open({
source: storage.getBuffer(),
mode: "read-only",
});
expect(dec((await reopen.get("b.txt"))!)).toBe("bravo");
});
it("14. delete + flush removes entry", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
{ name: "b.txt", payload: enc("b"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
expect(await storage.delete("a.txt")).toBe(true);
await storage.flush();
const reopen = await ZipArchiveStorage.open({
source: storage.getBuffer(),
mode: "read-only",
});
const list = await reopen.list();
expect(list.keys).toEqual(["b.txt"]);
});
it("15. path traversal rejected on write", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
await expect(
storage.set("../escape.txt", enc("nope"))
).rejects.toMatchObject({ zipCode: ZIP_CODES.PATH_INVALID });
await expect(
storage.set("/abs.txt", enc("nope"))
).rejects.toMatchObject({ zipCode: ZIP_CODES.PATH_INVALID });
await expect(
storage.set("a\\b.txt", enc("nope"))
).rejects.toMatchObject({ zipCode: ZIP_CODES.PATH_INVALID });
await expect(storage.set("", enc("nope"))).rejects.toMatchObject({
zipCode: ZIP_CODES.PATH_INVALID,
});
});
it("16. integration via id field + capabilities", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "dir/sub/file.txt", payload: enc("payload"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
id: "zip-test",
});
expect(storage.id).toBe("zip-test");
const list = await storage.list({ prefix: "dir/" });
expect(list.keys).toEqual(["dir/sub/file.txt"]);
const exists = await storage.exists("dir/sub/file.txt");
expect(exists).toBe(true);
const meta = await storage.getMetadata("dir/sub/file.txt");
expect(meta?.size).toBe(7);
const partial = await storage.getPartial("dir/sub/file.txt", 0, 4);
expect(dec(partial!)).toBe("payl");
const size = await storage.getSize();
expect(size).toBeGreaterThan(0);
const withMeta = await storage.getWithMetadata("dir/sub/file.txt");
expect(dec(withMeta!.value)).toBe("payload");
});
it("17. clear in writable mode, then flush", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
await storage.clear();
const reopen = await ZipArchiveStorage.open({
source: storage.getBuffer(),
mode: "read-only",
});
expect((await reopen.list()).keys).toEqual([]);
});
it("18. read + write streams round-trip", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("hello"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
const reader = storage.createReadStream("a.txt").getReader();
const chunks: Uint8Array[] = [];
while (true) {
const r = await reader.read();
if (r.done) break;
if (r.value !== undefined) chunks.push(r.value);
}
let total = 0;
for (const c of chunks) total += c.byteLength;
const merged = new Uint8Array(total);
let o = 0;
for (const c of chunks) {
merged.set(c, o);
o += c.byteLength;
}
expect(dec(merged)).toBe("hello");
const writer = storage.createWriteStream("b.txt").getWriter();
await writer.write(enc("written"));
await writer.close();
expect(dec((await storage.get("b.txt"))!)).toBe("written");
});
it("19. getItemSize + setPartial work in writable mode", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("hello world"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "rewrite",
});
expect(await storage.getItemSize("a.txt")).toBe(11);
expect(await storage.getItemSize("missing")).toBeNull();
await storage.setPartial("a.txt", 6, enc("WORLD"));
expect(dec((await storage.get("a.txt"))!)).toBe("hello WORLD");
});
it("20. AES read-only mode cannot flush with pending mutations", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
// Pending mutations cannot be created in read-only mode anyway, so flush is a no-op.
await storage.flush();
expect((await storage.list()).keys).toEqual(["a.txt"]);
});
it("21. encrypted entry without password raises PASSWORD_REQUIRED", {
timeout: 10000,
}, async () => {
const plaintext = enc("secret");
const aesBytes = await buildAesZip("locked.txt", plaintext, "pw", 0);
const storage = await ZipArchiveStorage.open({
source: aesBytes,
mode: "read-only",
});
await expect(storage.get("locked.txt")).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.PASSWORD_REQUIRED,
});
});
it("22. AES with underlying deflate compression decrypts and decompresses", {
timeout: 10000,
}, async () => {
const plaintext = new Uint8Array(1024);
for (let i = 0; i < plaintext.byteLength; i++) plaintext[i] = i & 0xff;
const aesBytes = await buildAesZip("locked.bin", plaintext, "pw", 8);
const storage = await ZipArchiveStorage.open({
source: aesBytes,
mode: "read-only",
password: async () => "pw",
});
const got = await storage.get("locked.bin");
expect(got).toEqual(plaintext);
});
// Conformance harness — fresh archive used as plaintext fixture
const plaintextBytes = buildPlainZip([
{
name: "conformance.txt",
payload: enc("conformance-data"),
method: 0,
},
]);
runArchiveConformance(
async (fixture, mode) =>
await ZipArchiveStorage.open({
source: fixture.bytes,
mode,
}),
{
plaintext: {
bytes: plaintextBytes,
entryPath: "conformance.txt",
entryBytes: enc("conformance-data"),
},
writable: true,
writableFactory: {
create: async (seed, mode) =>
await ZipArchiveStorage.open({ source: seed, mode }),
},
}
);
// Property tests for parser correctness.
it("23. property: arbitrary entries round-trip via encode + decode", {
timeout: 15000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.record({
name: fc
.stringMatching(
/^[a-z]{1,8}(\/[a-z0-9]{1,8})?\.bin$/
)
.filter(
(s) => !s.includes("..") && !s.startsWith("/")
),
payload: fc.uint8Array({
minLength: 0,
maxLength: 256,
}),
}),
{ minLength: 1, maxLength: 4 }
),
async (raw) => {
// Dedupe names while preserving order.
const seen = new Set<string>();
const entries = raw
.filter((e) => {
if (seen.has(e.name)) return false;
seen.add(e.name);
return true;
})
.map((e) => ({
name: e.name,
payload: e.payload,
method: 0 as const,
}));
if (entries.length === 0) return;
const bytes = buildPlainZip(entries);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
for (const e of entries) {
const got = await storage.get(e.name);
expect(got).toEqual(e.payload);
}
}
),
{ numRuns: 6 }
);
});
it("24. property: findEocdOffset locates EOCD across random archive comments", {
timeout: 5000,
}, async () => {
await fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 256 }),
(commentBytes) => {
const base = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
// Append a comment by extending the EOCD record.
const out = new Uint8Array(
base.byteLength + commentBytes.byteLength
);
out.set(base, 0);
out.set(commentBytes, base.byteLength);
// Patch comment length field (bytes 20..22 of EOCD = base.length-2..base.length).
const eocdOffset = base.byteLength - 22;
new DataView(out.buffer).setUint16(
eocdOffset + 20,
commentBytes.byteLength,
true
);
const found = findEocdOffset(out);
expect(found).toBe(eocdOffset);
}
),
{ numRuns: 12 }
);
});
it("25. ZipError instanceof and code", { timeout: 5000 }, () => {
const e = new ZipError(ZIP_CODES.EOCD_NOT_FOUND, "msg");
expect(e).toBeInstanceOf(ZipError);
expect(e.zipCode).toBe(ZIP_CODES.EOCD_NOT_FOUND);
expect(e.code).toBe(ZIP_CODES.EOCD_NOT_FOUND);
});
it("26. encrypted entry rejecting AES with no AE extra (legacy zipcrypto)", {
timeout: 5000,
}, async () => {
// Build a plain zip and post-hoc patch the encrypted flag.
const bytes = buildPlainZip([
{ name: "x.txt", payload: enc("hello"), method: 0 },
]);
// Patch local-header flags byte: bit 0 -> encrypted.
bytes[6]! |= 0x01;
// Patch CDR flags too.
const eocdOffset = bytes.byteLength - 22;
const ev = new DataView(
bytes.buffer,
bytes.byteOffset + eocdOffset,
22
);
const cdrOff = ev.getUint32(16, true);
bytes[cdrOff + 8]! |= 0x01;
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
password: async () => "irrelevant",
});
await expect(storage.get("x.txt")).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.UNSUPPORTED_FEATURE,
});
});
it("27. host source loads via sourceKey", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const host = new MemoryStorage();
await host.set("archive.zip", bytes);
const storage = await ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
});
expect(dec((await storage.get("a.txt"))!)).toBe("a");
});
it("28. host without sourceKey throws CORRUPT", {
timeout: 5000,
}, async () => {
const host = new MemoryStorage();
await expect(
ZipArchiveStorage.open({ source: host, mode: "read-only" })
).rejects.toMatchObject({ archiveCode: ARCHIVE_CODES.CORRUPT });
});
it("29. host missing key throws CORRUPT", { timeout: 5000 }, async () => {
const host = new MemoryStorage();
await expect(
ZipArchiveStorage.open({
source: host,
sourceKey: "missing.zip",
mode: "read-only",
})
).rejects.toMatchObject({ archiveCode: ARCHIVE_CODES.CORRUPT });
});
it("30. buffer with no EOCD throws CORRUPT", {
timeout: 5000,
}, async () => {
const garbage = new Uint8Array(64);
await expect(
ZipArchiveStorage.open({ source: garbage, mode: "read-only" })
).rejects.toMatchObject({ archiveCode: ARCHIVE_CODES.CORRUPT });
});
it("31. host-persisted rewrite writes back to host on rebuild", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const host = new MemoryStorage();
await host.set("archive.zip", bytes);
const storage = await ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "rewrite",
});
await storage.set("b.txt", enc("b"));
const stored = await host.get("archive.zip");
const reopen = await ZipArchiveStorage.open({
source: stored!,
mode: "read-only",
});
const list = await reopen.list();
expect(list.keys.sort()).toEqual(["a.txt", "b.txt"].sort());
});
it("32. ReadableStream source from resolveSibling is decoded", {
timeout: 5000,
}, async () => {
const split = buildSplitTwoVolume(
[{ name: "split.txt", payload: enc("streamy"), method: 0 }],
40
);
const host = new MemoryStorage();
await host.set("archive.zip", split.zip);
const storage = await ZipArchiveStorage.open({
source: host,
sourceKey: "archive.zip",
mode: "read-only",
resolveSibling: async (_b, index) => {
if (index !== 0) return null;
return new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(split.z01);
c.close();
},
});
},
});
expect(dec((await storage.get("split.txt"))!)).toBe("streamy");
});
it("33. list pagination via limit/offset", { timeout: 5000 }, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
{ name: "b.txt", payload: enc("b"), method: 0 },
{ name: "c.txt", payload: enc("c"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
const page = await storage.list({ limit: 2, offset: 0 });
expect(page.keys.length).toBe(2);
expect(page.hasMore).toBe(true);
const rest = await storage.list({ limit: 2, offset: 2 });
expect(rest.keys.length).toBe(1);
expect(rest.hasMore).toBe(false);
});
it("34. getPartial on missing key returns null", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "read-only",
});
expect(await storage.getPartial("missing", 0, 5)).toBeNull();
expect(await storage.getWithMetadata("missing")).toBeNull();
expect(await storage.exists("missing")).toBe(false);
});
it("35. clear in copy-on-write mode (overlay tombstones)", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
{ name: "b.txt", payload: enc("b"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
await storage.clear();
expect((await storage.list()).keys).toEqual([]);
await storage.flush();
const reopen = await ZipArchiveStorage.open({
source: storage.getBuffer(),
mode: "read-only",
});
expect((await reopen.list()).keys).toEqual([]);
});
it("36. existence + get respects pending overlay (set + delete)", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
await storage.set("new.txt", enc("new"));
expect(await storage.exists("new.txt")).toBe(true);
expect(dec((await storage.get("new.txt"))!)).toBe("new");
expect(await storage.delete("new.txt")).toBe(true);
expect(await storage.exists("new.txt")).toBe(false);
expect(await storage.get("new.txt")).toBeUndefined();
});
it("37. delete missing key returns false in copy-on-write", {
timeout: 5000,
}, async () => {
const bytes = buildPlainZip([
{ name: "a.txt", payload: enc("a"), method: 0 },
]);
const storage = await ZipArchiveStorage.open({
source: bytes,
mode: "copy-on-write",
});
expect(await storage.delete("missing")).toBe(false);
});
});

View File

@@ -0,0 +1,663 @@
import {
ARCHIVE_CODES,
ArchiveError,
type ArchiveMode,
assertNotReadOnly,
assertPasswordHook,
type PasswordHook,
type PendingMutations,
pendingMutations,
type ResolveSibling,
stripTrailingExtension,
TOMBSTONE,
} from "@suaveplan/drive-storage-archive-core";
import type {
StorageListOptions,
StorageListResult,
StorageMetadata,
StorageResult,
} from "@suaveplan/storage";
import { AbstractStorage } from "@suaveplan/storage/abstract";
import type { LoggerLike } from "@suaveplan/types";
import { ZIP_CODES, ZipError } from "../errors/error-codes.js";
import { walkCdr } from "../parser/central-directory/walk-cdr.js";
import { decompress } from "../parser/compression/decompress.js";
import { crc32 } from "../parser/crc32.js";
import { aesDecrypt } from "../parser/crypto/aes/aes-decrypt.js";
import { aesWriteBlock } from "../parser/crypto/aes/aes-write-block.js";
import { findEocdOffset } from "../parser/eocd/find-eocd.js";
import { parseEocd } from "../parser/eocd/parse-eocd.js";
import {
mergeEocd64,
parseEocd64Locator,
parseEocd64Record,
} from "../parser/eocd/parse-eocd64.js";
import { readEntryBytes } from "../parser/local-header/read-entry-bytes.js";
import { FLAG_ENCRYPTED, LOCAL_HEADER_SIG } from "../parser/signatures.js";
import type { EntryRecord, EocdRecord } from "../types/parser.types.js";
import type {
ZipArchiveStorageOptions,
ZipHostStorage,
} from "../types/storage.types.js";
import { encodeZip } from "./encode-zip.js";
import { normalizePath } from "./normalize-path.js";
/**
* `DriveStorage` adapter that views a ZIP archive's entries as
* key-value pairs. Implements the abstract surface of
* `AbstractStorage`; carries an explicit `id` field to satisfy the
* structural `DriveStorage` view from `@suaveplan/drive`.
*/
export class ZipArchiveStorage extends AbstractStorage {
public readonly id: string;
public readonly mode: ArchiveMode;
readonly #entries: Map<string, EntryRecord>;
readonly #pending: PendingMutations<Uint8Array>;
readonly #password: PasswordHook | undefined;
readonly #host: ZipHostStorage | undefined;
readonly #sourceKey: string | undefined;
readonly #logger: LoggerLike | undefined;
#buffer: Uint8Array;
private constructor(args: {
buffer: Uint8Array;
entries: Map<string, EntryRecord>;
mode: ArchiveMode;
password: PasswordHook | undefined;
host: ZipHostStorage | undefined;
sourceKey: string | undefined;
logger: LoggerLike | undefined;
id: string;
}) {
super();
this.id = args.id;
this.mode = args.mode;
this.#entries = args.entries;
this.#pending = pendingMutations<Uint8Array>();
this.#password = args.password;
this.#host = args.host;
this.#sourceKey = args.sourceKey;
this.#logger = args.logger;
this.#buffer = args.buffer;
}
/**
* Open a ZIP adapter over the given source. Performs EOCD discovery,
* ZIP64 promotion, optional split-volume reassembly, and full CDR
* parsing before returning the adapter.
*/
public static async open(
opts: ZipArchiveStorageOptions
): Promise<ZipArchiveStorage> {
const id = opts.id ?? "zip";
const { buffer, host, sourceKey } = await loadPrimary(opts);
const stitched = await maybeStitchSplit(buffer, opts);
const { entries } = parseArchive(stitched);
return new ZipArchiveStorage({
buffer: stitched,
entries,
mode: opts.mode,
password: opts.password,
host,
sourceKey,
logger: opts.logger,
id,
});
}
/** Returns the current encoded archive bytes (mutated when flushed). */
public getBuffer(): Uint8Array {
return this.#buffer;
}
public override async get(key: string): Promise<Uint8Array | undefined> {
const pending = this.#pending.get(key);
if (pending === TOMBSTONE) return undefined;
if (pending !== undefined) return pending;
const entry = this.#entries.get(key);
if (entry === undefined) return undefined;
if (entry.payload !== undefined) return entry.payload;
if ((entry.cdr.flags & FLAG_ENCRYPTED) !== 0) {
if (entry.cdr.aes === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.UNSUPPORTED_FEATURE,
`entry '${key}' uses legacy ZipCrypto encryption (not supported)`
);
}
assertPasswordHook(this.#password);
const raw = readEntryBytes(this.#buffer, entry.cdr);
const password = await this.#password();
const plaintextCompressed = await this.#runAesDecrypt(
entry.cdr.aes,
raw,
password,
entry.cdr.name
);
const plaintext = decompress(
plaintextCompressed,
entry.cdr.effectiveCompressionMethod,
entry.cdr.uncompressedSize
);
entry.payload = plaintext;
return plaintext;
}
const raw = readEntryBytes(this.#buffer, entry.cdr);
const plaintext = decompress(
raw,
entry.cdr.effectiveCompressionMethod,
entry.cdr.uncompressedSize
);
const actualCrc = crc32(plaintext);
// AE-2 stores zero CRC; skip validation for AES.
if (
entry.cdr.aes === undefined &&
actualCrc !== entry.cdr.crc32 >>> 0
) {
throw new ZipError(
ZIP_CODES.CRC_MISMATCH,
`CRC mismatch for entry '${entry.cdr.name}': expected ${entry.cdr.crc32}, got ${actualCrc}`
);
}
entry.payload = plaintext;
return plaintext;
}
public override async set(key: string, value: Uint8Array): Promise<void> {
assertNotReadOnly(this.mode);
normalizePath(key);
const existing = this.#entries.get(key);
if (existing?.cdr.aes !== undefined) {
// Writing an AES-encrypted entry is rejected in v0.1.0.
aesWriteBlock("", value, 3); // always throws
}
if (this.mode === "copy-on-write") {
this.#pending.set(key, value);
return;
}
// rewrite mode: persist immediately by rebuilding the archive.
this.#entries.set(key, {
cdr: {
...(existing?.cdr ?? makeSyntheticCdr(key)),
name: key,
},
payload: value,
mutated: true,
});
await this.#rebuildAndPersist();
}
public override async delete(key: string): Promise<boolean> {
assertNotReadOnly(this.mode);
if (this.mode === "copy-on-write") {
const had = this.#entries.has(key) || this.#pending.has(key);
this.#pending.delete(key);
return had;
}
const had = this.#entries.delete(key);
await this.#rebuildAndPersist();
return had;
}
public override async exists(key: string): Promise<boolean> {
const pending = this.#pending.get(key);
if (pending === TOMBSTONE) return false;
if (pending !== undefined) return true;
return this.#entries.has(key);
}
public override async list(
options?: StorageListOptions
): Promise<StorageListResult> {
const prefix = options?.prefix ?? "";
const all = new Set<string>();
for (const k of this.#entries.keys()) {
if (k.startsWith(prefix)) all.add(k);
}
for (const [k, v] of this.#pending.entries()) {
if (v === TOMBSTONE) {
all.delete(k);
} else if (k.startsWith(prefix)) {
all.add(k);
}
}
let keys = Array.from(all);
const total = keys.length;
const offset = options?.offset ?? 0;
const limit = options?.limit;
const page =
limit !== undefined
? keys.slice(offset, offset + limit)
: keys.slice(offset);
keys = page;
return {
keys,
total,
hasMore: offset + keys.length < total,
continuationToken:
offset + keys.length < total
? String(offset + keys.length)
: undefined,
};
}
public override async clear(): Promise<void> {
assertNotReadOnly(this.mode);
if (this.mode === "copy-on-write") {
for (const k of this.#entries.keys()) this.#pending.delete(k);
return;
}
this.#entries.clear();
await this.#rebuildAndPersist();
}
public override async getWithMetadata(
key: string
): Promise<StorageResult<Uint8Array> | null> {
const value = await this.get(key);
if (value === undefined) return null;
const metadata = (await this.getMetadata(key)) as StorageMetadata;
return { value, metadata };
}
public override async getMetadata(
key: string
): Promise<StorageMetadata | null> {
const pending = this.#pending.get(key);
if (pending === TOMBSTONE) return null;
if (pending !== undefined) {
return {
createdAt: 0,
modifiedAt: 0,
size: pending.byteLength,
};
}
const entry = this.#entries.get(key);
if (entry === undefined) return null;
return {
createdAt: 0,
modifiedAt: 0,
size: entry.cdr.uncompressedSize,
};
}
public override async getSize(): Promise<number> {
let total = 0;
for (const [k, e] of this.#entries.entries()) {
if (this.#pending.get(k) === TOMBSTONE) continue;
total += e.cdr.uncompressedSize;
}
for (const [k, v] of this.#pending.entries()) {
if (v === TOMBSTONE) continue;
if (!this.#entries.has(k)) total += v.byteLength;
}
return total;
}
public override async getPartial(
key: string,
offset: number,
length: number
): Promise<Uint8Array | null> {
const value = await this.get(key);
if (value === undefined) return null;
const start = Math.min(offset, value.byteLength);
const end = Math.min(start + length, value.byteLength);
return value.subarray(start, end);
}
public override async setPartial(
key: string,
offset: number,
bytes: Uint8Array
): Promise<void> {
assertNotReadOnly(this.mode);
const current = (await this.get(key)) ?? new Uint8Array(0);
const end = offset + bytes.byteLength;
const out = new Uint8Array(Math.max(current.byteLength, end));
out.set(current, 0);
out.set(bytes, offset);
await this.set(key, out);
}
public override async getItemSize(key: string): Promise<number | null> {
const meta = await this.getMetadata(key);
return meta?.size ?? null;
}
public override createReadStream(key: string): ReadableStream<Uint8Array> {
const self = this;
return new ReadableStream<Uint8Array>({
async start(controller) {
try {
const value = await self.get(key);
if (value !== undefined) controller.enqueue(value);
controller.close();
} catch (err) {
controller.error(err);
}
},
});
}
public override createWriteStream(key: string): WritableStream<Uint8Array> {
const self = this;
const chunks: Uint8Array[] = [];
return new WritableStream<Uint8Array>({
write(chunk) {
chunks.push(chunk);
},
async close() {
let total = 0;
for (const c of chunks) total += c.byteLength;
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(c, offset);
offset += c.byteLength;
}
await self.set(key, merged);
},
abort() {
chunks.length = 0;
},
});
}
/** Materialise the archive bytes from the current logical state. */
public async flush(): Promise<void> {
if (this.mode === "read-only") {
this.#pending.assertEmpty();
return;
}
await this.#rebuildAndPersist();
}
async #rebuildAndPersist(): Promise<void> {
// Apply overlay onto the in-memory entry table.
if (this.mode === "copy-on-write") {
for (const [k, v] of this.#pending.entries()) {
if (v === TOMBSTONE) {
this.#entries.delete(k);
} else {
this.#entries.set(k, {
cdr: {
...(this.#entries.get(k)?.cdr ??
makeSyntheticCdr(k)),
name: k,
},
payload: v,
mutated: true,
});
}
}
}
// Materialise the payload for every entry that has not yet been
// decompressed in memory.
const encodeEntries: Array<{ path: string; payload: Uint8Array }> = [];
for (const [k, e] of this.#entries) {
if (e.payload === undefined) {
e.payload = await this.#readPayloadFromBuffer(e);
}
encodeEntries.push({ path: k, payload: e.payload });
}
const next = encodeZip(encodeEntries);
this.#buffer = next;
// Re-parse so subsequent reads use accurate CDR offsets / sizes.
const { entries } = parseArchive(next);
this.#entries.clear();
for (const [k, v] of entries) this.#entries.set(k, v);
this.#pending.clear();
if (this.#host !== undefined && this.#sourceKey !== undefined) {
const host = this.#host as unknown as {
set: (k: string, v: Uint8Array) => Promise<void>;
};
await host.set(this.#sourceKey, next);
}
}
async #runAesDecrypt(
aes: NonNullable<EntryRecord["cdr"]["aes"]>,
raw: Uint8Array,
password: string,
name: string
): Promise<Uint8Array> {
const result: Uint8Array | Error = await aesDecrypt(
aes,
raw,
password,
name
).catch((err: Error) => err);
if (
result instanceof ZipError &&
result.zipCode === ZIP_CODES.AES_AUTH_FAILED
) {
throw new ArchiveError(
ARCHIVE_CODES.PASSWORD_INVALID,
result.message,
{ cause: result }
);
}
if (result instanceof Error) throw result;
return result;
}
async #readPayloadFromBuffer(entry: EntryRecord): Promise<Uint8Array> {
if ((entry.cdr.flags & FLAG_ENCRYPTED) !== 0) {
// For copy-on-write we can preserve the original archive by
// refusing to re-encode encrypted entries; v0.1.0 rejects.
throw new ZipError(
ZIP_CODES.AES_WRITE_UNSUPPORTED,
`cannot re-encode encrypted entry '${entry.cdr.name}' on flush`
);
}
const raw = readEntryBytes(this.#buffer, entry.cdr);
return decompress(
raw,
entry.cdr.effectiveCompressionMethod,
entry.cdr.uncompressedSize
);
}
}
function makeSyntheticCdr(name: string) {
return {
versionMadeBy: 0x031e,
versionNeeded: 20,
flags: 0x0800,
compressionMethod: 0,
effectiveCompressionMethod: 0,
mtimeDos: 0,
mdateDos: 0x0021,
crc32: 0,
compressedSize: 0,
uncompressedSize: 0,
diskNumberStart: 0,
internalAttrs: 0,
externalAttrs: 0,
localHeaderOffset: 0,
name,
extras: new Map(),
comment: new Uint8Array(0),
};
}
async function loadPrimary(opts: ZipArchiveStorageOptions): Promise<{
buffer: Uint8Array;
host: ZipHostStorage | undefined;
sourceKey: string | undefined;
}> {
if (opts.source instanceof Uint8Array) {
return { buffer: opts.source, host: undefined, sourceKey: undefined };
}
if (opts.sourceKey === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.CORRUPT,
"ZipArchiveStorage.open requires sourceKey when source is a host storage"
);
}
const host = opts.source as ZipHostStorage;
const got = await host.get(opts.sourceKey);
if (got === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.CORRUPT,
`host has no entry at key '${opts.sourceKey}'`
);
}
return { buffer: got, host, sourceKey: opts.sourceKey };
}
async function maybeStitchSplit(
primary: Uint8Array,
opts: ZipArchiveStorageOptions
): Promise<Uint8Array> {
const offset = findEocdOffset(primary);
if (offset === null) {
throw new ArchiveError(
ARCHIVE_CODES.CORRUPT,
"no EOCD found in primary volume"
);
}
const legacy = parseEocd(primary, offset);
let eocd: EocdRecord = legacy;
if (
legacy.totalEntries === 0xffff ||
legacy.centralDirSize === 0xffffffff ||
legacy.centralDirOffset === 0xffffffff ||
legacy.entriesOnThisDisk === 0xffff
) {
const locator = parseEocd64Locator(primary, offset);
const z64 = parseEocd64Record(primary, locator.eocd64Offset);
eocd = mergeEocd64(legacy, z64);
}
if (eocd.diskNumber === 0 && eocd.diskWithCentralDir === 0) {
return primary;
}
// Split archive — must resolve siblings.
// sourceKey is guaranteed by loadPrimary() when source is a host storage;
// the inline-buffer branch never reaches here because findEocdOffset would
// not have surfaced a multi-disk EOCD.
const sourceKey: string =
typeof opts.sourceKey === "string" ? opts.sourceKey : "<inline>";
if (opts.resolveSibling === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
"archive is split across multiple volumes but resolveSibling is not configured",
{ context: { basename: sourceKey, missingIndex: 0 } }
);
}
const basename = stripTrailingExtension(sourceKey);
const volumes: Uint8Array[] = [];
const total = eocd.diskNumber + 1;
for (let i = 0; i < total - 1; i++) {
const sibling = await callResolveSibling(
opts.resolveSibling,
basename,
i
);
if (sibling === null) {
throw new ArchiveError(
ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
`missing split-zip volume index ${i} for basename '${basename}'`,
{ context: { basename, missingIndex: i } }
);
}
validateVolume(sibling, i, basename);
volumes.push(sibling);
}
volumes.push(primary);
let len = 0;
for (const v of volumes) len += v.byteLength;
const out = new Uint8Array(len);
let cursor = 0;
for (const v of volumes) {
out.set(v, cursor);
cursor += v.byteLength;
}
return out;
}
async function callResolveSibling(
resolve: ResolveSibling,
basename: string,
index: number
): Promise<Uint8Array | null> {
const got = await resolve(basename, index);
if (got === null) return null;
if (got instanceof Uint8Array) return got;
// ReadableStream → bytes.
const reader = got.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const r = await reader.read();
if (r.done) break;
chunks.push(r.value);
}
let len = 0;
for (const c of chunks) len += c.byteLength;
const out = new Uint8Array(len);
let cursor = 0;
for (const c of chunks) {
out.set(c, cursor);
cursor += c.byteLength;
}
return out;
}
function validateVolume(
bytes: Uint8Array,
index: number,
basename: string
): void {
if (bytes.byteLength < 4) {
throw new ArchiveError(
ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
`volume ${index} for basename '${basename}' is shorter than a local header`,
{ context: { basename, missingIndex: index } }
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset, 4);
const magic = view.getUint32(0, true);
if (
magic !== LOCAL_HEADER_SIG &&
magic !== 0x08074b50 /* split-archive disk marker */
) {
throw new ArchiveError(
ARCHIVE_CODES.SPLIT_MISSING_VOLUME,
`volume ${index} for basename '${basename}' does not start with a local header or split marker`,
{ context: { basename, missingIndex: index } }
);
}
}
function parseArchive(buffer: Uint8Array): {
entries: Map<string, EntryRecord>;
eocd: EocdRecord;
} {
// Caller (open + #rebuildAndPersist) has already validated a non-null EOCD offset.
const offset = findEocdOffset(buffer) as number;
const legacy = parseEocd(buffer, offset);
let eocd: EocdRecord = legacy;
if (
legacy.totalEntries === 0xffff ||
legacy.centralDirSize === 0xffffffff ||
legacy.centralDirOffset === 0xffffffff ||
legacy.entriesOnThisDisk === 0xffff
) {
const locator = parseEocd64Locator(buffer, offset);
const z64 = parseEocd64Record(buffer, locator.eocd64Offset);
eocd = mergeEocd64(legacy, z64);
}
const entries = new Map<string, EntryRecord>();
const cdrEntries = walkCdr(
buffer,
eocd.centralDirOffset,
eocd.totalEntries
);
for (const e of cdrEntries) {
entries.set(e.name, { cdr: e });
}
return { entries, eocd };
}

View File

@@ -0,0 +1,13 @@
export type {
AesExtraField,
CdrEntry,
EntryRecord,
Eocd64Locator,
Eocd64Record,
EocdRecord,
LocalHeader,
} from "./parser.types.js";
export type {
ZipArchiveStorageOptions,
ZipHostStorage,
} from "./storage.types.js";

View File

@@ -0,0 +1,45 @@
# `parser.types`
## Purpose
Declares the data shapes the ZIP parser surfaces to its consumers. Every record produced by `find-eocd`, `parse-eocd`, `parse-eocd64`, `parse-cdr-entry`, `walk-cdr`, and `parse-local-header` is typed against this module so that downstream code (the storage adapter, the encoder, conformance tests) sees a single, stable surface free of `any` and free of cross-module coupling.
## Features
- `EocdRecord` — the composite End-Of-Central-Directory record, carrying both the legacy 32-bit fields and an `isZip64` flag indicating whether the ZIP64 merge step stamped wider values onto it.
- `Eocd64Locator` + `Eocd64Record` — the two ZIP64 records consumed by the parser when sentinel values appear.
- `CdrEntry` — full Central Directory File Header with every named field plus the decoded extras map and an optional `aes` block for WinZip AE-encrypted entries.
- `LocalHeader` — Local File Header decoded shape including the `headerLength` so callers can locate the payload start with no further reads.
- `AesExtraField` — typed view of the AE extra field (`0x9901`), narrowing `strength` to the literal union `1 | 2 | 3`.
- `EntryRecord` — composite in-memory record pairing a `CdrEntry` with an optional cached `payload` and a `mutated` flag used by the write path.
## Usage: Basic
```ts
import type { CdrEntry } from "@suaveplan/drive-storage-zip/types";
function describe(entry: CdrEntry): string {
return `${entry.name} (${entry.uncompressedSize} bytes, method ${entry.effectiveCompressionMethod})`;
}
```
## Usage: Advanced
```ts
import type { EntryRecord } from "@suaveplan/drive-storage-zip/types";
function isEncrypted(record: EntryRecord): boolean {
return record.cdr.aes !== undefined;
}
```
## API Reference
- `EocdRecord`, `Eocd64Locator`, `Eocd64Record` — EOCD family.
- `CdrEntry`, `LocalHeader`, `AesExtraField`, `EntryRecord` — entry family.
All members are `readonly` except `EntryRecord.payload` and `EntryRecord.mutated`, which the storage class mutates to cache decompressed bytes and flag in-flight writes.
## Implementation Notes
Numeric fields use `number` rather than `bigint` because the parser refuses values exceeding `Number.MAX_SAFE_INTEGER` (2^53 - 1) and surfaces the overflow as `ZIP.ZIP64_REQUIRED`. This keeps every consumer site free of `bigint` arithmetic at the cost of an early failure for genuinely planet-scale archives (>8 PiB), which we consider an acceptable trade-off in v0.1.0.

View File

@@ -0,0 +1,95 @@
/**
* Parsed End-Of-Central-Directory record. ZIP64 fields are merged in by
* `findEocd` when the corresponding sentinel value (`0xFFFFFFFF` /
* `0xFFFF`) is present, so consumers see one record carrying the
* effective 64-bit values regardless of whether they came from the
* legacy or the ZIP64 EOCD.
*/
export interface EocdRecord {
readonly diskNumber: number;
readonly diskWithCentralDir: number;
readonly entriesOnThisDisk: number;
readonly totalEntries: number;
readonly centralDirSize: number;
readonly centralDirOffset: number;
readonly comment: Uint8Array;
readonly isZip64: boolean;
}
/** Parsed ZIP64 EOCD locator (20 bytes, immediately before the EOCD). */
export interface Eocd64Locator {
readonly diskWithEocd64: number;
readonly eocd64Offset: number;
readonly totalDisks: number;
}
/** Parsed ZIP64 EOCD record. */
export interface Eocd64Record {
readonly diskNumber: number;
readonly diskWithCentralDir: number;
readonly entriesOnThisDisk: number;
readonly totalEntries: number;
readonly centralDirSize: number;
readonly centralDirOffset: number;
}
/** Decoded Central Directory File Header. */
export interface CdrEntry {
readonly versionMadeBy: number;
readonly versionNeeded: number;
readonly flags: number;
readonly compressionMethod: number;
/** Resolved compression method when AE encryption masks it. */
readonly effectiveCompressionMethod: number;
readonly mtimeDos: number;
readonly mdateDos: number;
readonly crc32: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly diskNumberStart: number;
readonly internalAttrs: number;
readonly externalAttrs: number;
readonly localHeaderOffset: number;
readonly name: string;
readonly extras: ReadonlyMap<number, Uint8Array>;
readonly comment: Uint8Array;
readonly aes?: AesExtraField;
}
/** Parsed local file header for a single entry. */
export interface LocalHeader {
readonly versionNeeded: number;
readonly flags: number;
readonly compressionMethod: number;
readonly mtimeDos: number;
readonly mdateDos: number;
readonly crc32: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly name: string;
readonly extras: ReadonlyMap<number, Uint8Array>;
readonly headerLength: number;
}
/**
* Decoded WinZip AE extra field. AE-2 (`aeVersion === 2`) elides the
* CRC32 in the central directory; AE-1 retains it.
*/
export interface AesExtraField {
readonly aeVersion: number;
readonly vendorId: string;
readonly strength: 1 | 2 | 3;
readonly originalMethod: number;
}
/**
* In-memory composite record used by `ZipArchiveStorage` to satisfy
* reads and to drive the encoder during `flush()` / `rewrite` writes.
*/
export interface EntryRecord {
readonly cdr: CdrEntry;
/** Decompressed payload, populated lazily on first `get(key)` for read-only adapters and eagerly when modified in write modes. */
payload?: Uint8Array;
/** Whether the payload originated from a mutation (used to bypass the AES-decrypt path on re-read). */
mutated?: boolean;
}

View File

@@ -0,0 +1,54 @@
# `storage.types`
## Purpose
Declares the public construction surface for `ZipArchiveStorage`. The exported `ZipArchiveStorageOptions` interface is the single source of truth for what callers must supply when opening a ZIP archive as a `DriveStorage`-shaped adapter, and `ZipHostStorage` declares the minimum structural view of the host storage we read sibling-volume bytes from when reassembling a split archive.
## Features
- `ZipArchiveStorageOptions` covers every legal combination of source (inline `Uint8Array`, host `AbstractStorage`, or a custom `ZipHostStorage`-shaped object), the three archive modes, the optional `password` hook, the optional `resolveSibling` callback for split volumes, an optional `logger`, and an optional `id` for the `DriveStorage.id` field.
- `ZipHostStorage` is a deliberately narrow structural interface (`get` + `exists`) so callers can plug in a raw `Map`-backed mock, an HTTP-backed shim, or an `AbstractStorage` subclass interchangeably.
- All fields are `readonly` to encourage immutable construction objects at call sites and to surface accidental post-construction mutation as a TypeScript error.
## Usage: Basic
```ts
import type { ZipArchiveStorageOptions } from "@suaveplan/drive-storage-zip/types";
const options: ZipArchiveStorageOptions = {
source: archiveBytes,
mode: "read-only",
};
```
## Usage: Advanced
```ts
import type {
ZipArchiveStorageOptions,
ZipHostStorage,
} from "@suaveplan/drive-storage-zip/types";
function buildHostBackedOptions(
host: ZipHostStorage,
primaryKey: string
): ZipArchiveStorageOptions {
return {
source: host,
sourceKey: primaryKey,
mode: "copy-on-write",
password: async () => process.env.ZIP_PASSWORD!,
};
}
```
## API Reference
- `ZipArchiveStorageOptions` — construction options.
- `ZipHostStorage` — minimal structural view of the host storage.
Both are pure type declarations with no runtime emit.
## Implementation Notes
We intentionally accept `Uint8Array | AbstractStorage | ZipHostStorage` for `source` rather than a discriminated union; the runtime dispatch (`instanceof Uint8Array`) is trivial and the relaxed type signature keeps test fixtures terse. The `id` field defaults to `"zip"` in the storage constructor, matching the convention used by sibling drive-storage adapters across the project.

View File

@@ -0,0 +1,45 @@
import type {
ArchiveMode,
PasswordHook,
ResolveSibling,
} from "@suaveplan/drive-storage-archive-core";
import type { AbstractStorage } from "@suaveplan/storage/abstract";
import type { LoggerLike } from "@suaveplan/types";
/**
* Structural minimum of the host storage we read sibling-volume bytes
* from. Identical to `AbstractStorage` for normal use; extracted so a
* raw `Map`-backed mock can satisfy the constructor in tests without
* pulling in the full storage package surface.
*/
export interface ZipHostStorage {
get(key: string): Promise<Uint8Array | undefined>;
exists(key: string): Promise<boolean>;
}
/**
* Construction options for `ZipArchiveStorage`.
*
* - `source`: either the primary-volume bytes, or a host `AbstractStorage`
* from which `get(sourceKey)` retrieves them. When the latter is used,
* write modes (`copy-on-write` / `rewrite`) persist back to the host
* on `flush()`; otherwise the rebuilt bytes are buffered internally
* and exposed via `getBuffer()`.
* - `sourceKey`: required when `source` is a host storage.
* - `mode`: one of `read-only`, `copy-on-write`, `rewrite`.
* - `password`: lazy hook invoked when an AES-encrypted entry is read.
* - `resolveSibling`: callback for split-zip volume reassembly. When
* omitted and the archive is split, the adapter throws
* `ARCHIVE_SPLIT_MISSING_VOLUME`.
* - `logger`: optional structured logger.
* - `id`: explicit DriveStorage identifier (defaults to `"zip"`).
*/
export interface ZipArchiveStorageOptions {
readonly source: Uint8Array | AbstractStorage | ZipHostStorage;
readonly sourceKey?: string;
readonly mode: ArchiveMode;
readonly password?: PasswordHook;
readonly resolveSibling?: ResolveSibling;
readonly logger?: LoggerLike;
readonly id?: string;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "src/__benchmarks__/**", "dist", "node_modules"]
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/drive-storage-zip",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"errors/index": "src/errors/index.ts",
"types/index": "src/types/index.ts",
"parser/index": "src/parser/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/drive-storage-archive-core",
"@suaveplan/error",
"@suaveplan/storage",
"@suaveplan/storage/abstract",
"@suaveplan/types",
"fflate",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,5 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
additionalCoverageExcludes: ["src/__fixtures__/**"],
});