nova-cache 0.3.2.1 → 0.4.0.0
raw patch · 11 files changed
+227/−419 lines, 11 files
Files
- CHANGELOG.md +32/−0
- README.md +49/−367
- exe/Main.hs +41/−7
- nova-cache.cabal +5/−6
- src/NovaCache/Base32.hs +8/−0
- src/NovaCache/NAR.hs +11/−1
- src/NovaCache/NarInfo.hs +16/−13
- src/NovaCache/Signing.hs +6/−1
- src/NovaCache/Store.hs +40/−13
- src/NovaCache/StorePath.hs +3/−0
- test/Main.hs +16/−11
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> [](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml) [](https://hackage.haskell.org/package/nova-cache)-+  -</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 }