191 lines
10 KiB
Markdown
191 lines
10 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: packer (enterprise-packer-integrity)
|
|
|
|
**Package**: `@suaveplan/packer`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: PACKER-18 — BLAKE3 per-entry digest and bundle-level Merkle root
|
|
|
|
The package SHALL compute and surface a content-addressable BLAKE3 digest for
|
|
every entry written to a bundle, plus a bundle-level Merkle root computed over
|
|
the sorted entry digests. The Merkle root MUST be a 32-byte BLAKE3 digest of
|
|
the concatenation of the sorted entry digests in `chunkId`-ascending order;
|
|
for a single-entry bundle the Merkle root equals the lone entry digest; for an
|
|
empty bundle the Merkle root MUST be `BLAKE3("")` (the 32-byte all-zero-input
|
|
digest). Whole-file bundles carry `(entryDigest, merkleRoot)` on
|
|
`PackedDataMetadata.integrity`; chunked bundles carry per-chunk `entryDigest`
|
|
on every `ManifestChunkEntry` and `merkleRoot` at the manifest root. The
|
|
production paths `Packer.pack` and `buildManifest` MUST always populate these
|
|
fields.
|
|
|
|
#### Scenario: whole-file pack emits integrity
|
|
|
|
- **WHEN** `Packer.pack(data)` runs
|
|
- **THEN** the returned `PackedData.metadata.integrity` is present, its
|
|
`entryDigest` equals `blake3(payload)` of the wire-shaped payload bytes,
|
|
and its `merkleRoot` equals `entryDigest`
|
|
|
|
#### Scenario: chunked manifest emits per-entry digest and Merkle root
|
|
|
|
- **WHEN** `buildManifest({chunks, ...}, signKey)` runs
|
|
- **THEN** every `manifest.chunks[i].entryDigest` equals `blake3(wireBytes_i)`
|
|
and `manifest.merkleRoot` equals `blake3(concat(sort_by_chunkId(digests)))`
|
|
|
|
#### Scenario: empty bundle Merkle root is BLAKE3 of zero bytes
|
|
|
|
- **WHEN** `merkleRootBlake3([])` is called
|
|
- **THEN** the result equals `blake3(new Uint8Array(0))`
|
|
|
|
**Verified by:** `src/integrity/integrity.test.ts::"computes the BLAKE3 entry digest of payload bytes"` AND `src/integrity/integrity.test.ts::"computes the Merkle root as BLAKE3 of concatenated sorted digests"` AND `src/integrity/integrity.test.ts::"returns BLAKE3 of zero bytes for an empty digest list"` AND `src/packer/packer.spec-conformance.test.ts::"populates metadata.integrity on every pack"` AND `src/chunked/manifest/manifest.spec-conformance.test.ts::"populates entryDigest on every chunk entry and a manifest merkleRoot"`
|
|
|
|
---
|
|
|
|
### Requirement: PACKER-19 — Mandatory integrity verification on unpack
|
|
|
|
The package SHALL reject any unpack of a bundle whose recomputed integrity
|
|
digests do not match the bundle's recorded values. `Unpacker.unpack` MUST
|
|
throw `PackerError(PACKER.INTEGRITY_FAILED)` when `metadata.integrity` is
|
|
absent OR when the recomputed `entryDigest` or `merkleRoot` does not match.
|
|
`unpackChunksWithManifest` MUST throw `PackerError(PACKER.INTEGRITY_FAILED)`
|
|
for any chunk whose `blake3(wireBytes)` does not equal the matching
|
|
`manifest.chunks[i].entryDigest`, OR when the manifest's `merkleRoot` does
|
|
not equal `merkleRootBlake3(manifest.chunks.map(c => c.entryDigest))`.
|
|
Integrity verification MUST run on **both** the whole-file and chunked paths;
|
|
ed25519 signing remains in force in parallel.
|
|
|
|
#### Scenario: whole-file unpack rejects missing integrity
|
|
|
|
- **WHEN** `Unpacker.unpack(packed)` is called on a `PackedData` whose
|
|
`metadata.integrity` field is absent
|
|
- **THEN** it throws `PackerError(PACKER.INTEGRITY_FAILED)` and the resolved
|
|
message is the catalog value for `packer.error.INTEGRITY_FAILED.absent`
|
|
|
|
#### Scenario: whole-file unpack rejects entry-digest mutation
|
|
|
|
- **WHEN** `Unpacker.unpack(packed)` is called on a `PackedData` whose
|
|
`metadata.integrity.entryDigest` has been mutated by one bit
|
|
- **THEN** it throws `PackerError(PACKER.INTEGRITY_FAILED)` before yielding
|
|
the plaintext
|
|
|
|
#### Scenario: whole-file unpack rejects merkle-root mutation
|
|
|
|
- **WHEN** `Unpacker.unpack(packed)` is called on a `PackedData` whose
|
|
`metadata.integrity.merkleRoot` has been mutated by one bit
|
|
- **THEN** it throws `PackerError(PACKER.INTEGRITY_FAILED)`
|
|
|
|
#### Scenario: chunked unpack rejects per-chunk BLAKE3 mismatch
|
|
|
|
- **WHEN** `unpackChunksWithManifest(chunks, manifest, opts)` is consumed
|
|
fully and one chunk's wire bytes differ from `manifest.chunks[i].entryDigest`
|
|
- **THEN** the iterator throws `PackerError(PACKER.INTEGRITY_FAILED)` before
|
|
yielding plaintext for the mismatched chunk
|
|
|
|
#### Scenario: chunked unpack rejects merkle-root mismatch
|
|
|
|
- **WHEN** `unpackChunksWithManifest(chunks, manifest, opts)` is given a
|
|
manifest whose `merkleRoot` does not equal `merkleRootBlake3(...)` of its
|
|
entries
|
|
- **THEN** the iterator throws `PackerError(PACKER.INTEGRITY_FAILED)` before
|
|
consuming any chunk
|
|
|
|
**Verified by:** `src/packer/packer.spec-conformance.test.ts::"rejects unpack when metadata.integrity is absent"` AND `src/packer/packer.spec-conformance.test.ts::"rejects unpack on mutated entryDigest"` AND `src/packer/packer.spec-conformance.test.ts::"rejects unpack on mutated merkleRoot"` AND `src/chunked/unpacker/unpacker-with-manifest.spec-conformance.test.ts::"rejects per-chunk BLAKE3 mismatch with INTEGRITY_FAILED"` AND `src/chunked/unpacker/unpacker-with-manifest.spec-conformance.test.ts::"rejects manifest with inconsistent merkleRoot"`
|
|
|
|
---
|
|
|
|
### Requirement: PACKER-20 — OpenTelemetry instrumentation on every public entrypoint
|
|
|
|
The package SHALL emit OpenTelemetry counters, histograms, and spans on every
|
|
public entrypoint via an injected `PackerObservability` bundle. The named
|
|
instruments are:
|
|
|
|
| Instrument | Type | Unit |
|
|
|---|---|---|
|
|
| `packer_bundle_size_bytes` | counter | `By` |
|
|
| `packer_entry_count` | counter | `1` |
|
|
| `packer_pack_total` | counter | `1` |
|
|
| `packer_unpack_total` | counter | `1` |
|
|
| `packer_integrity_failure_total` | counter | `1` |
|
|
| `packer_pack_duration_ms` | histogram | `ms` |
|
|
| `packer_unpack_duration_ms` | histogram | `ms` |
|
|
| `packer_entry_decode_ms` | histogram | `ms` |
|
|
|
|
Spans MUST wrap `Packer.pack`, `Unpacker.unpack`, `packChunks`,
|
|
`unpackChunks`, and `unpackChunksWithManifest`. When no observability bundle
|
|
is injected, the package MUST fall back to the no-op meter and tracer obtained
|
|
from `@opentelemetry/api` (`metrics.getMeter` / `trace.getTracer`); no
|
|
instrument call may throw.
|
|
|
|
#### Scenario: pack emits counters, histogram, and span
|
|
|
|
- **WHEN** `Packer.pack(data)` runs under a real in-memory `MeterProvider`
|
|
and `BasicTracerProvider`
|
|
- **THEN** the collected metrics include a `packer_pack_total` increment,
|
|
a `packer_bundle_size_bytes` increment equal to the output byte length,
|
|
a `packer_entry_count` increment of `1`, and a `packer_pack_duration_ms`
|
|
histogram observation; the collected spans include one whose name is
|
|
`packer.pack`
|
|
|
|
#### Scenario: unpack emits counters, histogram, and span
|
|
|
|
- **WHEN** `Unpacker.unpack(packed)` runs under a real in-memory provider
|
|
- **THEN** the collected metrics include `packer_unpack_total` (+1) and a
|
|
`packer_unpack_duration_ms` histogram observation; spans include `packer.unpack`
|
|
|
|
#### Scenario: integrity failure increments failure counter
|
|
|
|
- **WHEN** `Unpacker.unpack` rejects with `PACKER.INTEGRITY_FAILED`
|
|
- **THEN** `packer_integrity_failure_total` is incremented by 1 and the span
|
|
is recorded with status ERROR
|
|
|
|
#### Scenario: chunked paths emit spans and per-entry decode histogram
|
|
|
|
- **WHEN** `packChunks` / `unpackChunks` / `unpackChunksWithManifest` are
|
|
iterated to completion
|
|
- **THEN** spans `packer.pack_chunks` / `packer.unpack_chunks` /
|
|
`packer.unpack_chunks_with_manifest` are recorded and one
|
|
`packer_entry_decode_ms` observation is recorded per consumed chunk
|
|
|
|
**Verified by:** `src/observability/observability.spec-conformance.test.ts::"records packer_pack_total and packer_bundle_size_bytes on every pack"` AND `src/observability/observability.spec-conformance.test.ts::"records packer_unpack_total and a duration histogram on every unpack"` AND `src/observability/observability.spec-conformance.test.ts::"increments packer_integrity_failure_total when unpack rejects on integrity"` AND `src/observability/observability.spec-conformance.test.ts::"emits a packer.pack span captured by an in-memory span exporter"` AND `src/observability/observability.spec-conformance.test.ts::"emits a packer.unpack_chunks_with_manifest span and per-entry decode histogram observations"`
|
|
|
|
---
|
|
|
|
### Requirement: PACKER-21 — i18n-keyed error messages via injected MessageResolver
|
|
|
|
The package SHALL route every user-visible error message through an injected
|
|
`MessageResolver` (`@suaveplan/types`). A per-package English-defaults catalog
|
|
lives at `src/i18n/messages.ts` keyed by
|
|
`packer.error.<CODE>.<scenario>`. Constructor options
|
|
(`PackerOptions.messageResolver`, `PackChunkedOptions.messageResolver`,
|
|
`UnpackChunkedOptions.messageResolver`) accept a caller-supplied resolver;
|
|
when absent, the package falls back to `defaultPackerResolver` which reads
|
|
the local catalog. Every `throw new PackerError(CODE, message)` MUST pass a
|
|
resolver-returned string, never a hard-coded English literal.
|
|
|
|
#### Scenario: every catalog key is reachable from a throw site
|
|
|
|
- **WHEN** the test suite walks the catalog at `i18n/messages.ts`
|
|
- **THEN** every key is also a string literal passed to the resolver in at
|
|
least one `throw` site in `src/`
|
|
|
|
#### Scenario: custom resolver intercepts every throw
|
|
|
|
- **WHEN** an `Unpacker` is constructed with a custom `messageResolver` that
|
|
returns `"LOCALE_X:" + key` and `unpack` is called on inputs that trigger
|
|
every error path
|
|
- **THEN** every thrown `PackerError.message` is prefixed with `"LOCALE_X:"`
|
|
|
|
#### Scenario: default resolver returns the English catalog value
|
|
|
|
- **WHEN** a `Packer` is constructed without `messageResolver`
|
|
- **THEN** errors thrown by `pack` carry the English catalog string for the
|
|
triggered key
|
|
|
|
**Verified by:** `src/i18n/messages.spec-conformance.test.ts::"every key in the catalog is referenced from at least one throw site in src/"` AND `src/i18n/messages.spec-conformance.test.ts::"custom MessageResolver is consulted by Unpacker.unpack on every error path"` AND `src/i18n/messages.spec-conformance.test.ts::"default resolver returns the English catalog value when no resolver is injected"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|