Files
2026-05-27 23:29:53 +01:00

106 lines
6.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Packer — Spec Delta (packer-port)
## ADDED Requirements
### PACKER-01 — Whole-file pack/unpack round-trip
`Packer.pack(data, filename?)` followed by `Unpacker.unpack(packed, key, pubKey?)` MUST yield bytes equal to the original `data`. Optional MIME detection MUST surface in the returned metadata.
**Verified by:** `src/packer/packer.spec-conformance.test.ts` (`PACKER-01`).
### PACKER-02 — Whole-file gzip compression
When `compressionLevel > 0`, `pack` MUST gzip the payload before AEAD; `unpack` MUST gunzip after AEAD. Any compression failure MUST throw `PackerError(PACKER.COMPRESSION_FAILED)`.
**Verified by:** `src/packer/packer.spec-conformance.test.ts` (`PACKER-02`).
### PACKER-03 — Whole-file signing
When a `SignKey` is provided to `pack`, the produced `PackedData` MUST include an ed25519 signature over the canonical packed bytes. `unpack` with a corresponding `PublicKey` MUST verify the signature and throw `PackerError(PACKER.VERIFICATION_FAILED)` on mismatch.
**Verified by:** `src/packer/packer.spec-conformance.test.ts` (`PACKER-03`).
### PACKER-04 — Whole-file event lifecycle
`Packer` MUST emit typed events via `EventBus<PackerEventMap>`: `pack:start`, `pack:complete`, `pack:error`, with corresponding `unpack:*` events on the unpacker. Events MUST fire in deterministic FIFO order on completion.
**Verified by:** `src/packer/packer.spec-conformance.test.ts` (`PACKER-04`).
### PACKER-05 — Chunked v1 byte layout
A v1 chunk produced by `ChunkedPacker` MUST be the byte concatenation `metadata(100) ‖ sig(64) ‖ ciphertext(payloadLen)`, total `164 + payloadLen` bytes, big-endian. Field offsets within metadata MUST be exactly: `version` 0–2, `fileId` 2–34, `chunkId` 34–38, `totalChunks` 38–42, `fileSize` 42–50, `chunkSize` 50–54, `payloadLen` 54–58, `flags` 58–60, `nonce` 60–84, `aeadTag` 84–100. Sig occupies 100–164. Ciphertext starts at 164.
**Verified by:** `src/chunked/codec/chunk.spec-conformance.test.ts` (`PACKER-05`).
### PACKER-06 — `flags` reserved in v1
`flags` MUST be `0x0000` in v1. `parseHeader` MUST reject non-zero `flags` with `PackerError(PACKER.FLAGS_RESERVED_NONZERO)` before any further parse work.
**Verified by:** `src/chunked/codec/chunk.spec-conformance.test.ts` (`PACKER-06`).
### PACKER-07 — `payloadLen` invariants
For non-final chunks (`chunkId < totalChunks - 1`), `payloadLen` MUST equal `chunkSize - 164`. For the final chunk, `payloadLen` MUST equal `fileSize - chunkId * (chunkSize - 164)`. Mismatch MUST be rejected with `PackerError(PACKER.PAYLOAD_LEN_INVALID)` before sig verification.
**Verified by:** `src/chunked/codec/chunk.spec-conformance.test.ts` (`PACKER-07`).
### PACKER-08 — Streaming pipeline produces v1 chunks
`ChunkedPacker.pack(source, fileKey, signKey, options)` MUST stream-process the input via `normalizeSource → hash → aead-encrypt-stream → chop → per-chunk sign → emit`. The output MUST be an `AsyncIterable<{ chunkId, bytes }>` where each `bytes` is a v1-format chunk. The full source MUST NOT be buffered in memory during pack.
**Verified by:** `src/chunked/packer/packer.spec-conformance.test.ts` (`PACKER-08`).
### PACKER-09 — Non-final chunks are exactly `chunkSize` bytes
Every emitted chunk with `chunkId < totalChunks - 1` MUST be exactly `chunkSize` bytes on the wire (default `chunkSize = 1 MiB → payloadLen = 1048412`). No zero padding is added.
**Verified by:** `src/chunked/packer/packer.spec-conformance.test.ts` (`PACKER-09`).
### PACKER-10 — Final chunk is naturally short
The final chunk MUST be `164 + lastPayloadLen` bytes, where `lastPayloadLen = fileSize - (totalChunks - 1) * (chunkSize - 164)`. The final chunk MUST NOT be zero-padded to `chunkSize`.
**Verified by:** `src/chunked/packer/packer.spec-conformance.test.ts` (`PACKER-10`).
### PACKER-11 — Round-trip parity
`ChunkedUnpacker.unpack(chunks, fileKey, serverPubKey)` over the chunks emitted by `ChunkedPacker.pack(...)` MUST yield a plaintext stream byte-equal to the original input. Order MUST be `chunkId` ascending; out-of-order delivery is the consumer's responsibility to reorder before passing to unpacker.
**Verified by:** `src/chunked/unpacker/unpacker.spec-conformance.test.ts` (`PACKER-11`).
### PACKER-12 — Mutation detection
Any mutation to any signed byte (metadata field or any byte of ciphertext) MUST cause `ChunkedUnpacker.unpack` to throw `PackerError(PACKER.VERIFICATION_FAILED)` from the per-chunk sig-verify step before AEAD decryption is attempted.
**Verified by:** `src/chunked/unpacker/unpacker.spec-conformance.test.ts` (`PACKER-12`).
### PACKER-13 — Verifier without file key
`ChunkVerifier.verify(chunkBytes, serverPubKey)` MUST validate the structural invariants and the ed25519 signature without the file decryption key. It MUST return `{ ok: true, metadata }` for valid chunks and `{ ok: false, code }` for invalid chunks. It MUST NOT attempt AEAD decryption.
**Verified by:** `src/chunked/verifier/chunk-verifier.spec-conformance.test.ts` (`PACKER-13`).
### PACKER-14 — Verifier rejects forged sig
`ChunkVerifier.verify` MUST return `{ ok: false, code: "PACKER.VERIFICATION_FAILED" }` for a chunk whose signature does not validate under `serverPubKey`. Verifier MUST NOT consult the chunks plugin or any persistence layer.
**Verified by:** `src/chunked/verifier/chunk-verifier.spec-conformance.test.ts` (`PACKER-14`).
### PACKER-15 — Manifest signature
The manifest produced by `buildManifest(...)` MUST be ed25519-signed via `signManifest(manifest, signKey)`. `verifyManifest(manifest, serverPubKey)` MUST return `false` for any manifest whose signature does not validate; mutation of any signed field MUST cause verification failure.
**Verified by:** `src/chunked/manifest/manifest.spec-conformance.test.ts` (`PACKER-15`).
### PACKER-16 — Manifest matches chunk metadata
The manifest produced by a successful `ChunkedPacker.pack(...)` MUST satisfy: `manifest.fileId == chunkMeta[i].fileId` for all `i`, `manifest.totalChunks == chunkCount`, `manifest.chunkSize == options.chunkSize`, `manifest.fileSize == sum(payloadLen)`, and `manifest.chunks[i].payloadLen == chunkMeta[i].payloadLen` for all `i`.
**Verified by:** `src/chunked/manifest/manifest.spec-conformance.test.ts` (`PACKER-16`).
### PACKER-17 — Error registry namespaced and frozen
`PACKER_CODES` MUST be declared via `defineCodes("PACKER", [...])` from `@suaveplan/error`. `Object.isFrozen(PACKER_CODES)` MUST be `true`. Every code value MUST start with `PACKER.`. `PackerError` MUST extend `SuaveplanError<PackerErrorCode>`.
**Verified by:** `src/error-codes.spec-conformance.test.ts` (`PACKER-17`).