nova-cache 0.1.0.0 → 0.2.0.0
raw patch · 5 files changed
+428/−8 lines, 5 files
Files
- CHANGELOG.md +18/−0
- README.md +78/−5
- nova-cache.cabal +4/−2
- src/NovaCache/Validate.hs +156/−0
- test/Main.hs +172/−1
CHANGELOG.md view
@@ -1,5 +1,20 @@ # Changelog +## 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)@@ -12,3 +27,6 @@ - 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
README.md view
@@ -27,6 +27,7 @@ - **Signing** — Ed25519 fingerprint signing and verification for binary cache trust - **Compression** — xz compress/decompress for NAR transport - **Store** — Filesystem storage backend for narinfo and NAR files+- **Validate** — Pure protocol validation: field semantics, content hashes, Ed25519 signatures — all errors collected, not short-circuited - **Server** — Optional WAI/Warp HTTP server implementing the cache protocol (behind `server` cabal flag) Every module is pure by default. IO lives at the boundaries only.@@ -91,6 +92,26 @@ Right sig -> putStrLn ("Verified: " <> show (verify pk narinfo sig)) ``` +### Validate an Upload++```haskell+import NovaCache.Validate (validateFull, ValidationError)+import NovaCache.Signing (parsePublicKey)++-- Pure validation — no IO needed+validateUpload :: PublicKey -> NarInfo -> ByteString -> ByteString -> IO ()+validateUpload pk narinfo narBytes fileBytes =+ case validateFull pk narinfo narBytes fileBytes of+ Right () -> putStrLn "Upload valid"+ Left errs -> mapM_ (putStrLn . (" " ++) . show) errs++-- Individual checks compose too+import NovaCache.Validate (validateNarInfo, validateNarHash)++checkNarInfo :: NarInfo -> Either [ValidationError] NarInfo+checkNarInfo = validateNarInfo -- collects ALL errors, not just the first+```+ --- ## Modules@@ -163,6 +184,27 @@ -- "1;/nix/store/abc...;sha256:...;5678;" ``` +### NovaCache.Validate++Pure protocol validation — field semantics, content hashes, and signatures. All validators collect every error instead of short-circuiting:++```haskell+import NovaCache.Validate++-- Validate narinfo fields (sizes, store path, hash formats, references)+case validateNarInfo narinfo of+ Right ni -> processUpload ni+ Left errs -> rejectWith errs -- [InvalidStorePath ..., NegativeFileSize ...]++-- Verify content hashes match declared values+validateNarHash narinfo rawNarBytes -- Either ValidationError ()+validateFileHash narinfo compressedBytes -- Either ValidationError ()++-- Full pipeline: fields + nar hash + file hash + signatures+validateFull publicKey narinfo narBytes fileBytes+-- Either [ValidationError] ()+```+ --- ## Server@@ -211,8 +253,8 @@ │ Base32 ──→ Hash ──→ StorePath │ │ │ │ │ NarInfo ──→ Signing │- │ │ │- │ NAR │+ │ │ │ │+ │ NAR Validate │ │ │ └──────────────────────────────────────────┘ │@@ -222,8 +264,8 @@ └──────────────────────────────────────────┘ ``` -- **8 modules**, 6 pure + 2 at the IO boundary-- **48 tests**, hand-rolled harness, no framework dependencies+- **9 modules**, 7 pure + 2 at the IO boundary+- **74 tests**, hand-rolled harness, no framework dependencies - **Zero partial functions** — total by construction - **Strict by default** — bang patterns on all data fields @@ -282,6 +324,16 @@ verify :: PublicKey -> NarInfo -> Text -> Bool ``` +### Validate++```haskell+validateNarInfo :: NarInfo -> Either [ValidationError] NarInfo+validateNarHash :: NarInfo -> ByteString -> Either ValidationError ()+validateFileHash :: NarInfo -> ByteString -> Either ValidationError ()+validateSignature :: PublicKey -> NarInfo -> Either [ValidationError] ()+validateFull :: PublicKey -> NarInfo -> ByteString -> ByteString -> Either [ValidationError] ()+```+ ### Compression ```haskell@@ -304,11 +356,32 @@ --- +## GitHub Action — CI Cache Seeding++A reusable composite action is included for pushing Nix store paths to a nova-cache server from CI:++```yaml+- uses: Novavero-AI/nova-cache/.github/actions/seed@main+ with:+ cache-url: https://cache.example.com+ api-key: ${{ secrets.CACHE_API_KEY }}+```++| Input | Required | Description |+|-------|----------|-------------|+| `cache-url` | yes | Base URL of the nova-cache server |+| `api-key` | yes | Bearer token for authenticating uploads |+| `paths` | no | Explicit store paths (space-separated). Defaults to all paths from `shell.nix` / `default.nix` |++The action resolves Nix store path closures, exports them to a local binary cache, and uploads all narinfo + NAR files to the server. Works with any CI that has Nix installed.++---+ ## Build & Test ```bash cabal build # Build library-cabal test # Run all tests (48 tests, 8 groups)+cabal test # Run all tests (74 tests, 9 groups) cabal build --ghc-options="-Werror" # Warnings as errors cabal build --flag server # Build with WAI server cabal haddock # Generate docs
nova-cache.cabal view
@@ -1,11 +1,12 @@ cabal-version: 3.0 name: nova-cache-version: 0.1.0.0+version: 0.2.0.0 synopsis: Pure Nix binary cache protocol library description: A focused, minimal, pure-first library implementing the full Nix binary cache protocol — nix-base32, NAR serialization, narinfo parsing, Ed25519- signing, store path handling — with an optional WAI server.+ signing, store path handling, content validation — with an optional WAI+ server. license: MIT license-file: LICENSE@@ -36,6 +37,7 @@ NovaCache.Signing NovaCache.Store NovaCache.StorePath+ NovaCache.Validate build-depends: base >= 4.16 && < 5
+ src/NovaCache/Validate.hs view
@@ -0,0 +1,156 @@+-- | Pure protocol validation for narinfo and NAR content.+--+-- Validates narinfo field semantics (sizes, store paths, hash formats),+-- NAR/file content hashes against declared values, and Ed25519 signatures.+-- All functions are pure — no IO, no side effects.+module NovaCache.Validate+ ( ValidationError (..),+ validateNarInfo,+ validateNarHash,+ validateFileHash,+ validateSignature,+ validateFull,+ )+where++import Data.ByteString (ByteString)+import Data.Text (Text)+import NovaCache.Hash (formatNixHash, hashBytes, parseNixHash)+import NovaCache.NarInfo (NarInfo (..))+import NovaCache.Signing (PublicKey, verify)+import NovaCache.StorePath (defaultStoreDir, parseStorePath)++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Validation errors for narinfo and NAR content.+data ValidationError+ = -- | FileSize field is negative.+ NegativeFileSize !Integer+ | -- | NarSize field is negative.+ NegativeNarSize !Integer+ | -- | StorePath field does not parse. (raw value, parse error)+ InvalidStorePath !Text !String+ | -- | FileHash field does not parse. (raw value, parse error)+ InvalidFileHash !Text !String+ | -- | NarHash field does not parse. (raw value, parse error)+ InvalidNarHash !Text !String+ | -- | A reference does not parse as a store path basename. (raw value, parse error)+ InvalidReference !Text !String+ | -- | Computed NAR hash differs from declared. (expected, actual)+ NarHashMismatch !Text !Text+ | -- | Computed file hash differs from declared. (expected, actual)+ FileHashMismatch !Text !Text+ | -- | A signature line failed verification.+ SignatureInvalid !Text+ | -- | The narinfo has zero signatures.+ NoSignatures+ deriving (Eq, Show)++-- ---------------------------------------------------------------------------+-- NarInfo field validation+-- ---------------------------------------------------------------------------++-- | Validate narinfo field semantics: sizes non-negative, store path parses,+-- hash fields parse, references parse. Collects all errors (not short-circuit).+-- Returns the 'NarInfo' unchanged on success for composition.+validateNarInfo :: NarInfo -> Either [ValidationError] NarInfo+validateNarInfo ni =+ case concat [sizeErrors, storePathErrors, fileHashErrors, narHashErrors, refErrors] of+ [] -> Right ni+ errs -> Left errs+ where+ sizeErrors =+ [NegativeFileSize (niFileSize ni) | niFileSize ni < 0]+ ++ [NegativeNarSize (niNarSize ni) | niNarSize ni < 0]++ storePathErrors = case parseStorePath defaultStoreDir (niStorePath ni) of+ Left err -> [InvalidStorePath (niStorePath ni) err]+ Right _ -> []++ fileHashErrors = case parseNixHash (niFileHash ni) of+ Left err -> [InvalidFileHash (niFileHash ni) err]+ Right _ -> []++ narHashErrors = case parseNixHash (niNarHash ni) of+ Left err -> [InvalidNarHash (niNarHash ni) err]+ Right _ -> []++ refErrors = concatMap checkRef (niReferences ni)++ checkRef ref = case parseStorePath defaultStoreDir ref of+ Left err -> [InvalidReference ref err]+ Right _ -> []++-- ---------------------------------------------------------------------------+-- Content hash validation+-- ---------------------------------------------------------------------------++-- | Validate that the SHA-256 hash of raw uncompressed NAR bytes matches+-- the declared 'niNarHash'.+validateNarHash :: NarInfo -> ByteString -> Either ValidationError ()+validateNarHash ni narBytes =+ let actual = formatNixHash (hashBytes narBytes)+ expected = niNarHash ni+ in if expected == actual+ then Right ()+ else Left (NarHashMismatch expected actual)++-- | Validate that the SHA-256 hash of compressed file bytes matches+-- the declared 'niFileHash'.+validateFileHash :: NarInfo -> ByteString -> Either ValidationError ()+validateFileHash ni fileBytes =+ let actual = formatNixHash (hashBytes fileBytes)+ expected = niFileHash ni+ in if expected == actual+ then Right ()+ else Left (FileHashMismatch expected actual)++-- ---------------------------------------------------------------------------+-- Signature validation+-- ---------------------------------------------------------------------------++-- | Validate signatures against a trusted public key. At least one signature+-- must verify (matches Nix behaviour — any trusted key suffices).+-- Returns 'Left [NoSignatures]' when there are no signatures at all.+validateSignature :: PublicKey -> NarInfo -> Either [ValidationError] ()+validateSignature pk ni+ | null sigs = Left [NoSignatures]+ | any (verify pk ni) sigs = Right ()+ | otherwise = Left (map SignatureInvalid sigs)+ where+ sigs = niSigs ni++-- ---------------------------------------------------------------------------+-- Full validation+-- ---------------------------------------------------------------------------++-- | Compose all validation stages: narinfo fields, NAR hash, file hash,+-- and signatures. Collects all errors from all stages.+validateFull ::+ PublicKey ->+ NarInfo ->+ ByteString ->+ ByteString ->+ Either [ValidationError] ()+validateFull pk ni narBytes fileBytes =+ case concat [narInfoErrors, narHashErrors, fileHashErrors, sigErrors] of+ [] -> Right ()+ errs -> Left errs+ where+ narInfoErrors = case validateNarInfo ni of+ Left errs -> errs+ Right _ -> []++ narHashErrors = case validateNarHash ni narBytes of+ Left err -> [err]+ Right _ -> []++ fileHashErrors = case validateFileHash ni fileBytes of+ Left err -> [err]+ Right _ -> []++ sigErrors = case validateSignature pk ni of+ Left errs -> errs+ Right _ -> []
test/Main.hs view
@@ -16,6 +16,7 @@ import qualified NovaCache.Signing as Signing import qualified NovaCache.Store as Store import qualified NovaCache.StorePath as StorePath+import qualified NovaCache.Validate as Validate import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) import System.Exit (exitFailure, exitSuccess) import System.IO (hFlush, stdout)@@ -122,7 +123,8 @@ testNarInfo, testSigning, testCompression,- testFileStore+ testFileStore,+ testValidate ] -- ---------------------------------------------------------------------------@@ -569,6 +571,175 @@ result <- Store.readNarInfo store "../../etc/passwd" removeDirectoryRecursive tmpDir assertEqual "blocked" Nothing result+ ]++-- ---------------------------------------------------------------------------+-- Validate tests+-- ---------------------------------------------------------------------------++-- | Bytes whose SHA-256 is used as the NarHash in 'mkValidNarInfo'.+validNarBytes :: ByteString+validNarBytes = BS.pack [1, 2, 3, 4, 5]++-- | Bytes whose SHA-256 is used as the FileHash in 'mkValidNarInfo'.+validFileBytes :: ByteString+validFileBytes = BS.pack [10, 20, 30, 40, 50]++-- | A narinfo that passes all field validation.+mkValidNarInfo :: NarInfo.NarInfo+mkValidNarInfo =+ NarInfo.NarInfo+ { NarInfo.niStorePath = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0",+ NarInfo.niUrl = "nar/test.nar.xz",+ NarInfo.niCompression = "xz",+ NarInfo.niFileHash = Hash.formatNixHash (Hash.hashBytes validFileBytes),+ NarInfo.niFileSize = 5,+ NarInfo.niNarHash = Hash.formatNixHash (Hash.hashBytes validNarBytes),+ NarInfo.niNarSize = 5,+ NarInfo.niReferences = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0"],+ NarInfo.niDeriver = Nothing,+ NarInfo.niSystem = Nothing,+ NarInfo.niSigs = [],+ NarInfo.niCA = Nothing+ }++testValidate :: IO Bool+testValidate =+ runGroup+ "Validate"+ [ test "validateNarInfo valid" $+ assertEqual+ "valid narinfo"+ (Right mkValidNarInfo)+ (Validate.validateNarInfo mkValidNarInfo),+ test "validateNarInfo negative FileSize" $+ let ni = mkValidNarInfo {NarInfo.niFileSize = -1}+ in assertEqual+ "negative filesize"+ (Left [Validate.NegativeFileSize (-1)])+ (Validate.validateNarInfo ni),+ test "validateNarInfo negative NarSize" $+ let ni = mkValidNarInfo {NarInfo.niNarSize = -1}+ in assertEqual+ "negative narsize"+ (Left [Validate.NegativeNarSize (-1)])+ (Validate.validateNarInfo ni),+ test "validateNarInfo bad StorePath" $+ let ni = mkValidNarInfo {NarInfo.niStorePath = "not-a-store-path"}+ in case Validate.validateNarInfo ni of+ Left [Validate.InvalidStorePath raw _] ->+ assertEqual "raw value" "not-a-store-path" raw+ other -> do+ putStrLn (" expected Left [InvalidStorePath ..], got: " ++ show other)+ pure False,+ test "validateNarInfo bad FileHash" $+ let ni = mkValidNarInfo {NarInfo.niFileHash = "md5:bogus"}+ in case Validate.validateNarInfo ni of+ Left [Validate.InvalidFileHash raw _] ->+ assertEqual "raw value" "md5:bogus" raw+ other -> do+ putStrLn (" expected Left [InvalidFileHash ..], got: " ++ show other)+ pure False,+ test "validateNarInfo bad NarHash" $+ let ni = mkValidNarInfo {NarInfo.niNarHash = "md5:bogus"}+ in case Validate.validateNarInfo ni of+ Left [Validate.InvalidNarHash raw _] ->+ assertEqual "raw value" "md5:bogus" raw+ other -> do+ putStrLn (" expected Left [InvalidNarHash ..], got: " ++ show other)+ pure False,+ test "validateNarInfo bad reference" $+ let ni = mkValidNarInfo {NarInfo.niReferences = ["bad"]}+ in case Validate.validateNarInfo ni of+ Left [Validate.InvalidReference raw _] ->+ assertEqual "raw value" "bad" raw+ other -> do+ putStrLn (" expected Left [InvalidReference ..], got: " ++ show other)+ pure False,+ test "validateNarInfo multiple errors collected" $+ let ni =+ mkValidNarInfo+ { NarInfo.niFileSize = -1,+ NarInfo.niNarSize = -1,+ NarInfo.niStorePath = "bad"+ }+ in case Validate.validateNarInfo ni of+ Left errs -> assertTrue "at least 3 errors" (length errs >= 3)+ Right _ -> do+ putStrLn " expected Left, got Right"+ pure False,+ test "validateNarHash correct" $+ assertEqual+ "correct nar hash"+ (Right ())+ (Validate.validateNarHash mkValidNarInfo validNarBytes),+ test "validateNarHash wrong" $+ case Validate.validateNarHash mkValidNarInfo (BS.pack [99]) of+ Left (Validate.NarHashMismatch _ _) -> pure True+ other -> do+ putStrLn (" expected Left NarHashMismatch, got: " ++ show other)+ pure False,+ test "validateFileHash correct" $+ assertEqual+ "correct file hash"+ (Right ())+ (Validate.validateFileHash mkValidNarInfo validFileBytes),+ test "validateFileHash wrong" $+ case Validate.validateFileHash mkValidNarInfo (BS.pack [99]) of+ Left (Validate.FileHashMismatch _ _) -> pure True+ other -> do+ putStrLn (" expected Left FileHashMismatch, got: " ++ show other)+ pure False,+ test "validateSignature valid" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ ni = mkValidNarInfo+ case Signing.sign sk ni of+ Left err -> do+ putStrLn (" sign failed: " ++ err)+ pure False+ Right sig ->+ let niSigned = ni {NarInfo.niSigs = [sig]}+ in assertEqual "valid sig" (Right ()) (Validate.validateSignature pk niSigned),+ test "validateSignature invalid" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ bogusSig = "bogus-key:aW52YWxpZA=="+ ni = mkValidNarInfo {NarInfo.niSigs = [bogusSig]}+ assertEqual+ "invalid sig"+ (Left [Validate.SignatureInvalid bogusSig])+ (Validate.validateSignature pk ni),+ test "validateSignature no sigs" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ assertEqual+ "no sigs"+ (Left [Validate.NoSignatures])+ (Validate.validateSignature pk mkValidNarInfo),+ test "validateFull all good" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ ni = mkValidNarInfo+ case Signing.sign sk ni of+ Left err -> do+ putStrLn (" sign failed: " ++ err)+ pure False+ Right sig ->+ let niSigned = ni {NarInfo.niSigs = [sig]}+ in assertEqual+ "full valid"+ (Right ())+ (Validate.validateFull pk niSigned validNarBytes validFileBytes),+ test "validateFull multiple failures" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ ni = mkValidNarInfo {NarInfo.niFileSize = -1, NarInfo.niNarSize = -1}+ case Validate.validateFull pk ni (BS.pack [99]) (BS.pack [99]) of+ Left errs -> assertTrue "at least 4 errors" (length errs >= 4)+ Right _ -> do+ putStrLn " expected Left, got Right"+ pure False ] -- ---------------------------------------------------------------------------