first commit
This commit is contained in:
38
docs/networking/drive/drive-storage-zip/README.md
Normal file
38
docs/networking/drive/drive-storage-zip/README.md
Normal 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 -->
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
38
docs/networking/drive/drive-storage-zip/src/parser/crc32.md
Normal file
38
docs/networking/drive/drive-storage-zip/src/parser/crc32.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user