packages feed

nova-cache 0.3.2.1 → 0.4.0.0

raw patch · 11 files changed

+227/−419 lines, 11 files

Files

CHANGELOG.md view
@@ -1,5 +1,37 @@ # Changelog +## 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`
README.md view
@@ -1,396 +1,97 @@ <div align="center"> <h1>nova-cache</h1>-<p><strong>Nix Binary Cache Protocol for Haskell</strong></p>-<p>Pure-first implementation of the complete Nix binary cache protocol — base32, NAR, narinfo, Ed25519 signing, store paths — with an optional WAI server.</p>-<p><a href="#quick-start">Quick Start</a> · <a href="#modules">Modules</a> · <a href="#server">Server</a> · <a href="#api-reference">API Reference</a></p>-<p>+<p><strong>The Nix binary cache protocol, in Haskell.</strong></p>+<p>nix-base32, NAR archives, narinfo, store paths, and Ed25519 signing — with an optional WAI cache server. A pure core; IO is confined to the compression, storage, and server boundaries.</p>  [![CI](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml) [![Hackage](https://img.shields.io/hackage/v/nova-cache.svg)](https://hackage.haskell.org/package/nova-cache)-![Haskell](https://img.shields.io/badge/haskell-GHC%209.8-purple)+![GHC](https://img.shields.io/badge/GHC-9.8-purple) ![License](https://img.shields.io/badge/license-BSD--3--Clause-blue) -</p> </div>  --- -## What is nova-cache?--A focused, minimal library implementing the full Nix binary cache protocol:--- **Base32** — Nix-specific encoding with the custom `0123456789abcdfghijklmnpqrsvwxyz` alphabet, O(n) ST-based decode-- **Hash** — SHA-256 hashing with `sha256:<nix-base32>` formatting, composition pipeline over crypton-- **StorePath** — Store path parsing, validation, and hash extraction with enforced invariants via newtypes-- **NAR** — Binary serialization and deserialization of the Nix ARchive format using `Builder` monoid composition-- **NarInfo** — Parse and render the key-value narinfo text format-- **Signing** — Ed25519 fingerprint signing and verification for binary cache trust-- **Compression** — xz compress/decompress for NAR transport (behind `compression` cabal flag, default on)-- **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.-------## Quick Start--Add to your `.cabal` file:+## Installation  ```cabal build-depends: nova-cache ``` -### Hash a Store Path+The `compression` flag (on by default) requires the system `liblzma`. Build+with `-f-compression` if you only need hashing, NAR, or narinfo. +## Usage+ ```haskell import NovaCache.Hash (hashBytes, formatNixHash) import qualified Data.ByteString as BS -main :: IO ()-main = do-  contents <- BS.readFile "/nix/store/abc123-hello/bin/hello"-  let nixHash = hashBytes contents-  putStrLn (show (formatNixHash nixHash))-  -- "sha256:0m6g5r7c..."+-- Hash file contents into sha256:<nix-base32>+hash <- formatNixHash . hashBytes <$> BS.readFile path ``` -### Parse a NarInfo- ```haskell-import NovaCache.NarInfo (parseNarInfo, niStorePath, niNarHash)+import NovaCache.NarInfo (parseNarInfo)+import NovaCache.Signing (parseSecretKey, sign) -main :: IO ()-main = do-  let raw = "StorePath: /nix/store/abc...-hello\n\-            \URL: nar/abc....nar.xz\n\-            \Compression: xz\n\-            \FileHash: sha256:...\n\-            \FileSize: 1234\n\-            \NarHash: sha256:...\n\-            \NarSize: 5678\n\-            \References: \n"-  case parseNarInfo raw of-    Left err -> putStrLn ("Parse error: " ++ err)-    Right ni -> do-      putStrLn ("Store path: " <> show (niStorePath ni))-      putStrLn ("NAR hash:   " <> show (niNarHash ni))+-- Parse a narinfo and sign it+case (parseNarInfo raw, parseSecretKey "mykey:base64...") of+  (Right ni, Right sk) -> print (sign sk ni)  -- Right "mykey:<base64 sig>"+  _                    -> error "parse failed" ``` -### Sign and Verify- ```haskell-import NovaCache.Signing (parseSecretKey, parsePublicKey, sign, verify)--main :: IO ()-main = do-  let Right sk = parseSecretKey "mykey:base64secretkey..."-      Right pk = parsePublicKey "mykey:base64pubkey..."-  case sign sk narinfo of-    Left err  -> putStrLn ("Sign error: " ++ err)-    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--### NovaCache.Base32--Nix uses a non-standard base32 alphabet that omits `e`, `o`, `u`, `t` and encodes in reverse byte order. The encoder extracts 5-bit groups from descending positions; the decoder uses an O(n) ST-based bit scatter with mutable vectors.--```haskell-import NovaCache.Base32 (encode, decode)--encode "\xff"       -- "8z"-decode "0z"         -- Right "\x1e"-decode (encode bs)  -- Right bs  (roundtrip)-```--### NovaCache.Hash--SHA-256 via crypton, formatted as `sha256:<nix-base32>`:--```haskell-import NovaCache.Hash (hashBytes, hashFile, formatNixHash, parseNixHash)--formatNixHash (hashBytes "hello")--- "sha256:0m6g5r7c..."--nixHash <- hashFile "/nix/store/abc123-hello"-```--### NovaCache.StorePath--Newtypes enforce invariants — store path hashes are always 32 characters, names are validated against the Nix character set:--```haskell-import NovaCache.StorePath--let Right sp = parseStorePath defaultStoreDir "/nix/store/abc123...-hello-1.0"-storePathHashString sp  -- "abc123..."-storePathBaseName sp    -- "abc123...-hello-1.0"-renderStorePath defaultStoreDir sp  -- "/nix/store/abc123...-hello-1.0"-```--### NovaCache.NAR--The NAR tree ADT with `Builder`-based serialization:--```haskell-import NovaCache.NAR--let entry = NarRegular False "file contents"-let bytes = serialise entry-deserialise bytes  -- Right entry (roundtrip)---- Hash a NAR directly (serialise + SHA-256)-narHash entry  -- NixHash---- Build from filesystem-tree <- serialiseFromPath "/nix/store/abc123-hello"-```--### NovaCache.Signing--Ed25519 signing and verification of narinfo fingerprints. Keys are `name:base64` pairs:--```haskell-import NovaCache.Signing---- Fingerprint format: 1;<StorePath>;<NarHash>;<NarSize>;<refs>-fingerprint narinfo--- "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 ()+import NovaCache.Validate (validateFull) --- Full pipeline: fields + nar hash + file hash + signatures-validateFull publicKey narinfo narBytes fileBytes--- Either [ValidationError] ()+-- Validate an upload: fields + NAR hash + file hash + signatures.+-- Pure, and every error is collected rather than failing on the first.+case validateFull publicKey ni narBytes fileBytes of+  Right ()  -> accept+  Left errs -> reject errs ``` ----- ## Server -Enable the WAI server with the `server` cabal flag:- ```bash-cabal build --flag server-cabal run nova-cache-server -- --port 5000 --store ./nix-cache-```--Or via environment variables:--```bash-PORT=5000 NIX_CACHE_DIR=./nix-cache nova-cache-server+cabal run --flag server nova-cache-server -- --port 5000 --store ./nix-cache ``` -### Environment Variables+### Configuration  | Variable | Description |-|----------|-------------|-| `PORT` | Server listen port (default: 5000) |+| --- | --- |+| `PORT` | Listen port (default: 5000) | | `NIX_CACHE_DIR` | Store directory (default: `./nix-cache`) |-| `CACHE_API_KEY` | Bearer token required for PUT requests. Omit for open writes. |-| `SIGNING_KEY_FILE` | Path to Ed25519 secret key file for server-side narinfo signing. |-| `LOG_REQUESTS` | Set to `0` to disable request logging. Enabled by default (Apache Combined format). |+| `CACHE_API_KEY` | Bearer token required for `PUT`. The server refuses to start without it unless `--allow-open-writes` is passed. |+| `SIGNING_KEY_FILE` | Ed25519 secret key file for server-side narinfo signing |+| `LOG_REQUESTS` | Set to `0` to disable request logging |  ### Endpoints  | Method | Path | Description |-|--------|------|-------------|-| `GET` | `/nix-cache-info` | Cache metadata (StoreDir, WantMassQuery, Priority) |-| `GET` | `/narinfo-hashes` | All cached narinfo hashes (newline-delimited) |-| `GET` | `/<hash>.narinfo` | Fetch narinfo by store path hash |-| `GET` | `/nar/<file>` | Fetch compressed NAR file |-| `PUT` | `/<hash>.narinfo` | Upload narinfo |-| `PUT` | `/nar/<file>` | Upload NAR file |+| --- | --- | --- |+| `GET` | `/nix-cache-info` | Cache metadata |+| `GET` | `/narinfo-hashes` | All cached narinfo hashes, newline-delimited |+| `GET` | `/<hash>.narinfo` | Fetch a narinfo |+| `GET` | `/nar/<file>` | Fetch a NAR |+| `PUT` | `/<hash>.narinfo` | Upload a narinfo (authenticated, validated) |+| `PUT` | `/nar/<file>` | Upload a NAR (authenticated) | -### Public Binary Cache+### Public cache -A free, public binary cache is available at `cache.novavero.ai`. Add it to-your Nix configuration:+A public instance runs at `cache.novavero.ai`: -```nix-# nix.conf or /etc/nix/nix.conf+``` extra-substituters = https://cache.novavero.ai extra-trusted-public-keys = cache.novavero.ai-1:2yJK0UZWlDDTpThzEdqfGWaj+j3ljOCGoA50Ims47dM= ``` -Or use it directly:--```bash-nix build --substituters "https://cache.nixos.org https://cache.novavero.ai" \-          --trusted-public-keys "cache.nixos.org-1:DLD/YGKmo6OceLp6RsiGCbi5FwMExRzJcoJKanMPe/Q= cache.novavero.ai-1:2yJK0UZWlDDTpThzEdqfGWaj+j3ljOCGoA50Ims47dM="-```--### Self-Hosted Cache--Run your own instance:--```bash-# Push to your cache-nix copy --to http://cache.example.com /nix/store/abc123-hello--# Pull from your cache-nix build --substituters http://cache.example.com --trusted-public-keys "mykey:base64..."-```-------## Architecture--```-                    Pure Core (no IO)-  ┌──────────────────────────────────────────┐-  │                                          │-  │  Base32 ──→ Hash ──→ StorePath           │-  │                         │                │-  │                      NarInfo ──→ Signing │-  │                         │         │      │-  │                        NAR    Validate   │-  │                                          │-  └──────────────────────────────────────────┘-                       │-              IO Boundary (thin)-  ┌──────────────────────────────────────────────────────┐-  │  Compression (flag)    Store    Server (flag)       │-  └──────────────────────────────────────────────────────┘-```--- **10 modules**, 8 pure + 2 at the IO boundary (Compression optional via flag)-- **75 core tests + 4 compression tests**, hand-rolled harness, no framework dependencies-- **Zero partial functions** — total by construction-- **Strict by default** — bang patterns on all data fields-------## API Reference--### Base32--```haskell-encode :: ByteString -> Text-decode :: Text -> Either String ByteString-```--### Hash--```haskell-hashBytes     :: ByteString -> NixHash-hashFile      :: FilePath -> IO NixHash-formatNixHash :: NixHash -> Text-parseNixHash  :: Text -> Either String NixHash-```--### StorePath--```haskell-parseStorePath      :: StoreDir -> Text -> Either String StorePath-renderStorePath     :: StoreDir -> StorePath -> Text-storePathHashString :: StorePath -> Text-storePathBaseName   :: StorePath -> Text-```--### NAR--```haskell-serialise         :: NarEntry -> ByteString-deserialise       :: ByteString -> Either String NarEntry-narHash           :: NarEntry -> NixHash-serialiseFromPath :: FilePath -> IO NarEntry-```--### NarInfo--```haskell-parseNarInfo  :: Text -> Either String NarInfo-renderNarInfo :: NarInfo -> Text-```--### Signing--```haskell-parseSecretKey :: Text -> Either String SecretKey-parsePublicKey :: Text -> Either String PublicKey-fingerprint    :: NarInfo -> Text-sign           :: SecretKey -> NarInfo -> Either String Text-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-compressXz   :: ByteString -> ByteString-decompressXz :: ByteString -> IO (Either String ByteString)-```--### Store--```haskell-newFileStore      :: FilePath -> IO FileStore-readNarInfo       :: FileStore -> Text -> IO (Maybe ByteString)-writeNarInfo      :: FileStore -> Text -> ByteString -> IO Bool-readNar           :: FileStore -> Text -> IO (Maybe ByteString)-writeNar          :: FileStore -> Text -> ByteString -> IO Bool-listNarInfoHashes :: FileStore -> IO [Text]-getCacheInfo      :: FileStore -> (Text, Bool, Int)-```--Full Haddock documentation is available on [Hackage](https://hackage.haskell.org/package/nova-cache).-------## GitHub Action — CI Cache Seeding+## CI cache seeding -A reusable composite action is included for pushing Nix store paths to a nova-cache server from CI:+A composite action pushes store paths to a nova-cache server from CI. It+resolves runtime paths via `nix-build`, diffs against the server's+`/narinfo-hashes`, and uploads only what is missing.  ```yaml - uses: Novavero-AI/nova-cache/.github/actions/seed@main@@ -399,36 +100,17 @@     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` |-| `parallel` | no | Max concurrent uploads (default: 32) |--The action resolves runtime store paths via `nix-build`, diffs against the server's `GET /narinfo-hashes`, exports only missing paths, and uploads narinfo + NAR files in parallel. Includes per-upload timeouts, failure diagnostics, and a round-trip validation check. Works with any CI that has Nix installed.-----+Inputs: `cache-url` and `api-key` (required); `paths` and `parallel` (optional). -## Build & Test+## Build & test  ```bash-cabal build                              # Build library (compression on by default)-cabal build -f-compression               # Build without lzma C dependency-cabal test                               # Run all tests-cabal build --ghc-options="-Werror"      # Warnings as errors-cabal build --flag server                # Build with WAI server-cabal haddock                            # Generate docs+cabal build+cabal test ``` -Consumers that only need hashing, NAR, or narinfo can disable the `compression` flag to avoid the system `liblzma` dependency:--```cabal-constraints: nova-cache -compression-```+Optional flags: `-f-compression` skips the `liblzma` dependency, and `--flag server` builds the cache server. Requires GHC 9.8+ and cabal-install 3.10+.  --- -<p align="center">-  <sub>BSD-3-Clause · <a href="https://github.com/Novavero-AI">Novavero AI</a></sub>-</p>+<p align="center"><sub>BSD-3-Clause · <a href="https://github.com/Novavero-AI">Novavero AI</a></sub></p>
exe/Main.hs view
@@ -1,5 +1,6 @@ module Main (main) where +import Control.Exception (SomeException) import Data.Bifunctor (first) import Data.ByteArray (constEq) import qualified Data.ByteString as BS@@ -16,12 +17,12 @@     RequestBodyLength (..),     Response,     ResponseReceived,+    getRequestBodyChunk,     pathInfo,     requestBodyLength,     requestHeaders,     requestMethod,     responseLBS,-    strictRequestBody,   ) import qualified Network.Wai.Handler.Warp as Warp import Network.Wai.Middleware.RequestLogger (logStdout)@@ -30,6 +31,7 @@ import NovaCache.Store (FileStore, getCacheInfo, listNarInfoHashes, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo) import NovaCache.Validate (validateNarInfo) import System.Environment (getArgs, lookupEnv)+import System.Exit (exitFailure) import System.IO (hPutStrLn, stderr) import Text.Read (readMaybe) @@ -91,6 +93,7 @@       storeRoot = case args of         ("--store" : s : _) -> s         _ -> fromMaybe defaultStoreRoot storeEnv+      allowOpenWrites = "--allow-open-writes" `elem` args    store <- newFileStore storeRoot   sigKey <- loadSigningKey sigKeyPath@@ -110,8 +113,25 @@   putStrLn ("signing: " ++ maybe "disabled" (const "enabled") sigKey)   putStrLn ("write auth: " ++ maybe "disabled (open writes!)" (const "enabled") (cfgApiKey cfg))   putStrLn ("request logging: " ++ if logRequests then "enabled" else "disabled (LOG_REQUESTS=0)")-  Warp.run port (requestLogger (app cfg)) +  case cfgApiKey cfg of+    Nothing+      | not allowOpenWrites -> do+          hPutStrLn stderr $+            "FATAL: "+              ++ apiKeyEnvVar+              ++ " is not set — refusing to start with unauthenticated writes. "+              ++ "Set "+              ++ apiKeyEnvVar+              ++ ", or pass --allow-open-writes to override (not recommended on a public host)."+          exitFailure+    _ -> pure ()++  let settings =+        Warp.setPort port $+          Warp.setOnExceptionResponse onExceptionResponse Warp.defaultSettings+  Warp.runSettings settings (requestLogger (app cfg))+ -- | Load a signing key from a file, if configured. loadSigningKey :: Maybe FilePath -> IO (Maybe SecretKey) loadSigningKey Nothing = pure Nothing@@ -212,11 +232,20 @@ readBodyLimited req = case requestBodyLength req of   KnownLength len     | len > fromIntegral maxBodySize -> pure Nothing-  _ -> do-    body <- BL.toStrict <$> strictRequestBody req-    if BS.length body > maxBodySize-      then pure Nothing-      else pure (Just body)+  _ -> readChunks [] 0+  where+    -- Stream the body in chunks, aborting as soon as the running total+    -- exceeds the cap — so an unsized (chunked) upload can never buffer+    -- more than 'maxBodySize' into memory.+    readChunks acc total = do+      chunk <- getRequestBodyChunk req+      if BS.null chunk+        then pure (Just (BS.concat (reverse acc)))+        else+          let newTotal = total + BS.length chunk+           in if newTotal > maxBodySize+                then pure Nothing+                else readChunks (chunk : acc) newTotal  -- | Run an action with the limited request body, responding 413 if too large. withLimitedBody :: Request -> (Response -> IO ResponseReceived) -> (BS.ByteString -> IO ResponseReceived) -> IO ResponseReceived@@ -312,6 +341,11 @@ -- | 400 Bad Request with a text error message. badRequest :: Text -> Response badRequest msg = responseLBS HTTP.status400 textHeaders (BL.fromStrict (TE.encodeUtf8 msg))++-- | Map any uncaught handler exception to a generic 500, so internal error+-- detail (filesystem paths, exception text) is never leaked to clients.+onExceptionResponse :: SomeException -> Response+onExceptionResponse _ = responseLBS HTTP.status500 textHeaders "internal server error"  -- | Content-Type: text/plain headers. textHeaders :: HTTP.ResponseHeaders
nova-cache.cabal view
@@ -1,12 +1,11 @@ cabal-version:      3.0 name:               nova-cache-version:            0.3.2.1-synopsis:           Pure Nix binary cache protocol library+version:            0.4.0.0+synopsis:           Pure-first 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, content validation — with an optional WAI-  server.+  A pure-first library implementing the Nix binary cache protocol —+  nix-base32, NAR serialization, narinfo parsing, Ed25519 signing, store+  path handling, and content validation — with an optional WAI server.  license:            BSD-3-Clause license-file:       LICENSE
src/NovaCache/Base32.hs view
@@ -6,6 +6,7 @@ module NovaCache.Base32   ( encode,     decode,+    isBase32Char,   ) where @@ -48,6 +49,13 @@ -- | Sentinel value for characters outside the alphabet. invalidMarker :: Word8 invalidMarker = 0xFF++-- | Is a character part of the Nix base32 alphabet+-- (@0123456789abcdfghijklmnpqrsvwxyz@)?  Used to validate store-path hashes.+isBase32Char :: Char -> Bool+isBase32Char c = case nixDecodeLut V.!? ord c of+  Just v -> v /= invalidMarker+  Nothing -> False  -- | Bits per base32 character. bitsPerChar :: Int
src/NovaCache/NAR.hs view
@@ -252,10 +252,20 @@ readStr bs   | BS.length bs < wordSize =       Left "unexpected end of NAR: need 8 bytes for string length"-  | totalLen > BS.length payload =+  -- Compare the Word64 length to the remaining bytes BEFORE narrowing it to+  -- Int: a hostile length above maxBound::Int would otherwise wrap negative+  -- and slip past the totalLen check below.+  | len > fromIntegral (BS.length payload) =       Left         ( "unexpected end of NAR: string length "             ++ show len+            ++ " exceeds remaining "+            ++ show (BS.length payload)+        )+  | totalLen > BS.length payload =+      Left+        ( "unexpected end of NAR: padded string length "+            ++ show totalLen             ++ " exceeds remaining "             ++ show (BS.length payload)         )
src/NovaCache/NarInfo.hs view
@@ -2,7 +2,7 @@ -- -- A @.narinfo@ file is a simple @Key: Value@ text format describing a -- store path in a binary cache. The format supports multiple @Sig@ lines--- and optional fields for @Deriver@, @System@, and @CA@.+-- and optional fields for @Deriver@ and @CA@. module NovaCache.NarInfo   ( NarInfo (..),     parseNarInfo,@@ -11,7 +11,7 @@ where  import Data.List (find)-import Data.Maybe (mapMaybe)+import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -30,7 +30,6 @@     niNarSize :: !Integer,     niReferences :: ![Text],     niDeriver :: !(Maybe Text),-    niSystem :: !(Maybe Text),     niSigs :: ![Text],     niCA :: !(Maybe Text)   }@@ -52,19 +51,18 @@ keyNarSize = "NarSize" keyReferences = "References" -keyDeriver, keySystem, keySig, keyCA :: Text+keyDeriver, keySig, keyCA :: Text keyDeriver = "Deriver"-keySystem = "System" keySig = "Sig" keyCA = "CA" --- | Key-value separator in narinfo format.+-- | Key-value separator used when rendering narinfo lines. kvSeparator :: Text kvSeparator = ": " --- | Length of the key-value separator.-kvSeparatorLen :: Int-kvSeparatorLen = 2+-- | Field delimiter used when parsing (Nix splits on the first colon).+fieldColon :: Text+fieldColon = ":"  -- --------------------------------------------------------------------------- -- Parsing@@ -92,7 +90,6 @@         niNarSize = narSize,         niReferences = parseRefs (lookupFirst keyReferences kvs),         niDeriver = lookupFirst keyDeriver kvs,-        niSystem = lookupFirst keySystem kvs,         niSigs = lookupAll keySig kvs,         niCA = lookupFirst keyCA kvs       }@@ -122,7 +119,6 @@       kv keyReferences (T.unwords (niReferences ni))     ]       ++ optionalKV keyDeriver (niDeriver ni)-      ++ optionalKV keySystem (niSystem ni)       ++ map (kv keySig) (niSigs ni)       ++ optionalKV keyCA (niCA ni) @@ -131,11 +127,18 @@ -- ---------------------------------------------------------------------------  -- | Parse a single @Key: Value@ line.+--+-- Splits on the first colon (matching Nix), strips a single leading space+-- from the value, and tolerates a trailing carriage return so CRLF-terminated+-- narinfo from other tools parses correctly. parseLine :: Text -> Maybe (Text, Text)-parseLine line = case T.breakOn kvSeparator line of+parseLine rawLine = case T.breakOn fieldColon line of   (_, rest)     | T.null rest -> Nothing-  (key, rest) -> Just (key, T.drop kvSeparatorLen rest)+  (key, rest) -> Just (key, dropLeadingSpace (T.drop 1 rest))+  where+    line = T.dropWhileEnd (== '\r') rawLine+    dropLeadingSpace value = fromMaybe value (T.stripPrefix " " value)  -- | Render a key-value pair. kv :: Text -> Text -> Text
src/NovaCache/Signing.hs view
@@ -134,8 +134,13 @@       niStorePath ni,       niNarHash ni,       T.pack (show (niNarSize ni)),-      T.intercalate referenceSep (niReferences ni)+      T.intercalate referenceSep (map (storeDir <>) (niReferences ni))     ]+  where+    -- References in a narinfo are basenames, but the fingerprint signs them as+    -- full store paths (/nix/store/<hash>-<name>), matching C++ Nix.  The store+    -- directory is the leading path of the (already absolute) niStorePath.+    storeDir = T.dropWhileEnd (/= '/') (niStorePath ni)  -- --------------------------------------------------------------------------- -- Signing and verification
src/NovaCache/Store.hs view
@@ -19,9 +19,10 @@   ) where -import Control.Exception (SomeException, catch, onException)+import Control.Exception (IOException, SomeException, catch, onException) import Data.ByteString (ByteString) import qualified Data.ByteString as BS+import Data.Char (isAsciiLower, isAsciiUpper, isDigit) import Data.Text (Text) import qualified Data.Text as T import System.Directory@@ -159,20 +160,38 @@ -- Path sanitization -- --------------------------------------------------------------------------- --- | Validate a path component for safe filesystem use.+-- | Validate a path component for safe filesystem use via a positive allowlist. ----- Rejects components containing directory separators (@\/@, @\\@),--- traversal sequences (@..@), or empty strings. Returns 'Just' the--- sanitized 'FilePath' on success, 'Nothing' on rejection.+-- Accepts only non-empty names of @[A-Za-z0-9._+-]@ that do not start with a+-- dot and are not a Windows reserved device name. This rejects directory+-- separators, @.@\/@..@ traversal, dotfiles (including the temp-write prefix),+-- NUL bytes, alternate-data-stream (@name:stream@) syntax, and device names+-- like @nul@ — so a client-supplied hash or NAR filename can never escape the+-- store directory or resolve to a device, on any platform. sanitizePath :: Text -> Maybe FilePath sanitizePath txt   | T.null txt = Nothing-  | T.any isPathSeparator txt = Nothing-  | txt == ".." = Nothing+  | T.isPrefixOf "." txt = Nothing+  | T.any (not . isSafeChar) txt = Nothing+  | isReservedName txt = Nothing   | otherwise = Just (T.unpack txt)   where-    isPathSeparator c = c == '/' || c == '\\'+    isSafeChar c =+      isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-+" :: [Char]) +-- | Is the name a Windows reserved device (@con@, @prn@, @aux@, @nul@,+-- @com1@-@com9@, @lpt1@-@lpt9@)? Matched case-insensitively on the portion+-- before the first dot, since @nul.txt@ also opens the device. Enforced on+-- every platform so a Windows-hosted cache is safe too.+isReservedName :: Text -> Bool+isReservedName txt = T.toLower (T.takeWhile (/= '.') txt) `elem` reservedNames+  where+    reservedNames =+      ["con", "prn", "aux", "nul"]+        ++ ["com" <> n | n <- digits]+        ++ ["lpt" <> n | n <- digits]+    digits = [T.pack (show n) | n <- [1 .. 9 :: Int]]+ -- --------------------------------------------------------------------------- -- Internal -- ---------------------------------------------------------------------------@@ -205,9 +224,17 @@     silenceException _ = pure ()  -- | Read a file if it exists, returning 'Nothing' otherwise.+--+-- Any I/O error (bad permissions, a race with deletion, a name that slips+-- through validation) is treated as absence rather than propagating and+-- crashing the request handler. readIfExists :: FilePath -> IO (Maybe ByteString)-readIfExists path = do-  exists <- doesFileExist path-  if exists-    then Just <$> BS.readFile path-    else pure Nothing+readIfExists path = readExisting `catch` ignoreIOError+  where+    readExisting = do+      exists <- doesFileExist path+      if exists+        then Just <$> BS.readFile path+        else pure Nothing+    ignoreIOError :: IOException -> IO (Maybe ByteString)+    ignoreIOError _ = pure Nothing
src/NovaCache/StorePath.hs view
@@ -20,6 +20,7 @@ import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T+import NovaCache.Base32 (isBase32Char)  -- --------------------------------------------------------------------------- -- Types@@ -108,6 +109,8 @@       Left ("store path too short: " ++ T.unpack basename)   | T.index basename storePathHashLen /= hashNameSeparator =       Left ("expected '-' after hash in store path: " ++ T.unpack basename)+  | not (T.all isBase32Char hashPart) =+      Left ("invalid nix-base32 hash in store path: " ++ T.unpack hashPart)   | T.null name =       Left ("empty name in store path: " ++ T.unpack basename)   | not (T.all validNameChar name) =
test/Main.hs view
@@ -330,7 +330,6 @@       "NarSize: 67890",       "References: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-glibc-2.38",       "Deriver: cccccccccccccccccccccccccccccccc-hello-1.0.drv",-      "System: x86_64-linux",       "Sig: cache.example.com:c2lnbmF0dXJl",       "Sig: backup.example.com:YW5vdGhlcnNpZw=="     ]@@ -352,9 +351,8 @@             ok5 <- assertEqual "narSize" 67890 (NarInfo.niNarSize ni)             ok6 <- assertEqual "refs count" 2 (length (NarInfo.niReferences ni))             ok7 <- assertEqual "deriver" (Just "cccccccccccccccccccccccccccccccc-hello-1.0.drv") (NarInfo.niDeriver ni)-            ok8 <- assertEqual "system" (Just "x86_64-linux") (NarInfo.niSystem ni)-            ok9 <- assertEqual "sigs count" 2 (length (NarInfo.niSigs ni))-            pure (ok1 && ok2 && ok3 && ok4 && ok5 && ok6 && ok7 && ok8 && ok9),+            ok8 <- assertEqual "sigs count" 2 (length (NarInfo.niSigs ni))+            pure (ok1 && ok2 && ok3 && ok4 && ok5 && ok6 && ok7 && ok8),       test "parse/render roundtrip" $         case NarInfo.parseNarInfo sampleNarInfoText of           Left err -> do@@ -386,11 +384,10 @@                 pure False               Right ni -> do                 ok1 <- assertEqual "deriver" Nothing (NarInfo.niDeriver ni)-                ok2 <- assertEqual "system" Nothing (NarInfo.niSystem ni)-                ok3 <- assertEqual "sigs" [] (NarInfo.niSigs ni)-                ok4 <- assertEqual "ca" Nothing (NarInfo.niCA ni)-                ok5 <- assertEqual "refs" [] (NarInfo.niReferences ni)-                pure (ok1 && ok2 && ok3 && ok4 && ok5),+                ok2 <- assertEqual "sigs" [] (NarInfo.niSigs ni)+                ok3 <- assertEqual "ca" Nothing (NarInfo.niCA ni)+                ok4 <- assertEqual "refs" [] (NarInfo.niReferences ni)+                pure (ok1 && ok2 && ok3 && ok4),       test "parse missing required key fails" $         let incomplete = T.unlines ["StorePath: /nix/store/aaaa-test", "URL: nar/test.nar.xz"]          in assertLeft "missing key" (NarInfo.parseNarInfo incomplete),@@ -424,6 +421,12 @@               ok1 <- assertTrue "starts with 1;" (T.isPrefixOf "1;" fp)               ok2 <- assertTrue "contains storePath" (T.isInfixOf "/nix/store/" fp)               pure (ok1 && ok2),+      test "fingerprint renders references as full store paths" $+        let ni = mkTestNarInfo {NarInfo.niReferences = ["00000000000000000000000000000000-glibc-2.40"]}+            fp = Signing.fingerprint ni+         in assertTrue+              "reference is a full /nix/store path in the fingerprint"+              (T.isInfixOf "/nix/store/00000000000000000000000000000000-glibc-2.40" fp),       test "parseSecretKey valid" $         let keyBytes = BS.pack ([1 .. 32] ++ [33 .. 64])             keyB64 = TE.decodeUtf8 (B64.encode keyBytes)@@ -485,7 +488,6 @@       NarInfo.niNarSize = 200,       NarInfo.niReferences = ["aaaa-hello-1.0"],       NarInfo.niDeriver = Nothing,-      NarInfo.niSystem = Nothing,       NarInfo.niSigs = [],       NarInfo.niCA = Nothing     }@@ -545,6 +547,10 @@         assertEqual "empty" Nothing (Store.sanitizePath ""),       test "sanitizePath accepts valid hash" $         assertEqual "valid" (Just "abc123def456") (Store.sanitizePath "abc123def456"),+      test "sanitizePath rejects windows device name" $+        assertEqual "device nul" Nothing (Store.sanitizePath "nul"),+      test "sanitizePath rejects dotfile" $+        assertEqual "dotfile" Nothing (Store.sanitizePath ".hidden"),       test "read rejects traversal" $ do         tmpDir <- createTestDir         store <- Store.newFileStore tmpDir@@ -605,7 +611,6 @@       NarInfo.niNarSize = 5,       NarInfo.niReferences = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0"],       NarInfo.niDeriver = Nothing,-      NarInfo.niSystem = Nothing,       NarInfo.niSigs = [],       NarInfo.niCA = Nothing     }