nova-cache-0.10.0.0: CHANGELOG.md
# Changelog
## 0.10.0.0 - 2026-08-20
- **Bounded zstd: the new `NovaCache.Zstd`, a public `nova-cache:zstandard` sublibrary.** The modern caches (Cachix, attic, FlakeHub) serve NARs zstd-compressed, and a cache of our own wants the same: near-xz ratio on binaries with decompression an order of magnitude faster, cheap enough to compress at push time (`compress`, frame content size recorded; `defaultCompressionLevel` is libzstd's own 3). `decompress` takes the narinfo's declared NarSize as its output bound and fails past it, and `withZstdSource` decompresses a chunk source into a chunk source under the same limits, pairing with streaming NAR consumption - the xz discipline, ported. Two deliberate divergences from `NovaCache.Xz`, documented in place: decoder-state memory is capped by libzstd's default window limit (128 MiB; the binding exposes no tunable), and a truncated input yields truncated output at this layer - the signed NarSize and NarHash checks above are the arbiter of completeness. Concatenated frames decode as one output, as upstream's sink accepts. The `zstd` dependency bundles libzstd's C sources; no system library on any platform. (`zstandard`, not `zstd`: an in-package component name shadows the like-named dependency, so a sublibrary called `zstd` could never depend on the `zstd` binding.)
## 0.9.0.0 - 2026-08-20
- **`NovaCache.Xz` moves to the public `nova-cache:xz` sublibrary; the `xz` flag is gone.** A dependency's flag cannot be set from a consumer's `.cabal` file, so reaching the decoder forced a mirrored flag plus a matching `constraints: nova-cache +xz` in every downstream - two knobs that had to agree and that the solver could not see. `build-depends: nova-cache:xz` now expresses the need directly, and consumers without it still never build the bundled liblzma, keeping the 0.5.0.0 lesson. Builds that passed `-f xz` drop the flag and add the dependency; the module and its API are unchanged.
## 0.8.0.0 - 2026-08-20
- **Bounded xz decompression returns: the new `NovaCache.Xz`, behind a manual, off-by-default `xz` flag.** 0.5.0.0 removed xz support for having no consumer; foreign-cache substitution (cache.nixos.org serves `.nar.xz`) is the consumer, and the decoder comes back shaped for untrusted input. The consumer knows the narinfo's declared NarSize before decompressing, so `decompress` takes that bound and fails past it - a small compressed input cannot expand to arbitrary memory ahead of the hash check - and the decoder's own state is capped too (`xzMaxDecoderMemoryBytes`; the dictionary size is an attacker-chosen number read from the stream header, and upstream passes no limit there). `withXzSource` decompresses a chunk source into a chunk source under the same limits, pairing with streaming NAR consumption. Concatenated streams decode as one output, matching upstream's `LZMA_CONCATENATED` decoder. The `lzma-static` dependency bundles liblzma's C sources, so the flag needs no system library on any platform - and it stays off by default anyway: the 0.5.0.0 lesson was a compression dependency nobody asked for in every install.
- **Streaming NAR parsing: the new `NovaCache.NAR.Stream`.** A pure, chunk-fed event machine over the NAR grammar: feed chunks as they arrive (a download, a decompressor) and act on events as entries complete, with regular-file contents passing through as slices of the fed chunks. Memory is bounded by the largest structural wire string - names, targets, and tokens are capped at 64 KiB, since without a bound one hostile 8-byte length prefix could demand an arbitrary allocation - never by archive or file size, which is what let nova-nix's substituter document an RSS spike the size of the path being fetched. `deserialise` is now the whole-input instantiation of the same machine (its structural bound is the input's own length, so it accepts exactly what it always accepted): the grammar exists once, and the entire existing NAR test suite runs against the shared core.
- **Streaming NAR serialisation: `NovaCache.NAR.withNarSource`.** Serialises a filesystem tree as a pull source of chunks without ever holding a file's contents in memory: structure (names, kinds, targets) is planned up front with the same case-hack resolution, bytewise entry order, and loud special-file/collision failures as `serialiseFromPath`, then regular files stream through 128 KiB reads. A file's size is read once when its streaming starts and exactly that many bytes are emitted, as upstream's dump does; a shrinking file fails loudly rather than emitting a torn archive. The empty-chunk end convention matches `writeNarStreaming`, so the two ends compose directly.
- **Incremental hashing: `hashInit`/`hashUpdate`/`hashFinalize` in `NovaCache.Hash`.** Pure persistent SHA-256 contexts, so a consumer can hash a NAR in the same pass that streams it and verify a narinfo's NarHash without materializing the archive.
- **The Windows reserved-device guard covers the platform's full set.** `isReservedDeviceName` - the shared predicate behind the NAR entry-name check and the store-key allowlist - compared the raw stem before the first dot against `con`/`prn`/`aux`/`nul` and `com1`-`com9`/`lpt1`-`lpt9`. Win32 device parsing also trims trailing spaces from the stem (`NUL .txt` still opens the device) and reserves `COM0`/`LPT0` and the superscript-digit forms (U+00B9/U+00B2/U+00B3, matched as their UTF-8 bytes), so archives naming `lpt0`, a space-padded device stem, or a superscript-digit device were accepted and would resolve to a device under a Windows extractor. The guard now matches Microsoft's documented reserved set - and the twin check nova-nix applies at materialization, which already rejected all three.
- **Size fields are capped at the uint64 maximum.** The 20-digit length bound on `NarSize`/`FileSize` still admitted values between 2^64 and 10^20 - 1; upstream's `string2Int<uint64_t>` refuses those, so such a narinfo would be validated and signed here and then rejected as corrupt by every real Nix client. Values above 18446744073709551615 now fail the parse.
- **Toolchain: GHC 9.14.1 (the first GHC LTS release)** - CI, the deploy and release pipelines, and the README badge move from GHC 9.8.4 to 9.14.1, matching nova-nix. GHC 9.14 opens GHC's new LTS scheme (a minimum of two years of bugfix releases), and under that scheme every earlier series stops receiving fixes, so no version in between had a future. Dependency bounds already admitted the newer compiler and the full set resolves on it. The project targets the LTS alone: base >= 4.22, and foldl' comes from the Prelude, so its one redundant Data.List import is gone.
## 0.7.0.0 - 2026-07-21
- **NAR entry names and symlink targets are byte strings.** The format imposes no text encoding on either, and upstream carries both verbatim; decoding them as UTF-8 at parse rejected archives real Nix accepts. `NarEntry` now carries `ByteString` for directory entry names and symlink targets (the breaking change behind the major bump), the parser stops decoding, and entry order is defined bytewise. For names both representations accept - valid UTF-8 - code-point order and byte order coincide, so previously-valid archives keep their exact bytes and hashes. The `checkName` rejection categories apply unchanged to the byte form; they are ASCII-structural, so they now also catch hazards inside names that do not decode (a `nul.` device stem followed by undecodable bytes used to fail as bad UTF-8 and still fails - for the real reason).
- **`serialiseFromPath` walks the filesystem byte-true.** The walk moves to the platform-native path type (`System.Directory.OsPath`). On POSIX, names and symlink targets enter the archive as the raw bytes the filesystem reports - previously a non-UTF-8 on-disk name was silently rewritten with replacement characters, changing the archived name and hash. On Windows, names are the UTF-8 encoding of their UTF-16 spelling, and a name holding an unpaired surrogate (no UTF-8 form exists, and upstream defines no byte spelling) fails loudly instead of guessing. Public signatures are unchanged.
- **`NovaCache.SafeName` predicates take bytes.** `isReservedDeviceName` and `hasTrailingDotOrSpace` operate on `ByteString`, the form NAR entry names have; Text callers (the store-key allowlist) encode first.
- **`caseHackSuffix` is a `ByteString`**, matching the entry names it marks.
- Dependency floors rise to `directory >= 1.3.8` and `filepath >= 1.4.100` (the `OsPath` API); both the 1.4 and 1.5 `filepath` lineages are supported.
## 0.6.0.0 - 2026-07-20
- **NAR serialisation understands upstream's case-hack.** A case-folding store filesystem (Windows NTFS, default macOS APFS) cannot hold two sibling names differing only by case, so an extractor there materializes the collision with upstream's reversible `~nix~case~hack~<N>` suffix. `serialiseFromPath` now strips the suffix on those platforms - entries are emitted under their NAR names, ordered by them - so a hacked tree reproduces its original NAR bytes; two on-disk names stripping to the same entry fail loudly. New `serialiseFromPathWith` takes the mode explicitly (`CaseHack`, `defaultCaseHack`, `caseHackSuffix` exported); on other platforms a file legitimately named with the suffix still serialises verbatim. The platform-dependent default of `serialiseFromPath` is the behavior change behind the major bump.
- **NAR entry names are checked against Windows resolution hazards.** `checkName` now also rejects a colon anywhere (drive prefix `C:evil`, alternate data stream `a:b`), Windows reserved device names (`nul`, `con`, `com1`..., matched on the portion before the first dot), and names ending in a dot or space (NTFS strips both, so the on-disk name would silently diverge from the NAR name). An extractor relying on `checkName` can no longer be steered outside its target directory or into a device by an archive entry name.
- **`sanitizePath` rejects a trailing dot.** The store-key allowlist already excluded spaces and leading dots; a trailing dot slipped through and NTFS would strip it, landing the file under a different name than the one validated. The Windows-unsafe categories now live in one shared module, `NovaCache.SafeName`, used by both the store-key and NAR entry-name guards.
- **Store-path names reject dot segments.** `parseBaseName` accepted `.`, `..`, and their `.-x` / `..-y` prefixed forms, so paths no Nix client parses could be stored and signed. The rule is upstream's: the first dash-separated component may not be `.` or `..`, while other dot-leading names (`.config-1.0`) stay valid.
- **Size fields are length-bounded before parsing.** `NarSize`/`FileSize` parsed into an unbounded `Integer` digit by digit - quadratic in the field length. Sizes are uint64 on the wire (at most 20 digits); longer fields are rejected before the parse, making its cost constant.
- **Duplicate scalar narinfo keys resolve last-wins**, matching upstream's assign-as-read parser. `Sig` remains the intentionally repeatable key.
- **The NAR executable marker must be empty.** The parser accepted any marker value where the format fixes it as the empty string; a nonempty value is now rejected, as upstream does.
- **`SecretKey` no longer derives `Show` or `Eq`.** The derived `Show` rendered the raw Ed25519 key bytes through any enclosing `Show` (config records, debug traces), and the derived `Eq` compared secret material in input-dependent time. `Show` now renders the key name and a redaction marker; `Eq` compares the bytes in constant time.
- **New `NovaCache.Server.newTTLCache`**, and the bundled server's landing page uses it: the unauthenticated root route paid a full narinfo-store scan per request to render the path-count stat; the count now refreshes at most once per minute, keeping per-request work bounded regardless of store size.
## 0.5.0.0 - 2026-07-12
- **Signing: fingerprints sort and deduplicate references.** C++ Nix computes and verifies narinfo fingerprints over a sorted, deduplicated store-path set; signing in the narinfo's file order produced signatures real Nix clients reject while nova-cache's own `verify` (recomputing from the same order) passed and masked the divergence. References are now sorted by basename and deduplicated before signing.
- **Removed `NovaCache.Compression`, the `compression` flag, and the `lzma` dependency.** The module had no consumer, and the default-on manual flag made every Hackage install require system liblzma dev files, which plain Windows and minimal Linux machines lack - `cabal install` of downstream packages failed while CI (which pins the flag off inside the repo) stayed green. xz support returns with its first real consumer as a size-bounded decoder suitable for untrusted cache data.
- **Wire-format strictness now matches upstream Nix.** `validateNarInfo` requires the StorePath field to be absolute and references to be bare basenames (the other spellings produce narinfos real clients reject at parse time, and a bare StorePath also derived an empty store dir inside the signed fingerprint); store-path names are ASCII-only and capped at 211 characters; NAR parsing rejects backslashes in entry names (the Windows traversal vector) and nonzero string padding; and key parsing rejects an empty name or empty key material at load time instead of producing signatures no trust anchor can match. New `parseStorePathBaseName` and `parseAbsoluteStorePath` expose the per-field parsers.
- **Upstream-optional narinfo fields are now optional.** Only StorePath, URL, NarHash, and NarSize are required; `Compression` defaults to bzip2 as upstream, and `FileHash`/`FileSize` are `Maybe` (breaking record change, covered by the major bump). Valid narinfos from foreign caches no longer fail to parse over absent optional fields.
- **New `NovaCache.Server` module: the cache's HTTP protocol as a WAI `Application`.** Routing, write authentication, request-body limits, and the narinfo validation/signing pipeline move from the server executable into tested library API. Deployment branding stays out of the library: embedders supply their own root-page response, and the bundled executable carries its landing page itself. Adds `wai` and `http-types` to the library dependencies.
- **`GET /narinfo-hashes` requires the write key and is `Cache-Control: no-store`.** The listing enumerates the whole store - something the public cache protocol deliberately never offers - and lists a directory per hit; it exists only for the push tool, which already holds the write key.
- **NAR bodies no longer transit memory.** Uploads stream to a temp file under a running size cap and rename into place atomically (`NovaCache.Store.writeNarStreaming`); downloads are served from disk via WAI's `responseFile` (`NovaCache.Store.narFilePath`). A multi-GB NAR previously occupied that much RAM per request in both directions.
- **`HEAD` is answered wherever `GET` is.** Clients probing narinfo existence with `HEAD` previously got 404.
- **The server refuses to start when `CACHE_API_KEY` normalizes to empty** (BOM or whitespace only - the copy-paste artifact). An empty armed key would authenticate an empty bearer token.
- **The server refuses to start when a configured signing key fails to load.** It previously logged a warning and ran unsigned, persisting narinfos no trust anchor can verify - the same misconfiguration class the key parser now rejects, closed at the process boundary too.
- **Configurable bind host: `--host` / `HOST`.** The default stays all interfaces, so existing deployments do not silently rebind.
- The deploy workflow pins cloudflared by version and checksum instead of pulling `latest`, and builds the server on GitHub's hosted arm64 image (same Ubuntu as the production box), shipping the binary over the Access tunnel - the production host deliberately carries no compiler toolchain.
- **The sdist ships `NOTICE`.** Apache-2.0 section 4(d) asks redistributions to carry it; the file existed in the repo but not in the released tarball.
- **CI builds from the sdist in isolation**, so tree-vs-tarball divergences (files missing from the tarball, dev-only project settings) fail the pipeline instead of surfacing at install time. CI also compiles the server executable and test suites under `-Werror` on every platform.
- Dropped the server executable's unused `crypton` dependency.
- Workflows run with a read-only `GITHUB_TOKEN`.
## 0.4.2.1 - 2026-06-12
- **Relicensed from BSD-3-Clause to Apache-2.0.** Apache adds an explicit patent grant and trademark terms, and a `NOTICE` file now carries the copyright (Novavero AI Inc.). Earlier releases on Hackage remain under their original licenses.
- **Source is now ASCII-only**, enforced by a CI check. Removes some mis-encoded characters from comments and keeps the tree clean and portable.
- Widened the `containers` upper bound to allow `containers` 0.8.
- Server: the landing page wraps the config snippet and reflows the stats grid on small screens.
## 0.4.2.0 - 2026-06-10
- **Signing: public-key derivation and rendering** - new `toPublicKey`
(derive the `PublicKey` for a `SecretKey`) and `renderPublicKey` (render
the `name:base64` trust-anchor format clients put in
`trusted-public-keys`).
- **Signing: `normalizeKeyText`** - strips byte-order marks and surrounding
whitespace from key text. The server applies it to `CACHE_API_KEY` and
the signing key file, so keys that picked up a BOM or stray CRLF in
transit load byte-clean instead of silently failing auth or signing.
- **Server: the landing page shows the cache public key**, derived at
startup from the live signing key, as a copyable `trusted-public-keys`
line. The published trust anchor can no longer drift from the key in
use.
- README: the public cache key now matches the key the server signs with.
- Removed the CI seed action and its README section; native pushing from
nova-nix replaces it.
## 0.4.1.1 - 2026-06-10
- Documentation-only release: the 0.4.1.0 entry below now records everything
that release actually contained. No code changes.
## 0.4.1.0 - 2026-06-10
- **Server: landing page at `GET /`** - a human-facing page with live stats
(store-path count, signing status, priority), the substituter snippet, and
protocol endpoint documentation. The cache protocol routes are unchanged;
the page is briefly cacheable (`max-age=300, must-revalidate`) since its
stats change as paths are added.
- **Correctness and hardening sweep** (a post-0.4.0.0 audit; note: shipped in
this release without individual entries at tag time - recorded here for the
honest history):
- `Base32.decode` rejects non-canonical encodings (overflow bits must be zero).
- NAR deserialisation is strict on untrusted input: trailing bytes rejected,
regular nodes require a `contents` token, directory entry names must be
sorted, unique, and safe.
- narinfo integer fields parse as strict base-10 (no hex/octal/whitespace),
so a non-canonical `NarSize` can no longer be re-signed under the cache key.
- Hash validation compares decoded digest bytes, accepting any valid
encoding of the same hash.
- Ed25519 verification is name-keyed, matching Nix's lookup-by-name.
- `decompressXz` re-raises asynchronous exceptions, so timeouts can cancel it.
- Server: per-route body caps (narinfo small, NAR large), uploaded narinfos
must name the store path of the request URL, signing failures refuse the
write instead of storing unsigned, and narinfo responses are revalidatable
rather than immutable.
- **Breaking (should have been a major bump; noted for transparency):**
`getCacheInfo` returns a `CacheInfo` record instead of a tuple.
## 0.4.0.0 - 2026-06-08
### Breaking changes
- **`NarInfo` drops the `niSystem` field** - `System` is not part of the Nix
narinfo wire format (Nix ignores unknown keys), so it is removed from the
exported record and `renderNarInfo` no longer emits a `System:` line.
### Security
- **API key required for writes** - the server refuses to start when
`CACHE_API_KEY` is unset, unless `--allow-open-writes` is passed explicitly,
so a missing environment variable can no longer leave the cache
world-writable.
- **Streaming request-body cap** - request bodies are read in bounded chunks
with a running size check, so an unsized (chunked) upload can no longer
buffer past the 100 MB limit into memory before it is rejected.
- **Stricter path validation** - `sanitizePath` now accepts only
`[A-Za-z0-9._+-]` names that are neither dotfiles nor Windows reserved device
names (`nul`, `con`, `com1`...), rejecting device access, alternate-data-stream
syntax, and the temporary-write prefix in addition to traversal.
- **No internal detail in error responses** - store reads tolerate I/O errors
(treated as absence) and any uncaught handler exception maps to a plain 500,
so filesystem paths and error text are never returned to clients.
### Bug fixes
- **CRLF-tolerant narinfo parsing** - lines are split on the first colon with a
trailing carriage return stripped, so narinfo produced by other tools (or
with `\r\n` line endings) parses correctly instead of corrupting text fields
or rejecting valid integer fields.
## 0.3.2.1 - 2026-03-18
- Replace partial `decodeUtf8` with total `decodeLatin1` in `Base64.encode`
and `Signing.sign` - provably safe on base64 ASCII output, eliminates last
partial function usage
- Fix redundant `T.breakOn` call in `NarInfo.parseLine`
- No API changes
## 0.3.2.0 - 2026-03-18
### Server logging
- **Request logging** - Apache Combined format via `wai-extra` middleware,
configurable with `LOG_REQUESTS=0` to disable.
- **Error logging** - auth rejections, validation failures, oversized uploads,
and bad paths now logged to stderr with request method and path context.
- **`withLimitedBody` combinator** - extracted common body-limiting pattern from
PUT handlers, reducing duplication.
- **`notFound` named constant** - replaces three inline 404 responses.
- Fixed `loadSigningKey` warnings going to stdout instead of stderr.
### Seed action fixes
- **Resolve runtime outputs, not derivations** - `nix-instantiate` replaced
with `nix-build --no-out-link` so the cache stores actual binaries instead of
`.drv` build recipes.
- **Filter uploads to diff-only** - `nix copy` exports transitive deps; uploads
now filtered against the missing-hashes diff to avoid re-uploading cached paths.
- **Fix broken pipe** - `find | xargs` with `pipefail` caused spurious failures;
now writes to intermediate files.
- **Fix xargs line-too-long** - large path lists passed via file instead of inline.
- **Diagnostic output** - upload failure HTTP codes now printed; `nix copy` errors
no longer silenced.
- **Parallelism** - per-upload `--max-time` added (120s for NARs, 30s for narinfos).
### Server validation
- **Reject derivation narinfos** - `validateNarInfo` now rejects any narinfo
where StorePath ends in `.drv` with a new `DerivationStorePath` error. Binary
caches serve build outputs, not build recipes.
- 1 new test (75 total)
## 0.3.1.0 - 2026-03-07
### Drop `memory` dependency, use `ram`
- **`memory` -> `ram`** - Replaced `memory` package with `ram` (modern, minimal replacement). Same `Data.ByteArray` API, drops the heavy `basement` transitive dependency. Fixes build failure with `crypton >= 1.1` which switched from `memory` to `ram` internally - `Data.ByteArray.ByteArrayAccess` instances were no longer compatible across packages.
- **`crypton >= 1.1` required** - Lower bound bumped from `1.0` to `1.1` so that `crypton` and `nova-cache` both link against `ram` for `ByteArrayAccess` instances. Older `crypton` versions used `memory`, causing instance mismatches at link time.
- All imports remain `Data.ByteArray` - no source-level changes needed for downstream consumers.
## 0.3.0.0 - 2026-02-28
### Breaking changes
- **`decompressXz`** signature changed from `ByteString -> ByteString` to
`ByteString -> IO (Either String ByteString)` - catches lzma exceptions
instead of crashing on malformed input
- **`writeNarInfo`** / **`writeNar`** now return `IO Bool` instead of `IO ()` -
`False` indicates a rejected path (traversal, empty)
### Security
- **Request body size limit** - PUT endpoints reject bodies over 100 MB with
413 Payload Too Large (prevents memory exhaustion)
- **Constant-time auth comparison** - API key check uses `constEq` from
`Data.ByteArray` instead of `==` (prevents timing side-channel)
### Bug fixes
- **Safe UTF-8 decoding in NAR deserializer** - `decodeUtf8` replaced with
`decodeUtf8'`; malformed UTF-8 in symlink targets or directory entry names
now returns a parse error instead of throwing
### New features
- **`GET /narinfo-hashes`** endpoint - returns all cached narinfo hashes as
newline-delimited text, enabling efficient cache diffing
- **`listNarInfoHashes`** function added to `NovaCache.Store`
### Improvements
- Server logs warnings to stderr when narinfo signing fails (parse error or
sign error) instead of silently returning unsigned body
- Server returns 400 Bad Request when PUT paths fail sanitization instead of
silently discarding the upload
- README: license badge and footer corrected from MIT to BSD-3-Clause
- README: `parallel` input documented in seed action table
- README: `CACHE_API_KEY` and `SIGNING_KEY_FILE` env vars documented
- Seed action: replaced per-path HEAD checks with single `GET /narinfo-hashes`
call and local diff for dramatically faster cache seeding
## 0.2.4.1 - 2026-02-26
- License changed from MIT to BSD-3-Clause
## 0.2.4.0 - 2026-02-25
- New module: `NovaCache.Base64` - base64 encode/decode re-exported so
downstream consumers don't need a direct `base64-bytestring` dependency
- No changes to existing modules
## 0.2.3.0 - 2026-02-23
- Drop `unix` dependency - `checkExecutable` now uses cross-platform
`System.Directory.getPermissions` instead of `System.Posix.Files`
- Fixes Windows build (the `unix` package is not available on Windows)
- No API changes
## 0.2.2.0 - 2026-02-23
- Gate `NovaCache.Compression` and `lzma` dependency behind a `compression`
cabal flag (default on, backwards compatible)
- Consumers that only need hashing/NAR/narinfo can build with `-compression`
to avoid the system `liblzma` C dependency
- Compression tests moved to separate test suite (`nova-cache-compression-test`)
- No changes to any library source modules
## 0.2.1.0 - 2026-02-22
- Server: `validateNarInfo` wired into PUT handler - rejects malformed uploads
with 400 Bad Request and collected validation errors
- Server: `Cache-Control: public, max-age=31536000, immutable` on narinfo and
NAR GET responses for CDN edge caching
- Store: default priority changed from 30 to 50 (community cache fallback
behind cache.nixos.org at 40)
- Seed action: fix round-trip validation to account for server-side signing;
now verifies StorePath field, signature presence, and NAR fetchability
- Public binary cache documented with key and nix.conf instructions
## 0.2.0.0 - 2026-02-22
- New module: `NovaCache.Validate` - pure protocol validation layer
- `ValidationError` sum type with 10 constructors covering sizes, store paths,
hash formats, content hashes, and Ed25519 signatures
- `validateNarInfo` - field semantic validation (non-negative sizes, parseable
store paths/hashes/references), collects all errors instead of short-circuiting
- `validateNarHash` / `validateFileHash` - SHA-256 content hash verification
against declared narinfo values
- `validateSignature` - Ed25519 signature verification against a trusted public
key (at-least-one semantics, matching Nix behaviour)
- `validateFull` - composes all four stages, collecting errors across all
- 17 new tests (74 total across 9 groups)
- No new dependencies
## 0.1.0.0 - 2026-02-21
- Initial release (renamed from gb-nix-cache)
- Nix-base32 encoding/decoding
- SHA-256 hashing with Nix hash formatting
- Store path parsing and rendering
- NAR binary format serialization/deserialization
- NarInfo text format parsing/rendering
- Ed25519 signing and verification
- xz compression/decompression
- Filesystem storage backend
- Optional WAI cache server (behind `server` flag)
- Path traversal protection on all store operations
- Total port parsing (no partial `read`)
- 54 tests across 8 modules