packages feed

nova-cache 0.1.0.0 → 0.2.0.0

raw patch · 5 files changed

+428/−8 lines, 5 files

Files

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     ]  -- ---------------------------------------------------------------------------