Initial Commit
This commit is contained in:
105
openspec/archive/2026-04-28-packer-port/specs/packer/spec.md
Normal file
105
openspec/archive/2026-04-28-packer-port/specs/packer/spec.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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`).
|
||||
Reference in New Issue
Block a user