nova-cache (empty) → 0.1.0.0
raw patch · 14 files changed
+2535/−0 lines, 14 filesdep +basedep +base64-bytestringdep +bytestring
Dependencies added: base, base64-bytestring, bytestring, containers, crypton, directory, filepath, http-types, lzma, memory, nova-cache, text, unix, vector, wai, warp
Files
- CHANGELOG.md +14/−0
- LICENSE +21/−0
- README.md +321/−0
- exe/Main.hs +202/−0
- nova-cache.cabal +107/−0
- src/NovaCache/Base32.hs +169/−0
- src/NovaCache/Compression.hs +21/−0
- src/NovaCache/Hash.hs +66/−0
- src/NovaCache/NAR.hs +344/−0
- src/NovaCache/NarInfo.hs +173/−0
- src/NovaCache/Signing.hs +200/−0
- src/NovaCache/Store.hs +157/−0
- src/NovaCache/StorePath.hs +137/−0
- test/Main.hs +603/−0
+ CHANGELOG.md view
@@ -0,0 +1,14 @@+# Changelog++## 0.1.0.0 — 2026-02-21++- Initial release (renamed from gb-nix-cache)+- Nix-base32 encoding/decoding+- SHA-256 hashing with Nix hash formatting+- Store path parsing and rendering+- NAR binary format serialization/deserialization+- NarInfo text format parsing/rendering+- Ed25519 signing and verification+- xz compression/decompression+- Filesystem storage backend+- Optional WAI cache server (behind `server` flag)
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Novavero AI++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,321 @@+<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>++[](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+- **Store** — Filesystem storage backend for narinfo and NAR files+- **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:++```cabal+build-depends: nova-cache+```++### Hash a Store Path++```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..."+```++### Parse a NarInfo++```haskell+import NovaCache.NarInfo (parseNarInfo, niStorePath, niNarHash)++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))+```++### 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))+```++---++## 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;"+```++---++## 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+```++### Endpoints++| Method | Path | Description |+|--------|------|-------------|+| `GET` | `/nix-cache-info` | Cache metadata (StoreDir, WantMassQuery, Priority) |+| `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 |++### Use as a Nix Substituter++```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 │+ │ │+ └──────────────────────────────────────────┘+ │+ IO Boundary (thin)+ ┌──────────────────────────────────────────┐+ │ Compression Store Server (flag) │+ └──────────────────────────────────────────┘+```++- **8 modules**, 6 pure + 2 at the IO boundary+- **48 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+```++### Compression++```haskell+compressXz :: ByteString -> ByteString+decompressXz :: ByteString -> ByteString+```++### Store++```haskell+newFileStore :: FilePath -> IO FileStore+readNarInfo :: FileStore -> Text -> IO (Maybe ByteString)+writeNarInfo :: FileStore -> Text -> ByteString -> IO ()+readNar :: FileStore -> Text -> IO (Maybe ByteString)+writeNar :: FileStore -> Text -> ByteString -> IO ()+getCacheInfo :: FileStore -> (Text, Bool, Int)+```++Full Haddock documentation is available on [Hackage](https://hackage.haskell.org/package/nova-cache).++---++## Build & Test++```bash+cabal build # Build library+cabal test # Run all tests (48 tests, 8 groups)+cabal build --ghc-options="-Werror" # Warnings as errors+cabal build --flag server # Build with WAI server+cabal haddock # Generate docs+```++---++<p align="center">+ <sub>MIT License · <a href="https://github.com/Novavero-AI">Novavero AI</a></sub>+</p>
+ exe/Main.hs view
@@ -0,0 +1,202 @@+module Main (main) where++import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BL+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Network.HTTP.Types as HTTP+import Network.Wai (Application, Request, Response, ResponseReceived, pathInfo, requestHeaders, requestMethod, responseLBS, strictRequestBody)+import qualified Network.Wai.Handler.Warp as Warp+import NovaCache.NarInfo (NarInfo (..), parseNarInfo, renderNarInfo)+import NovaCache.Signing (SecretKey, parseSecretKey, sign)+import NovaCache.Store (FileStore, getCacheInfo, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo)+import System.Environment (getArgs, lookupEnv)+import Text.Read (readMaybe)++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | Default server port.+defaultPort :: Int+defaultPort = 5000++-- | Default store directory.+defaultStoreRoot :: FilePath+defaultStoreRoot = "./nix-cache"++-- | Environment variable for the write API key.+apiKeyEnvVar :: String+apiKeyEnvVar = "CACHE_API_KEY"++-- | Environment variable for the signing key file path.+signingKeyEnvVar :: String+signingKeyEnvVar = "SIGNING_KEY_FILE"++-- ---------------------------------------------------------------------------+-- Server configuration+-- ---------------------------------------------------------------------------++-- | Runtime server configuration.+data Config = Config+ { cfgStore :: !FileStore,+ cfgApiKey :: !(Maybe BS.ByteString),+ cfgSigningKey :: !(Maybe SecretKey)+ }++-- ---------------------------------------------------------------------------+-- Main+-- ---------------------------------------------------------------------------++main :: IO ()+main = do+ args <- getArgs+ portEnv <- lookupEnv "PORT"+ storeEnv <- lookupEnv "NIX_CACHE_DIR"+ apiKeyEnv <- lookupEnv apiKeyEnvVar+ sigKeyPath <- lookupEnv signingKeyEnvVar++ let port = case args of+ ("--port" : p : _) -> fromMaybe defaultPort (readMaybe p)+ _ -> maybe defaultPort (fromMaybe defaultPort . readMaybe) portEnv+ storeRoot = case args of+ ("--store" : s : _) -> s+ _ -> fromMaybe defaultStoreRoot storeEnv++ store <- newFileStore storeRoot+ sigKey <- loadSigningKey sigKeyPath++ let cfg =+ Config+ { cfgStore = store,+ cfgApiKey = TE.encodeUtf8 . T.pack <$> apiKeyEnv,+ cfgSigningKey = sigKey+ }++ putStrLn ("nova-cache-server listening on port " ++ show port)+ putStrLn ("store root: " ++ storeRoot)+ putStrLn ("signing: " ++ maybe "disabled" (const "enabled") sigKey)+ putStrLn ("write auth: " ++ maybe "disabled (open writes!)" (const "enabled") (cfgApiKey cfg))+ Warp.run port (app cfg)++-- | Load a signing key from a file, if configured.+loadSigningKey :: Maybe FilePath -> IO (Maybe SecretKey)+loadSigningKey Nothing = pure Nothing+loadSigningKey (Just path) = do+ contents <- T.strip . TE.decodeUtf8 <$> BS.readFile path+ case parseSecretKey contents of+ Left err -> do+ putStrLn ("WARNING: failed to load signing key: " ++ err)+ pure Nothing+ Right sk -> pure (Just sk)++-- ---------------------------------------------------------------------------+-- WAI application+-- ---------------------------------------------------------------------------++-- | WAI application implementing the Nix binary cache HTTP protocol.+app :: Config -> Application+app cfg req respond = case (requestMethod req, pathInfo req) of+ -- GET /nix-cache-info+ ("GET", ["nix-cache-info"]) ->+ respond (responseLBS HTTP.status200 textHeaders (BL.fromStrict (renderCacheInfo (cfgStore cfg))))+ -- GET /<hash>.narinfo+ ("GET", [hashNarinfo])+ | Just hashKey <- T.stripSuffix ".narinfo" hashNarinfo -> do+ result <- readNarInfo (cfgStore cfg) hashKey+ case result of+ Just content ->+ respond (responseLBS HTTP.status200 narInfoHeaders (BL.fromStrict content))+ Nothing ->+ respond (responseLBS HTTP.status404 textHeaders "not found")+ -- GET /nar/<file>+ ("GET", ["nar", fileName]) -> do+ result <- readNar (cfgStore cfg) fileName+ case result of+ Just content ->+ respond (responseLBS HTTP.status200 octetHeaders (BL.fromStrict content))+ Nothing ->+ respond (responseLBS HTTP.status404 textHeaders "not found")+ -- PUT /<hash>.narinfo (auth required)+ ("PUT", [hashNarinfo])+ | Just hashKey <- T.stripSuffix ".narinfo" hashNarinfo ->+ requireAuth cfg req respond $ do+ body <- BL.toStrict <$> strictRequestBody req+ let signed = signNarInfo (cfgSigningKey cfg) body+ writeNarInfo (cfgStore cfg) hashKey signed+ respond (responseLBS HTTP.status200 textHeaders "ok")+ -- PUT /nar/<file> (auth required)+ ("PUT", ["nar", fileName]) ->+ requireAuth cfg req respond $ do+ body <- BL.toStrict <$> strictRequestBody req+ writeNar (cfgStore cfg) fileName body+ respond (responseLBS HTTP.status200 textHeaders "ok")+ -- Fallback+ _ ->+ respond (responseLBS HTTP.status404 textHeaders "not found")++-- ---------------------------------------------------------------------------+-- Auth+-- ---------------------------------------------------------------------------++-- | Gate a handler behind API key authentication.+--+-- If no key is configured, all writes are permitted (open mode).+-- Otherwise the request must carry @Authorization: Bearer \<key\>@.+requireAuth :: Config -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived -> IO ResponseReceived+requireAuth cfg req respond action = case cfgApiKey cfg of+ Nothing -> action+ Just expected ->+ let provided = lookup HTTP.hAuthorization (requestHeaders req)+ in if provided == Just ("Bearer " <> expected)+ then action+ else respond (responseLBS HTTP.status401 textHeaders "unauthorized")++-- ---------------------------------------------------------------------------+-- Signing+-- ---------------------------------------------------------------------------++-- | Sign a narinfo body if a signing key is configured.+signNarInfo :: Maybe SecretKey -> BS.ByteString -> BS.ByteString+signNarInfo Nothing body = body+signNarInfo (Just sk) body = case parseNarInfo (TE.decodeUtf8 body) of+ Left _ -> body+ Right ni -> case sign sk ni of+ Left _ -> body+ Right sig ->+ let signed = ni {niSigs = niSigs ni ++ [sig]}+ in TE.encodeUtf8 (renderNarInfo signed)++-- ---------------------------------------------------------------------------+-- Response helpers+-- ---------------------------------------------------------------------------++-- | Render the nix-cache-info response body.+renderCacheInfo :: FileStore -> BS.ByteString+renderCacheInfo store =+ let (storeDir, wantMassQuery, priority) = getCacheInfo store+ in TE.encodeUtf8 $+ T.unlines+ [ "StoreDir: " <> storeDir,+ "WantMassQuery: " <> boolText wantMassQuery,+ "Priority: " <> T.pack (show priority)+ ]++-- | Render a Bool as @1@ or @0@.+boolText :: Bool -> Text+boolText True = "1"+boolText False = "0"++-- | Content-Type: text/plain headers.+textHeaders :: HTTP.ResponseHeaders+textHeaders = [(HTTP.hContentType, "text/plain")]++-- | Content-Type: application/x-nix-narinfo headers.+narInfoHeaders :: HTTP.ResponseHeaders+narInfoHeaders = [(HTTP.hContentType, "text/x-nix-narinfo")]++-- | Content-Type: application/octet-stream headers.+octetHeaders :: HTTP.ResponseHeaders+octetHeaders = [(HTTP.hContentType, "application/octet-stream")]
+ nova-cache.cabal view
@@ -0,0 +1,107 @@+cabal-version: 3.0+name: nova-cache+version: 0.1.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.++license: MIT+license-file: LICENSE+author: Devon Tomlin+maintainer: devon.tomlin@novavero.ai+homepage: https://github.com/Novavero-AI/nova-cache+bug-reports: https://github.com/Novavero-AI/nova-cache/issues+category: Nix, Distribution+stability: experimental+build-type: Simple+tested-with: GHC == 9.6.7+extra-doc-files:+ CHANGELOG.md+ README.md++flag server+ description: Build the cache server executable (pulls in warp/wai)+ default: False+ manual: True++library+ exposed-modules:+ NovaCache.Base32+ NovaCache.Compression+ NovaCache.Hash+ NovaCache.NAR+ NovaCache.NarInfo+ NovaCache.Signing+ NovaCache.Store+ NovaCache.StorePath++ build-depends:+ base >= 4.16 && < 5+ , base64-bytestring >= 1.2 && < 1.3+ , bytestring >= 0.11 && < 0.13+ , containers >= 0.6 && < 0.8+ , crypton >= 1.0 && < 2+ , directory >= 1.3 && < 1.4+ , filepath >= 1.4 && < 1.6+ , lzma >= 0.0.1 && < 0.1+ , memory >= 0.18 && < 1+ , text >= 2.0 && < 2.2+ , unix >= 2.7 && < 2.9+ , vector >= 0.12 && < 0.14++ hs-source-dirs: src+ default-language: Haskell2010+ default-extensions:+ BangPatterns+ OverloadedStrings+ ghc-options:+ -Wall+ -Wcompat+ -Wincomplete-record-updates+ -Wincomplete-uni-patterns++executable nova-cache-server+ if !flag(server)+ buildable: False++ main-is: Main.hs+ hs-source-dirs: exe+ default-language: Haskell2010+ default-extensions:+ OverloadedStrings+ ghc-options: -Wall -Wcompat -threaded -rtsopts++ build-depends:+ base >= 4.16 && < 5+ , bytestring >= 0.11 && < 0.13+ , nova-cache+ , http-types >= 0.12 && < 0.13+ , text >= 2.0 && < 2.2+ , wai >= 3.2 && < 3.3+ , warp >= 3.3 && < 3.5++test-suite nova-cache-test+ type: exitcode-stdio-1.0+ main-is: Main.hs+ hs-source-dirs: test+ default-language: Haskell2010+ default-extensions:+ OverloadedStrings+ ghc-options: -Wall -Wcompat++ build-depends:+ base >= 4.16 && < 5+ , base64-bytestring >= 1.2 && < 1.3+ , bytestring >= 0.11 && < 0.13+ , crypton >= 1.0 && < 2+ , directory >= 1.3 && < 1.4+ , nova-cache+ , memory >= 0.18 && < 1+ , text >= 2.0 && < 2.2++source-repository head+ type: git+ location: https://github.com/Novavero-AI/nova-cache+ branch: main
+ src/NovaCache/Base32.hs view
@@ -0,0 +1,169 @@+-- | Nix-specific base32 encoding and decoding.+--+-- Nix uses a non-standard base32 alphabet that omits @e@, @o@, @u@, @t@,+-- and encodes bytes in reverse order compared to RFC 4648. The encoding+-- extracts 5-bit groups from the raw bytes in descending position order.+module NovaCache.Base32+ ( encode,+ decode,+ )+where++import Control.Monad (when)+import Control.Monad.ST (runST)+import Data.Bits (shiftL, shiftR, (.&.), (.|.))+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU+import Data.Char (ord)+import Data.Text (Text)+import qualified Data.Text as T+import Data.Vector.Unboxed (Vector)+import qualified Data.Vector.Unboxed as V+import qualified Data.Vector.Unboxed.Mutable as MV+import Data.Word (Word8)++-- ---------------------------------------------------------------------------+-- Alphabet+-- ---------------------------------------------------------------------------++-- | Nix base32 alphabet: @0123456789abcdfghijklmnpqrsvwxyz@+--+-- 32 characters, deliberately omitting @e@, @o@, @u@, @t@ to avoid+-- visually ambiguous characters and accidental English words.+nixAlphabet :: Vector Char+nixAlphabet = V.fromList "0123456789abcdfghijklmnpqrsvwxyz"++-- | Reverse lookup table: ASCII ordinal to 5-bit value.+-- Invalid characters map to 'invalidMarker'.+nixDecodeLut :: Vector Word8+nixDecodeLut =+ V.generate asciiTableSize $ \c ->+ maybe invalidMarker fromIntegral (V.findIndex (== toEnum c) nixAlphabet)++-- | Size of the ASCII lookup table.+asciiTableSize :: Int+asciiTableSize = 256++-- | Sentinel value for characters outside the alphabet.+invalidMarker :: Word8+invalidMarker = 0xFF++-- | Bits per base32 character.+bitsPerChar :: Int+bitsPerChar = 5++-- | Bits per byte.+bitsPerByte :: Int+bitsPerByte = 8++-- | Bit mask for extracting 5 bits.+fiveBitMask :: Int+fiveBitMask = 0x1F++-- | Bit mask for byte-within-8 alignment.+byteAlignMask :: Int+byteAlignMask = 7++-- ---------------------------------------------------------------------------+-- Encode+-- ---------------------------------------------------------------------------++-- | Number of base32 characters for a given input byte length.+--+-- @\lceil len \times 8 / 5 \rceil@+encodedLength :: Int -> Int+encodedLength n =+ let totalBits = n * bitsPerByte+ in totalBits `div` bitsPerChar+ + (if totalBits `mod` bitsPerChar > 0 then 1 else 0)++-- | Encode a 'ByteString' to Nix-base32 'Text'.+--+-- Each output character position @n@ maps to a 5-bit group extracted from+-- the input at bit offset @n * 5@. Characters are emitted from the highest+-- position down, matching the Nix reference implementation.+encode :: ByteString -> Text+encode bs = T.pack [charAtPosition n | n <- [encLen - 1, encLen - 2 .. 0]]+ where+ len = BS.length bs+ encLen = encodedLength len++ charAtPosition :: Int -> Char+ charAtPosition n = V.unsafeIndex nixAlphabet (extract5Bits n)++ extract5Bits :: Int -> Int+ extract5Bits n =+ let bitOff = n * bitsPerChar+ byteIdx = bitOff `shiftR` 3+ bitIdx = bitOff .&. byteAlignMask+ lo = safeByte byteIdx+ hi = safeByte (byteIdx + 1)+ combined = fromIntegral lo .|. (fromIntegral hi `shiftL` bitsPerByte :: Int)+ in (combined `shiftR` bitIdx) .&. fiveBitMask++ safeByte :: Int -> Word8+ safeByte i+ | i < len = BSU.unsafeIndex bs i+ | otherwise = 0++-- ---------------------------------------------------------------------------+-- Decode+-- ---------------------------------------------------------------------------++-- | Decode Nix-base32 'Text' to a 'ByteString'.+--+-- For each character at string position @n@, the corresponding bit offset+-- in the output is @5 * (inputLen - 1 - n)@. Each 5-bit value's bits are+-- scattered into the output byte array via an O(n) mutable vector pass.+decode :: Text -> Either String ByteString+decode txt+ | T.null txt = Right BS.empty+ | otherwise = do+ values <- traverse lookupChar (T.unpack txt)+ Right (scatterDecode values inputLen outputLen)+ where+ inputLen = T.length txt+ outputLen = (inputLen * bitsPerChar) `div` bitsPerByte++-- | Resolve a character to its 5-bit value, failing on invalid input.+lookupChar :: Char -> Either String Word8+lookupChar c+ | ci >= asciiTableSize = Left (invalidCharMsg c)+ | val == invalidMarker = Left (invalidCharMsg c)+ | otherwise = Right val+ where+ ci = ord c+ val = V.unsafeIndex nixDecodeLut ci++-- | Scatter 5-bit values into an output byte array using a mutable vector.+--+-- O(n) in the number of input characters. Each character contributes+-- exactly 5 bits, placed at the correct byte and bit offset.+scatterDecode :: [Word8] -> Int -> Int -> ByteString+scatterDecode values inputLen outputLen = runST $ do+ arr <- MV.replicate outputLen (0 :: Word8)+ scatterAll arr 0 values+ frozen <- V.unsafeFreeze arr+ pure (BS.pack (V.toList frozen))+ where+ scatterAll _ !_ [] = pure ()+ scatterAll arr !n (c5 : rest) = do+ let bitsStart = bitsPerChar * (inputLen - 1 - n)+ scatterBits arr c5 bitsStart 0+ scatterAll arr (n + 1) rest++ scatterBits arr c5 bitsStart !bit+ | bit >= bitsPerChar = pure ()+ | otherwise = do+ let b = bitsStart + bit+ byteIdx = b `shiftR` 3+ bitIdx = b .&. byteAlignMask+ bitVal = (c5 `shiftR` bit) .&. 1+ when (byteIdx < outputLen) $ do+ old <- MV.unsafeRead arr byteIdx+ MV.unsafeWrite arr byteIdx (old .|. (bitVal `shiftL` bitIdx))+ scatterBits arr c5 bitsStart (bit + 1)++invalidCharMsg :: Char -> String+invalidCharMsg c = "invalid nix-base32 character: " ++ [c]
+ src/NovaCache/Compression.hs view
@@ -0,0 +1,21 @@+-- | xz compression and decompression for NAR files.+--+-- Thin wrappers around the @lzma@ package, converting between strict+-- 'ByteString' and the underlying lazy interface.+module NovaCache.Compression+ ( compressXz,+ decompressXz,+ )+where++import qualified Codec.Compression.Lzma as Lzma+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as BL++-- | Compress a strict 'ByteString' with xz.+compressXz :: ByteString -> ByteString+compressXz = BL.toStrict . Lzma.compress . BL.fromStrict++-- | Decompress an xz-compressed strict 'ByteString'.+decompressXz :: ByteString -> ByteString+decompressXz = BL.toStrict . Lzma.decompress . BL.fromStrict
+ src/NovaCache/Hash.hs view
@@ -0,0 +1,66 @@+-- | SHA-256 hashing with Nix-specific formatting.+--+-- Nix represents content hashes as @sha256:\<nix-base32\>@. This module+-- provides the bridge between raw cryptographic hashing and the Nix+-- string representation, composing 'Crypto.Hash' with 'NovaCache.Base32'.+module NovaCache.Hash+ ( NixHash (..),+ hashBytes,+ hashFile,+ formatNixHash,+ parseNixHash,+ )+where++import Crypto.Hash (Digest, SHA256, hash)+import Data.ByteArray (convert)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.Text (Text)+import qualified Data.Text as T+import qualified NovaCache.Base32 as Base32++-- | A SHA-256 hash as raw bytes (always exactly 32 bytes).+newtype NixHash = NixHash ByteString+ deriving (Eq, Ord, Show)++-- | Size of a SHA-256 digest in bytes.+sha256DigestSize :: Int+sha256DigestSize = 32++-- | Hash algorithm prefix used in Nix hash strings.+sha256Prefix :: Text+sha256Prefix = "sha256:"++-- | Compute the SHA-256 hash of a strict 'ByteString'.+hashBytes :: ByteString -> NixHash+hashBytes = NixHash . convert . sha256++-- | Compute the SHA-256 hash of a file's contents.+hashFile :: FilePath -> IO NixHash+hashFile path = hashBytes <$> BS.readFile path++-- | Format a 'NixHash' as @sha256:\<nix-base32\>@.+formatNixHash :: NixHash -> Text+formatNixHash (NixHash raw) = sha256Prefix <> Base32.encode raw++-- | Parse a @sha256:\<nix-base32\>@ string back to a 'NixHash'.+--+-- Validates both the prefix and the decoded length.+parseNixHash :: Text -> Either String NixHash+parseNixHash txt = case T.stripPrefix sha256Prefix txt of+ Nothing -> Left ("expected " ++ T.unpack sha256Prefix ++ " prefix, got: " ++ T.unpack txt)+ Just encoded -> do+ decoded <- Base32.decode encoded+ if BS.length decoded == sha256DigestSize+ then Right (NixHash decoded)+ else Left (digestSizeError (BS.length decoded))++-- | Internal: compute SHA-256 digest.+sha256 :: ByteString -> Digest SHA256+sha256 = hash++-- | Error message for wrong digest size.+digestSizeError :: Int -> String+digestSizeError actual =+ "expected " ++ show sha256DigestSize ++ "-byte SHA-256 digest, got " ++ show actual ++ " bytes"
+ src/NovaCache/NAR.hs view
@@ -0,0 +1,344 @@+-- | NAR (Nix ARchive) binary format serialization and deserialization.+--+-- NAR is a deterministic archive format used by Nix. All strings are+-- length-prefixed and padded to 8-byte alignment. The grammar is:+--+-- @+-- archive ::= "nix-archive-1" node+-- node ::= "(" "type" ("regular" regular | "symlink" symlink | "directory" directory) ")"+-- regular ::= ["executable" ""] "contents" STRING+-- symlink ::= "target" STRING+-- directory ::= (entry)*+-- entry ::= "entry" "(" "name" STRING "node" node ")"+-- @+module NovaCache.NAR+ ( NarEntry (..),+ serialise,+ deserialise,+ narHash,+ serialiseFromPath,+ )+where++import Data.Bits (shiftL, (.&.), (.|.))+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as B+import qualified Data.ByteString.Lazy as BL+import Data.List (sort, sortBy)+import Data.Ord (comparing)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Word (Word64)+import qualified NovaCache.Hash as Hash+import System.Directory+ ( doesDirectoryExist,+ doesFileExist,+ getSymbolicLinkTarget,+ listDirectory,+ pathIsSymbolicLink,+ )+import System.FilePath ((</>))+import qualified System.Posix.Files as Posix++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | A node in the NAR tree.+data NarEntry+ = -- | Regular file: executable flag and contents.+ NarRegular !Bool !ByteString+ | -- | Symbolic link: target path.+ NarSymlink !Text+ | -- | Directory: sorted list of (name, entry) pairs.+ NarDirectory ![(Text, NarEntry)]+ deriving (Eq, Show)++-- ---------------------------------------------------------------------------+-- Wire tokens (named constants, no magic strings)+-- ---------------------------------------------------------------------------++tokMagic, tokLParen, tokRParen, tokType :: ByteString+tokMagic = "nix-archive-1"+tokLParen = "("+tokRParen = ")"+tokType = "type"++tokRegular, tokDirectory, tokSymlink :: ByteString+tokRegular = "regular"+tokDirectory = "directory"+tokSymlink = "symlink"++tokContents, tokTarget, tokExecutable :: ByteString+tokContents = "contents"+tokTarget = "target"+tokExecutable = "executable"++tokEntry, tokName, tokNode :: ByteString+tokEntry = "entry"+tokName = "name"+tokNode = "node"++-- | Alignment boundary for NAR wire strings.+narAlignment :: Int+narAlignment = 8++-- ---------------------------------------------------------------------------+-- Serialization (pure Builder pipeline)+-- ---------------------------------------------------------------------------++-- | Serialise a 'NarEntry' to NAR binary format.+serialise :: NarEntry -> ByteString+serialise = BL.toStrict . B.toLazyByteString . buildArchive++-- | Build the full NAR archive: magic header followed by the root node.+buildArchive :: NarEntry -> B.Builder+buildArchive entry = narStr tokMagic <> buildNode entry++-- | Build a NAR node.+buildNode :: NarEntry -> B.Builder+buildNode (NarRegular isExec contents) =+ narStr tokLParen+ <> narStr tokType+ <> narStr tokRegular+ <> execFlag isExec+ <> narStr tokContents+ <> narStr contents+ <> narStr tokRParen+buildNode (NarSymlink target) =+ narStr tokLParen+ <> narStr tokType+ <> narStr tokSymlink+ <> narStr tokTarget+ <> narStr (TE.encodeUtf8 target)+ <> narStr tokRParen+buildNode (NarDirectory entries) =+ narStr tokLParen+ <> narStr tokType+ <> narStr tokDirectory+ <> foldMap buildDirEntry (sortBy (comparing fst) entries)+ <> narStr tokRParen++-- | Emit the executable flag tokens if the file is executable.+execFlag :: Bool -> B.Builder+execFlag True = narStr tokExecutable <> narStr BS.empty+execFlag False = mempty++-- | Build a single directory entry: @"entry" "(" "name" \<n\> "node" \<node\> ")"@.+buildDirEntry :: (Text, NarEntry) -> B.Builder+buildDirEntry (entryName, entry) =+ narStr tokEntry+ <> narStr tokLParen+ <> narStr tokName+ <> narStr (TE.encodeUtf8 entryName)+ <> narStr tokNode+ <> buildNode entry+ <> narStr tokRParen++-- | Write a length-prefixed, 8-byte-padded bytestring to the Builder.+narStr :: ByteString -> B.Builder+narStr bs =+ B.word64LE (fromIntegral len)+ <> B.byteString bs+ <> B.byteString (BS.replicate padLen 0)+ where+ len = BS.length bs+ padLen = narPad len++-- | Compute padding to reach the next 8-byte boundary.+narPad :: Int -> Int+narPad len =+ let remainder = len .&. (narAlignment - 1)+ in if remainder == 0 then 0 else narAlignment - remainder++-- ---------------------------------------------------------------------------+-- Deserialization (pure, cursor-passing parser)+-- ---------------------------------------------------------------------------++-- | Parser state: remaining bytes after consuming a token.+type NarParser a = ByteString -> Either String (a, ByteString)++-- | Deserialise NAR binary format to a 'NarEntry'.+deserialise :: ByteString -> Either String NarEntry+deserialise bs = do+ (magic, rest) <- readStr bs+ expect tokMagic magic+ fst <$> parseNode rest++-- | Parse a single NAR node.+parseNode :: NarParser NarEntry+parseNode bs = do+ (lp, rest) <- readStr bs+ expect tokLParen lp+ (ty, afterTy) <- readStr rest+ expect tokType ty+ (kind, afterKind) <- readStr afterTy+ dispatch kind afterKind+ where+ dispatch kind rest+ | kind == tokRegular = parseRegular rest+ | kind == tokSymlink = parseSymlink rest+ | kind == tokDirectory = parseDirectory rest+ | otherwise = Left ("unknown NAR entry type: " ++ show kind)++-- | Parse a regular file node (optional executable flag + contents).+parseRegular :: NarParser NarEntry+parseRegular bs = do+ (tok, afterTok) <- readStr bs+ regular tok afterTok+ where+ regular tok rest+ | tok == tokExecutable = do+ (_, afterEmpty) <- readStr rest+ (cTok, afterCTok) <- readStr afterEmpty+ expect tokContents cTok+ (contents, afterContents) <- readStr afterCTok+ (rp, final) <- readStr afterContents+ expect tokRParen rp+ pure (NarRegular True contents, final)+ | tok == tokContents = do+ (contents, afterContents) <- readStr rest+ (rp, final) <- readStr afterContents+ expect tokRParen rp+ pure (NarRegular False contents, final)+ | tok == tokRParen =+ pure (NarRegular False BS.empty, rest)+ | otherwise =+ Left ("expected 'executable', 'contents', or ')' in regular, got: " ++ show tok)++-- | Parse a symlink node.+parseSymlink :: NarParser NarEntry+parseSymlink bs = do+ (tgt, afterTgt) <- readStr bs+ expect tokTarget tgt+ (targetPath, afterPath) <- readStr afterTgt+ (rp, final) <- readStr afterPath+ expect tokRParen rp+ pure (NarSymlink (TE.decodeUtf8 targetPath), final)++-- | Parse a directory node (zero or more child entries).+parseDirectory :: NarParser NarEntry+parseDirectory = go []+ where+ go !acc bs = do+ (tok, afterTok) <- readStr bs+ if tok == tokRParen+ then pure (NarDirectory (reverse acc), afterTok)+ else do+ expect tokEntry tok+ (lp, afterLp) <- readStr afterTok+ expect tokLParen lp+ (nTok, afterNTok) <- readStr afterLp+ expect tokName nTok+ (entryName, afterName) <- readStr afterNTok+ (nodeTok, afterNodeTok) <- readStr afterName+ expect tokNode nodeTok+ (entry, afterEntry) <- parseNode afterNodeTok+ (rp, afterRp) <- readStr afterEntry+ expect tokRParen rp+ go ((TE.decodeUtf8 entryName, entry) : acc) afterRp++-- ---------------------------------------------------------------------------+-- Wire primitives+-- ---------------------------------------------------------------------------++-- | Read a length-prefixed, 8-byte-padded string from the buffer.+readStr :: NarParser ByteString+readStr bs+ | BS.length bs < wordSize =+ Left "unexpected end of NAR: need 8 bytes for string length"+ | totalLen > BS.length payload =+ Left+ ( "unexpected end of NAR: string length "+ ++ show len+ ++ " exceeds remaining "+ ++ show (BS.length payload)+ )+ | otherwise =+ Right (BS.take (fromIntegral len) payload, BS.drop totalLen payload)+ where+ len = readWord64LE bs+ payload = BS.drop wordSize bs+ totalLen = fromIntegral len + narPad (fromIntegral len)++-- | Read a little-endian 'Word64' from the first 8 bytes.+readWord64LE :: ByteString -> Word64+readWord64LE bs =+ byte 0+ .|. (byte 1 `shiftL` 8)+ .|. (byte 2 `shiftL` 16)+ .|. (byte 3 `shiftL` 24)+ .|. (byte 4 `shiftL` 32)+ .|. (byte 5 `shiftL` 40)+ .|. (byte 6 `shiftL` 48)+ .|. (byte 7 `shiftL` 56)+ where+ byte i = fromIntegral (BS.index bs i)++-- | Size of a Word64 in bytes.+wordSize :: Int+wordSize = 8++-- | Assert that a token matches the expected value.+expect :: ByteString -> ByteString -> Either String ()+expect expected got+ | got == expected = Right ()+ | otherwise = Left ("expected " ++ show expected ++ ", got " ++ show got)++-- ---------------------------------------------------------------------------+-- Hashing+-- ---------------------------------------------------------------------------++-- | SHA-256 hash of the NAR serialization. Pure composition.+narHash :: NarEntry -> Hash.NixHash+narHash = Hash.hashBytes . serialise++-- ---------------------------------------------------------------------------+-- Filesystem to NarEntry (IO boundary)+-- ---------------------------------------------------------------------------++-- | Walk a filesystem path and build a 'NarEntry'.+--+-- This is the sole IO function in the module. It classifies each path+-- as symlink, directory, or regular file, then delegates to pure+-- constructors.+serialiseFromPath :: FilePath -> IO NarEntry+serialiseFromPath path = do+ isSym <- pathIsSymbolicLink path+ if isSym+ then NarSymlink . T.pack <$> getSymbolicLinkTarget path+ else do+ isDir <- doesDirectoryExist path+ if isDir+ then buildDirectory path+ else buildRegularFile path++-- | Build a directory entry by recursively walking children.+buildDirectory :: FilePath -> IO NarEntry+buildDirectory path = do+ names <- sort <$> listDirectory path+ entries <- traverse walkChild names+ pure (NarDirectory entries)+ where+ walkChild name = do+ entry <- serialiseFromPath (path </> name)+ pure (T.pack name, entry)++-- | Build a regular file entry, checking the executable bit.+buildRegularFile :: FilePath -> IO NarEntry+buildRegularFile path = do+ isFile <- doesFileExist path+ if isFile+ then do+ contents <- BS.readFile path+ isExec <- checkExecutable path+ pure (NarRegular isExec contents)+ else pure (NarRegular False BS.empty)++-- | Check whether a file has the owner-execute permission set.+checkExecutable :: FilePath -> IO Bool+checkExecutable path = do+ status <- Posix.getFileStatus path+ pure (Posix.fileMode status .&. Posix.ownerExecuteMode /= 0)
+ src/NovaCache/NarInfo.hs view
@@ -0,0 +1,173 @@+-- | NarInfo text format parsing and rendering.+--+-- 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@.+module NovaCache.NarInfo+ ( NarInfo (..),+ parseNarInfo,+ renderNarInfo,+ )+where++import Data.List (find)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import qualified Data.Text as T++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | A parsed @.narinfo@ record. All fields are strict.+data NarInfo = NarInfo+ { niStorePath :: !Text,+ niUrl :: !Text,+ niCompression :: !Text,+ niFileHash :: !Text,+ niFileSize :: !Integer,+ niNarHash :: !Text,+ niNarSize :: !Integer,+ niReferences :: ![Text],+ niDeriver :: !(Maybe Text),+ niSystem :: !(Maybe Text),+ niSigs :: ![Text],+ niCA :: !(Maybe Text)+ }+ deriving (Eq, Show)++-- ---------------------------------------------------------------------------+-- Key constants (no magic strings in logic)+-- ---------------------------------------------------------------------------++keyStorePath, keyUrl, keyCompression, keyFileHash :: Text+keyStorePath = "StorePath"+keyUrl = "URL"+keyCompression = "Compression"+keyFileHash = "FileHash"++keyFileSize, keyNarHash, keyNarSize, keyReferences :: Text+keyFileSize = "FileSize"+keyNarHash = "NarHash"+keyNarSize = "NarSize"+keyReferences = "References"++keyDeriver, keySystem, keySig, keyCA :: Text+keyDeriver = "Deriver"+keySystem = "System"+keySig = "Sig"+keyCA = "CA"++-- | Key-value separator in narinfo format.+kvSeparator :: Text+kvSeparator = ": "++-- | Length of the key-value separator.+kvSeparatorLen :: Int+kvSeparatorLen = 2++-- ---------------------------------------------------------------------------+-- Parsing+-- ---------------------------------------------------------------------------++-- | Parse a narinfo text body into a 'NarInfo'.+parseNarInfo :: Text -> Either String NarInfo+parseNarInfo txt = do+ let kvs = mapMaybe parseLine (T.lines txt)+ storePath <- require keyStorePath kvs+ url <- require keyUrl kvs+ compression <- require keyCompression kvs+ fileHash <- require keyFileHash kvs+ fileSize <- require keyFileSize kvs >>= parseInteger keyFileSize+ narHashVal <- require keyNarHash kvs+ narSize <- require keyNarSize kvs >>= parseInteger keyNarSize+ pure+ NarInfo+ { niStorePath = storePath,+ niUrl = url,+ niCompression = compression,+ niFileHash = fileHash,+ niFileSize = fileSize,+ niNarHash = narHashVal,+ niNarSize = narSize,+ niReferences = parseRefs (lookupFirst keyReferences kvs),+ niDeriver = lookupFirst keyDeriver kvs,+ niSystem = lookupFirst keySystem kvs,+ niSigs = lookupAll keySig kvs,+ niCA = lookupFirst keyCA kvs+ }++-- | Parse a space-separated references field.+parseRefs :: Maybe Text -> [Text]+parseRefs Nothing = []+parseRefs (Just r)+ | T.null (T.strip r) = []+ | otherwise = T.words r++-- ---------------------------------------------------------------------------+-- Rendering+-- ---------------------------------------------------------------------------++-- | Render a 'NarInfo' to its text representation.+renderNarInfo :: NarInfo -> Text+renderNarInfo ni =+ T.unlines $+ [ kv keyStorePath (niStorePath ni),+ kv keyUrl (niUrl ni),+ kv keyCompression (niCompression ni),+ kv keyFileHash (niFileHash ni),+ kv keyFileSize (showT (niFileSize ni)),+ kv keyNarHash (niNarHash ni),+ kv keyNarSize (showT (niNarSize ni)),+ kv keyReferences (T.unwords (niReferences ni))+ ]+ ++ optionalKV keyDeriver (niDeriver ni)+ ++ optionalKV keySystem (niSystem ni)+ ++ map (kv keySig) (niSigs ni)+ ++ optionalKV keyCA (niCA ni)++-- ---------------------------------------------------------------------------+-- Key-value primitives+-- ---------------------------------------------------------------------------++-- | Parse a single @Key: Value@ line.+parseLine :: Text -> Maybe (Text, Text)+parseLine line = case T.breakOn kvSeparator line of+ (_, rest)+ | T.null rest -> Nothing+ | otherwise -> Just (key, T.drop kvSeparatorLen rest)+ where+ key = fst (T.breakOn kvSeparator line)++-- | Render a key-value pair.+kv :: Text -> Text -> Text+kv key val = key <> kvSeparator <> val++-- | Render an optional key-value pair.+optionalKV :: Text -> Maybe Text -> [Text]+optionalKV _ Nothing = []+optionalKV key (Just val) = [kv key val]++-- | Look up the first occurrence of a key.+lookupFirst :: Text -> [(Text, Text)] -> Maybe Text+lookupFirst key kvs = snd <$> find ((== key) . fst) kvs++-- | Look up all occurrences of a key.+lookupAll :: Text -> [(Text, Text)] -> [Text]+lookupAll key = map snd . filter ((== key) . fst)++-- | Require a key to be present.+require :: Text -> [(Text, Text)] -> Either String Text+require key kvs = case lookupFirst key kvs of+ Nothing -> Left ("missing required key: " ++ T.unpack key)+ Just val -> Right val++-- | Parse an integer value from text.+parseInteger :: Text -> Text -> Either String Integer+parseInteger key txt = case reads (T.unpack txt) of+ [(n, "")] -> Right n+ _ -> Left ("invalid integer for " ++ T.unpack key ++ ": " ++ T.unpack txt)++-- | Show a value as 'Text'.+showT :: (Show a) => a -> Text+showT = T.pack . show
+ src/NovaCache/Signing.hs view
@@ -0,0 +1,200 @@+-- | Ed25519 signing and verification for Nix binary cache.+--+-- Nix signs narinfo fingerprints with Ed25519. Keys are stored as+-- @name:base64@ pairs. The fingerprint is a semicolon-delimited string:+--+-- @1;\<StorePath\>;\<NarHash\>;\<NarSize\>;\<comma-separated-refs\>@+module NovaCache.Signing+ ( SecretKey (..),+ PublicKey (..),+ parseSecretKey,+ parsePublicKey,+ fingerprint,+ sign,+ verify,+ )+where++import Crypto.Error (CryptoFailable (..))+import qualified Crypto.PubKey.Ed25519 as Ed25519+import Data.ByteArray (convert)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import NovaCache.NarInfo (NarInfo (..))++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | An Ed25519 secret key with its key name.+data SecretKey = SecretKey+ { skName :: !Text,+ skBytes :: !ByteString+ }+ deriving (Eq, Show)++-- | An Ed25519 public key with its key name.+data PublicKey = PublicKey+ { pkName :: !Text,+ pkBytes :: !ByteString+ }+ deriving (Eq, Show)++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | Size of an Ed25519 secret key: 32-byte seed + 32-byte public half.+ed25519SecretKeySize :: Int+ed25519SecretKeySize = 64++-- | Size of an Ed25519 public key.+ed25519PublicKeySize :: Int+ed25519PublicKeySize = 32++-- | Size of the Ed25519 seed (first half of the secret key).+ed25519SeedSize :: Int+ed25519SeedSize = 32++-- | Key separator in @name:base64@ format.+keySeparator :: Text+keySeparator = ":"++-- | Fingerprint version tag.+fingerprintVersion :: Text+fingerprintVersion = "1"++-- | Fingerprint field separator.+fingerprintSep :: Text+fingerprintSep = ";"++-- | Reference separator within a fingerprint.+referenceSep :: Text+referenceSep = ","++-- ---------------------------------------------------------------------------+-- Key parsing+-- ---------------------------------------------------------------------------++-- | Parse a @name:base64@ secret key string.+parseSecretKey :: Text -> Either String SecretKey+parseSecretKey txt = do+ (keyName, decoded) <- splitAndDecode txt "secret key"+ expectSize ed25519SecretKeySize "secret key" decoded+ pure SecretKey {skName = keyName, skBytes = decoded}++-- | Parse a @name:base64@ public key string.+parsePublicKey :: Text -> Either String PublicKey+parsePublicKey txt = do+ (keyName, decoded) <- splitAndDecode txt "public key"+ expectSize ed25519PublicKeySize "public key" decoded+ pure PublicKey {pkName = keyName, pkBytes = decoded}++-- | Split a @name:base64@ string and decode the base64 payload.+splitAndDecode :: Text -> String -> Either String (Text, ByteString)+splitAndDecode txt label = case T.breakOn keySeparator txt of+ (_, rest)+ | T.null rest -> Left (label ++ " missing ':' separator")+ | otherwise -> do+ let encoded = T.drop 1 rest+ keyName = fst (T.breakOn keySeparator txt)+ decoded <- decodeBase64 encoded+ pure (keyName, decoded)++-- | Assert that decoded bytes have the expected size.+expectSize :: Int -> String -> ByteString -> Either String ()+expectSize expected label bs+ | BS.length bs == expected = Right ()+ | otherwise =+ Left+ ( "expected "+ ++ show expected+ ++ "-byte "+ ++ label+ ++ ", got "+ ++ show (BS.length bs)+ )++-- ---------------------------------------------------------------------------+-- Fingerprint+-- ---------------------------------------------------------------------------++-- | Construct the fingerprint string for a narinfo.+--+-- Format: @1;\<StorePath\>;\<NarHash\>;\<NarSize\>;\<ref1,ref2,...\>@+fingerprint :: NarInfo -> Text+fingerprint ni =+ T.intercalate+ fingerprintSep+ [ fingerprintVersion,+ niStorePath ni,+ niNarHash ni,+ T.pack (show (niNarSize ni)),+ T.intercalate referenceSep (niReferences ni)+ ]++-- ---------------------------------------------------------------------------+-- Signing and verification+-- ---------------------------------------------------------------------------++-- | Sign a narinfo, producing a @keyname:base64sig@ string.+sign :: SecretKey -> NarInfo -> Either String Text+sign (SecretKey keyName secretBytes) ni = do+ sk <- cryptoSecretKey (BS.take ed25519SeedSize secretBytes)+ let pk = Ed25519.toPublic sk+ msg = TE.encodeUtf8 (fingerprint ni)+ sig = Ed25519.sign sk pk msg+ sigB64 = TE.decodeUtf8 (B64.encode (convert sig))+ pure (keyName <> keySeparator <> sigB64)++-- | Verify a @keyname:base64sig@ signature against a narinfo and public key.+--+-- Returns 'False' for any malformed input rather than failing.+verify :: PublicKey -> NarInfo -> Text -> Bool+verify (PublicKey _ pubBytes) ni sigLine =+ case extractSignaturePayload sigLine of+ Nothing -> False+ Just sigRaw -> verifyRaw pubBytes (TE.encodeUtf8 (fingerprint ni)) sigRaw++-- | Extract the raw signature bytes from a @keyname:base64sig@ line.+extractSignaturePayload :: Text -> Maybe ByteString+extractSignaturePayload sigLine = case T.breakOn keySeparator sigLine of+ (_, rest)+ | T.null rest -> Nothing+ | otherwise -> either (const Nothing) Just (decodeBase64 (T.drop 1 rest))++-- | Low-level Ed25519 verification of raw bytes.+verifyRaw :: ByteString -> ByteString -> ByteString -> Bool+verifyRaw pubBytes msg sigBytes =+ case (cryptoPublicKey pubBytes, cryptoSignature sigBytes) of+ (Right pk, Right sig) -> Ed25519.verify pk msg sig+ _ -> False++-- ---------------------------------------------------------------------------+-- Cryptographic primitives (thin wrappers over crypton)+-- ---------------------------------------------------------------------------++-- | Decode base64 text to bytes.+decodeBase64 :: Text -> Either String ByteString+decodeBase64 = B64.decode . TE.encodeUtf8++-- | Lift a 'CryptoFailable' to 'Either' with a descriptive label.+liftCrypto :: String -> CryptoFailable a -> Either String a+liftCrypto _ (CryptoPassed x) = Right x+liftCrypto label (CryptoFailed err) = Left ("invalid " ++ label ++ ": " ++ show err)++-- | Parse raw bytes as an Ed25519 secret key.+cryptoSecretKey :: ByteString -> Either String Ed25519.SecretKey+cryptoSecretKey = liftCrypto "Ed25519 secret key" . Ed25519.secretKey++-- | Parse raw bytes as an Ed25519 public key.+cryptoPublicKey :: ByteString -> Either String Ed25519.PublicKey+cryptoPublicKey = liftCrypto "Ed25519 public key" . Ed25519.publicKey++-- | Parse raw bytes as an Ed25519 signature.+cryptoSignature :: ByteString -> Either String Ed25519.Signature+cryptoSignature = liftCrypto "Ed25519 signature" . Ed25519.signature
+ src/NovaCache/Store.hs view
@@ -0,0 +1,157 @@+-- | Filesystem storage backend for a Nix binary cache.+--+-- Stores narinfo and NAR files in a directory tree:+--+-- @+-- \<root\>\/narinfo\/\<hash\> -- narinfo files by store path hash+-- \<root\>\/nar\/\<filename\> -- compressed NAR files+-- @+module NovaCache.Store+ ( FileStore (..),+ newFileStore,+ readNarInfo,+ writeNarInfo,+ readNar,+ writeNar,+ getCacheInfo,+ sanitizePath,+ )+where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.Text (Text)+import qualified Data.Text as T+import System.Directory+ ( createDirectoryIfMissing,+ doesFileExist,+ )+import System.FilePath ((</>))++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Filesystem-backed cache store configuration.+data FileStore = FileStore+ { fsRoot :: !FilePath,+ fsStoreDir :: !Text,+ fsPriority :: !Int+ }+ deriving (Show)++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | Subdirectory for narinfo files.+narinfoSubdir :: String+narinfoSubdir = "narinfo"++-- | Subdirectory for NAR files.+narSubdir :: String+narSubdir = "nar"++-- | Default cache priority (lower = preferred by Nix).+defaultPriority :: Int+defaultPriority = 30++-- | Default Nix store directory.+defaultStoreDir :: Text+defaultStoreDir = "/nix/store"++-- ---------------------------------------------------------------------------+-- Initialization+-- ---------------------------------------------------------------------------++-- | Create a 'FileStore' rooted at the given directory.+--+-- Ensures the @narinfo@ and @nar@ subdirectories exist.+newFileStore :: FilePath -> IO FileStore+newFileStore root = do+ createDirectoryIfMissing True (root </> narinfoSubdir)+ createDirectoryIfMissing True (root </> narSubdir)+ pure+ FileStore+ { fsRoot = root,+ fsStoreDir = defaultStoreDir,+ fsPriority = defaultPriority+ }++-- ---------------------------------------------------------------------------+-- NarInfo operations+-- ---------------------------------------------------------------------------++-- | Read a narinfo by its store path hash. Returns 'Nothing' if absent.+--+-- Returns 'Nothing' for path components containing traversal sequences.+readNarInfo :: FileStore -> Text -> IO (Maybe ByteString)+readNarInfo fs hashKey = case sanitizePath hashKey of+ Nothing -> pure Nothing+ Just safe -> readIfExists (fsRoot fs </> narinfoSubdir </> safe)++-- | Write a narinfo by its store path hash.+--+-- Silently rejects path components containing traversal sequences.+writeNarInfo :: FileStore -> Text -> ByteString -> IO ()+writeNarInfo fs hashKey body = case sanitizePath hashKey of+ Nothing -> pure ()+ Just safe -> BS.writeFile (fsRoot fs </> narinfoSubdir </> safe) body++-- ---------------------------------------------------------------------------+-- NAR operations+-- ---------------------------------------------------------------------------++-- | Read a NAR file by its filename. Returns 'Nothing' if absent.+--+-- Returns 'Nothing' for path components containing traversal sequences.+readNar :: FileStore -> Text -> IO (Maybe ByteString)+readNar fs fileName = case sanitizePath fileName of+ Nothing -> pure Nothing+ Just safe -> readIfExists (fsRoot fs </> narSubdir </> safe)++-- | Write a NAR file by its filename.+--+-- Silently rejects path components containing traversal sequences.+writeNar :: FileStore -> Text -> ByteString -> IO ()+writeNar fs fileName body = case sanitizePath fileName of+ Nothing -> pure ()+ Just safe -> BS.writeFile (fsRoot fs </> narSubdir </> safe) body++-- ---------------------------------------------------------------------------+-- Cache metadata+-- ---------------------------------------------------------------------------++-- | Cache metadata: (storeDir, wantMassQuery, priority).+getCacheInfo :: FileStore -> (Text, Bool, Int)+getCacheInfo fs = (fsStoreDir fs, True, fsPriority fs)++-- ---------------------------------------------------------------------------+-- Path sanitization+-- ---------------------------------------------------------------------------++-- | Validate a path component for safe filesystem use.+--+-- Rejects components containing directory separators (@\/@, @\\@),+-- traversal sequences (@..@), or empty strings. Returns 'Just' the+-- sanitized 'FilePath' on success, 'Nothing' on rejection.+sanitizePath :: Text -> Maybe FilePath+sanitizePath txt+ | T.null txt = Nothing+ | T.any isPathSeparator txt = Nothing+ | txt == ".." = Nothing+ | otherwise = Just (T.unpack txt)+ where+ isPathSeparator c = c == '/' || c == '\\'++-- ---------------------------------------------------------------------------+-- Internal+-- ---------------------------------------------------------------------------++-- | Read a file if it exists, returning 'Nothing' otherwise.+readIfExists :: FilePath -> IO (Maybe ByteString)+readIfExists path = do+ exists <- doesFileExist path+ if exists+ then Just <$> BS.readFile path+ else pure Nothing
+ src/NovaCache/StorePath.hs view
@@ -0,0 +1,137 @@+-- | Nix store path parsing, rendering, and validation.+--+-- A store path has the form @\/nix\/store\/\<hash\>-\<name\>@ where the hash+-- is a 32-character nix-base32 string and the name contains only characters+-- from a restricted set: alphanumeric plus @-._+?=@.+module NovaCache.StorePath+ ( StoreDir (..),+ StorePath (..),+ StorePathHash (..),+ StorePathName (..),+ parseStorePath,+ renderStorePath,+ storePathHashString,+ storePathBaseName,+ defaultStoreDir,+ )+where++import Data.Char (isAlphaNum)+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as T++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Root directory of the Nix store.+newtype StoreDir = StoreDir FilePath+ deriving (Eq, Ord, Show)++-- | A parsed store path: hash prefix and validated name.+data StorePath = StorePath+ { spHash :: !StorePathHash,+ spName :: !StorePathName+ }+ deriving (Eq, Ord, Show)++-- | The 32-character nix-base32 hash prefix of a store path.+newtype StorePathHash = StorePathHash Text+ deriving (Eq, Ord, Show)++-- | The validated name component of a store path.+newtype StorePathName = StorePathName Text+ deriving (Eq, Ord, Show)++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | The standard Nix store directory.+defaultStoreDir :: StoreDir+defaultStoreDir = StoreDir defaultStoreDirPath++-- | Default store directory path.+defaultStoreDirPath :: FilePath+defaultStoreDirPath = "/nix/store"++-- | Length of the nix-base32 hash in a store path.+storePathHashLen :: Int+storePathHashLen = 32++-- | Minimum basename length: hash + separator.+minBaseNameLen :: Int+minBaseNameLen = storePathHashLen + 1++-- | The separator between hash and name in a store path.+hashNameSeparator :: Char+hashNameSeparator = '-'++-- ---------------------------------------------------------------------------+-- Validation+-- ---------------------------------------------------------------------------++-- | Characters allowed in the name component of a store path.+--+-- Alphanumeric plus @-._+?=@, matching the Nix specification.+validNameChar :: Char -> Bool+validNameChar c =+ isAlphaNum c+ || c == '-'+ || c == '_'+ || c == '.'+ || c == '+'+ || c == '?'+ || c == '='++-- ---------------------------------------------------------------------------+-- Parsing+-- ---------------------------------------------------------------------------++-- | Parse a store path from a full path or bare basename.+--+-- Accepts @\/nix\/store\/\<hash\>-\<name\>@ or just @\<hash\>-\<name\>@.+parseStorePath :: StoreDir -> Text -> Either String StorePath+parseStorePath (StoreDir dir) txt =+ parseBaseName (stripDirPrefix dir txt)++-- | Strip the store directory prefix if present.+stripDirPrefix :: FilePath -> Text -> Text+stripDirPrefix dir txt =+ fromMaybe txt (T.stripPrefix (T.pack dir <> "/") txt)++-- | Parse a @\<hash\>-\<name\>@ basename into a 'StorePath'.+parseBaseName :: Text -> Either String StorePath+parseBaseName basename+ | T.length basename < minBaseNameLen =+ Left ("store path too short: " ++ T.unpack basename)+ | T.index basename storePathHashLen /= hashNameSeparator =+ Left ("expected '-' after hash in store path: " ++ T.unpack basename)+ | T.null name =+ Left ("empty name in store path: " ++ T.unpack basename)+ | not (T.all validNameChar name) =+ Left ("invalid characters in store path name: " ++ T.unpack name)+ | otherwise =+ Right StorePath {spHash = StorePathHash hashPart, spName = StorePathName name}+ where+ hashPart = T.take storePathHashLen basename+ name = T.drop minBaseNameLen basename++-- ---------------------------------------------------------------------------+-- Rendering+-- ---------------------------------------------------------------------------++-- | Render a store path as a full path under the given store directory.+renderStorePath :: StoreDir -> StorePath -> Text+renderStorePath (StoreDir dir) sp =+ T.pack dir <> "/" <> storePathBaseName sp++-- | Extract the 32-character nix-base32 hash string.+storePathHashString :: StorePath -> Text+storePathHashString (StorePath (StorePathHash h) _) = h++-- | Render the @\<hash\>-\<name\>@ basename.+storePathBaseName :: StorePath -> Text+storePathBaseName (StorePath (StorePathHash h) (StorePathName n)) =+ h <> T.singleton hashNameSeparator <> n
+ test/Main.hs view
@@ -0,0 +1,603 @@+module Main (main) where++import qualified Crypto.PubKey.Ed25519 as Ed25519+import Data.ByteArray (convert)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified NovaCache.Base32 as Base32+import qualified NovaCache.Compression as Compression+import qualified NovaCache.Hash as Hash+import qualified NovaCache.NAR as NAR+import qualified NovaCache.NarInfo as NarInfo+import qualified NovaCache.Signing as Signing+import qualified NovaCache.Store as Store+import qualified NovaCache.StorePath as StorePath+import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)+import System.Exit (exitFailure, exitSuccess)+import System.IO (hFlush, stdout)++-- ---------------------------------------------------------------------------+-- Test harness (hand-rolled, no framework)+-- ---------------------------------------------------------------------------++-- | Run a named test, short-circuit on first failure.+test :: String -> IO Bool -> IO Bool+test name action = do+ putStr (" " ++ name ++ "... ")+ hFlush stdout+ result <- action+ if result+ then do+ putStrLn "OK"+ pure True+ else do+ putStrLn "FAILED"+ pure False++-- | Assert equality.+assertEqual :: (Eq a, Show a) => String -> a -> a -> IO Bool+assertEqual label expected actual+ | expected == actual = pure True+ | otherwise = do+ putStrLn ""+ putStrLn (" " ++ label)+ putStrLn (" expected: " ++ show expected)+ putStrLn (" actual: " ++ show actual)+ pure False++-- | Assert a Right value matches.+assertRight :: (Eq a, Show a) => String -> a -> Either String a -> IO Bool+assertRight label expected (Right actual) = assertEqual label expected actual+assertRight label _ (Left err) = do+ putStrLn ""+ putStrLn (" " ++ label)+ putStrLn (" expected Right, got Left: " ++ err)+ pure False++-- | Assert a Left (error case).+assertLeft :: (Show a) => String -> Either String a -> IO Bool+assertLeft _ (Left _) = pure True+assertLeft label (Right val) = do+ putStrLn ""+ putStrLn (" " ++ label)+ putStrLn (" expected Left, got Right: " ++ show val)+ pure False++-- | Assert a Bool is True.+assertTrue :: String -> Bool -> IO Bool+assertTrue _ True = pure True+assertTrue label False = do+ putStrLn ""+ putStrLn (" " ++ label ++ ": expected True")+ pure False++-- | Assert a Bool is False.+assertFalse :: String -> Bool -> IO Bool+assertFalse _ False = pure True+assertFalse label True = do+ putStrLn ""+ putStrLn (" " ++ label ++ ": expected False")+ pure False++-- | Run a group of tests, stopping at first failure.+runGroup :: String -> [IO Bool] -> IO Bool+runGroup name tests = do+ putStrLn (name ++ ":")+ go tests+ where+ go [] = pure True+ go (t : ts) = do+ ok <- t+ if ok then go ts else pure False++-- | Run all test groups.+runAll :: [IO Bool] -> IO ()+runAll groups = do+ results <- sequence groups+ let passed = length (filter id results)+ total = length results+ putStrLn ""+ if and results+ then do+ putStrLn ("All " ++ show total ++ " groups passed.")+ exitSuccess+ else do+ putStrLn (show passed ++ "/" ++ show total ++ " groups passed.")+ exitFailure++main :: IO ()+main = do+ putStrLn "nova-cache test suite"+ putStrLn "======================"+ putStrLn ""+ runAll+ [ testBase32,+ testHash,+ testStorePath,+ testNAR,+ testNarInfo,+ testSigning,+ testCompression,+ testFileStore+ ]++-- ---------------------------------------------------------------------------+-- Base32 tests+-- ---------------------------------------------------------------------------++testBase32 :: IO Bool+testBase32 =+ runGroup+ "Base32"+ [ test "encode empty" $+ assertEqual "encode empty" "" (Base32.encode BS.empty),+ test "decode empty" $+ assertRight "decode empty" BS.empty (Base32.decode ""),+ test "encode/decode roundtrip (single byte)" $+ let bs = BS.singleton 0xFF+ encoded = Base32.encode bs+ in assertRight "roundtrip 0xFF" bs (Base32.decode encoded),+ test "encode/decode roundtrip (known SHA-256)" $+ let bs = BS.pack [0 .. 31]+ encoded = Base32.encode bs+ in assertRight "roundtrip 32 bytes" bs (Base32.decode encoded),+ test "encode/decode roundtrip (all zeros)" $+ let bs = BS.replicate 32 0+ encoded = Base32.encode bs+ in assertRight "roundtrip zeros" bs (Base32.decode encoded),+ test "encode/decode roundtrip (all 0xFF)" $+ let bs = BS.replicate 32 0xFF+ encoded = Base32.encode bs+ in assertRight "roundtrip 0xFF*32" bs (Base32.decode encoded),+ test "decode invalid character" $+ assertLeft "invalid char" (Base32.decode "hello!"),+ test "encode length for 32 bytes" $+ -- 32 bytes -> ceil(32*8/5) = ceil(51.2) = 52 chars+ let bs = BS.replicate 32 0x42+ encoded = Base32.encode bs+ in assertEqual "encoded length" 52 (T.length encoded),+ test "nix known vector" $+ -- SHA-256 of empty string in nix-base32 should be 52 chars+ let Hash.NixHash raw = Hash.hashBytes BS.empty+ encoded = Base32.encode raw+ in assertEqual "sha256 of empty in base32 length" 52 (T.length encoded)+ ]++-- ---------------------------------------------------------------------------+-- Hash tests+-- ---------------------------------------------------------------------------++testHash :: IO Bool+testHash =+ runGroup+ "Hash"+ [ test "hashBytes deterministic" $+ let h1 = Hash.hashBytes (BS.pack [1, 2, 3])+ h2 = Hash.hashBytes (BS.pack [1, 2, 3])+ in assertEqual "deterministic" h1 h2,+ test "hashBytes different inputs differ" $+ let h1 = Hash.hashBytes (BS.pack [1, 2, 3])+ h2 = Hash.hashBytes (BS.pack [4, 5, 6])+ in assertTrue "different" (h1 /= h2),+ test "hashBytes is 32 bytes" $+ let Hash.NixHash raw = Hash.hashBytes BS.empty+ in assertEqual "32 bytes" 32 (BS.length raw),+ test "formatNixHash prefix" $+ let formatted = Hash.formatNixHash (Hash.hashBytes BS.empty)+ in assertTrue "sha256: prefix" (T.isPrefixOf "sha256:" formatted),+ test "formatNixHash/parseNixHash roundtrip" $+ let h = Hash.hashBytes (BS.pack [42])+ formatted = Hash.formatNixHash h+ in assertRight "roundtrip" h (Hash.parseNixHash formatted),+ test "parseNixHash bad prefix" $+ assertLeft "bad prefix" (Hash.parseNixHash "md5:abc"),+ test "parseNixHash bad base32" $+ assertLeft "bad base32" (Hash.parseNixHash "sha256:!!invalid!!")+ ]++-- ---------------------------------------------------------------------------+-- StorePath tests+-- ---------------------------------------------------------------------------++testStorePath :: IO Bool+testStorePath =+ runGroup+ "StorePath"+ [ test "parse/render roundtrip" $+ let storeDir = StorePath.defaultStoreDir+ input = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0"+ in case StorePath.parseStorePath storeDir input of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ pure False+ Right sp ->+ assertEqual "roundtrip" input (StorePath.renderStorePath storeDir sp),+ test "parse basename only" $+ let storeDir = StorePath.defaultStoreDir+ basename = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0"+ in case StorePath.parseStorePath storeDir basename of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ pure False+ Right sp ->+ assertEqual "basename" basename (StorePath.storePathBaseName sp),+ test "parse extracts hash" $+ let storeDir = StorePath.defaultStoreDir+ input = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-test"+ in case StorePath.parseStorePath storeDir input of+ Left _ -> pure False+ Right sp ->+ assertEqual "hash" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" (StorePath.storePathHashString sp),+ test "reject too short" $+ let storeDir = StorePath.defaultStoreDir+ in assertLeft "too short" (StorePath.parseStorePath storeDir "abc-def"),+ test "reject empty name" $+ let storeDir = StorePath.defaultStoreDir+ in assertLeft "empty name" (StorePath.parseStorePath storeDir "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-"),+ test "reject invalid name chars" $+ let storeDir = StorePath.defaultStoreDir+ input = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello world"+ in assertLeft "invalid chars" (StorePath.parseStorePath storeDir input)+ ]++-- ---------------------------------------------------------------------------+-- NAR tests+-- ---------------------------------------------------------------------------++testNAR :: IO Bool+testNAR =+ runGroup+ "NAR"+ [ test "serialise/deserialise roundtrip (regular file)" $+ let entry = NAR.NarRegular False (BS.pack [72, 101, 108, 108, 111])+ serialised = NAR.serialise entry+ in assertRight "roundtrip regular" entry (NAR.deserialise serialised),+ test "serialise/deserialise roundtrip (empty file)" $+ let entry = NAR.NarRegular False BS.empty+ serialised = NAR.serialise entry+ in assertRight "roundtrip empty" entry (NAR.deserialise serialised),+ test "serialise/deserialise roundtrip (executable)" $+ let entry = NAR.NarRegular True (BS.pack [0x7F, 0x45, 0x4C, 0x46])+ serialised = NAR.serialise entry+ in assertRight "roundtrip exec" entry (NAR.deserialise serialised),+ test "serialise/deserialise roundtrip (symlink)" $+ let entry = NAR.NarSymlink "/usr/bin/hello"+ serialised = NAR.serialise entry+ in assertRight "roundtrip symlink" entry (NAR.deserialise serialised),+ test "serialise/deserialise roundtrip (directory)" $+ let entry =+ NAR.NarDirectory+ [ ("bar", NAR.NarRegular False (BS.pack [2])),+ ("foo", NAR.NarRegular False (BS.pack [1]))+ ]+ serialised = NAR.serialise entry+ in assertRight "roundtrip dir" entry (NAR.deserialise serialised),+ test "serialise/deserialise roundtrip (nested directory)" $+ let entry =+ NAR.NarDirectory+ [ ("bin", NAR.NarDirectory [("hello", NAR.NarRegular True (BS.pack [42]))]),+ ("lib", NAR.NarSymlink "../lib64")+ ]+ serialised = NAR.serialise entry+ in assertRight "roundtrip nested" entry (NAR.deserialise serialised),+ test "directory entries sorted" $+ let entry =+ NAR.NarDirectory+ [ ("zebra", NAR.NarRegular False BS.empty),+ ("alpha", NAR.NarRegular False BS.empty)+ ]+ serialised = NAR.serialise entry+ in case NAR.deserialise serialised of+ Left err -> do+ putStrLn (" deserialise failed: " ++ err)+ pure False+ Right (NAR.NarDirectory entries) ->+ assertEqual "sorted" ["alpha", "zebra"] (map fst entries)+ Right other -> do+ putStrLn (" expected directory, got: " ++ show other)+ pure False,+ test "narHash deterministic" $+ let entry = NAR.NarRegular False (BS.pack [1, 2, 3])+ h1 = NAR.narHash entry+ h2 = NAR.narHash entry+ in assertEqual "deterministic hash" h1 h2,+ test "narHash differs for different content" $+ let h1 = NAR.narHash (NAR.NarRegular False (BS.pack [1]))+ h2 = NAR.narHash (NAR.NarRegular False (BS.pack [2]))+ in assertTrue "different hashes" (h1 /= h2),+ test "deserialise garbage fails" $+ assertLeft "garbage" (NAR.deserialise (BS.pack [0, 0, 0, 0, 0, 0, 0, 0]))+ ]++-- ---------------------------------------------------------------------------+-- NarInfo tests+-- ---------------------------------------------------------------------------++sampleNarInfoText :: Text+sampleNarInfoText =+ T.unlines+ [ "StorePath: /nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0",+ "URL: nar/1234abcd.nar.xz",+ "Compression: xz",+ "FileHash: sha256:abcdef1234567890",+ "FileSize: 12345",+ "NarHash: sha256:fedcba0987654321",+ "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=="+ ]++testNarInfo :: IO Bool+testNarInfo =+ runGroup+ "NarInfo"+ [ test "parse sample narinfo" $+ case NarInfo.parseNarInfo sampleNarInfoText of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ pure False+ Right ni -> do+ ok1 <- assertEqual "storePath" "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0" (NarInfo.niStorePath ni)+ ok2 <- assertEqual "url" "nar/1234abcd.nar.xz" (NarInfo.niUrl ni)+ ok3 <- assertEqual "compression" "xz" (NarInfo.niCompression ni)+ ok4 <- assertEqual "fileSize" 12345 (NarInfo.niFileSize ni)+ 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),+ test "parse/render roundtrip" $+ case NarInfo.parseNarInfo sampleNarInfoText of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ pure False+ Right ni ->+ let rendered = NarInfo.renderNarInfo ni+ in case NarInfo.parseNarInfo rendered of+ Left err -> do+ putStrLn (" re-parse failed: " ++ err)+ pure False+ Right reparsed ->+ assertEqual "roundtrip" ni reparsed,+ test "parse minimal narinfo (no optional fields)" $+ let minimal =+ T.unlines+ [ "StorePath: /nix/store/aaaa-test",+ "URL: nar/test.nar.xz",+ "Compression: xz",+ "FileHash: sha256:abc",+ "FileSize: 100",+ "NarHash: sha256:def",+ "NarSize: 200",+ "References: "+ ]+ in case NarInfo.parseNarInfo minimal of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ 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),+ 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),+ test "parse bad integer fails" $+ let bad =+ T.unlines+ [ "StorePath: /nix/store/aaaa-test",+ "URL: nar/test.nar.xz",+ "Compression: xz",+ "FileHash: sha256:abc",+ "FileSize: not-a-number",+ "NarHash: sha256:def",+ "NarSize: 200",+ "References: "+ ]+ in assertLeft "bad integer" (NarInfo.parseNarInfo bad)+ ]++-- ---------------------------------------------------------------------------+-- Signing tests+-- ---------------------------------------------------------------------------++testSigning :: IO Bool+testSigning =+ runGroup+ "Signing"+ [ test "fingerprint format" $+ let ni = mkTestNarInfo+ fp = Signing.fingerprint ni+ in do+ ok1 <- assertTrue "starts with 1;" (T.isPrefixOf "1;" fp)+ ok2 <- assertTrue "contains storePath" (T.isInfixOf "/nix/store/" fp)+ pure (ok1 && ok2),+ test "parseSecretKey valid" $+ let keyBytes = BS.pack ([1 .. 32] ++ [33 .. 64])+ keyB64 = TE.decodeUtf8 (B64.encode keyBytes)+ keyStr = "test-key:" <> keyB64+ in case Signing.parseSecretKey keyStr of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ pure False+ Right sk ->+ assertEqual "key name" "test-key" (Signing.skName sk),+ test "parsePublicKey valid" $+ let keyBytes = BS.pack [1 .. 32]+ keyB64 = TE.decodeUtf8 (B64.encode keyBytes)+ keyStr = "test-key:" <> keyB64+ in case Signing.parsePublicKey keyStr of+ Left err -> do+ putStrLn (" parse failed: " ++ err)+ pure False+ Right pk ->+ assertEqual "key name" "test-key" (Signing.pkName pk),+ test "parseSecretKey no colon fails" $+ assertLeft "no colon" (Signing.parseSecretKey "nokeyname"),+ test "parsePublicKey wrong size fails" $+ let keyStr = "test-key:" <> TE.decodeUtf8 (B64.encode (BS.pack [1 .. 16]))+ in assertLeft "wrong size" (Signing.parsePublicKey keyStr),+ test "sign/verify roundtrip" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ ni = mkTestNarInfo+ case Signing.sign sk ni of+ Left err -> do+ putStrLn (" sign failed: " ++ err)+ pure False+ Right sig ->+ assertTrue "verify passes" (Signing.verify pk ni sig),+ test "verify rejects tampered narinfo" $ do+ sk <- generateTestSecretKey+ let pk = deriveTestPublicKey sk+ ni = mkTestNarInfo+ case Signing.sign sk ni of+ Left err -> do+ putStrLn (" sign failed: " ++ err)+ pure False+ Right sig ->+ let tampered = ni {NarInfo.niNarSize = 999999}+ in assertFalse "verify rejects tampered" (Signing.verify pk tampered sig)+ ]++-- | Create a test NarInfo for signing tests.+mkTestNarInfo :: NarInfo.NarInfo+mkTestNarInfo =+ NarInfo.NarInfo+ { NarInfo.niStorePath = "/nix/store/aaaa-hello-1.0",+ NarInfo.niUrl = "nar/test.nar.xz",+ NarInfo.niCompression = "xz",+ NarInfo.niFileHash = "sha256:abc",+ NarInfo.niFileSize = 100,+ NarInfo.niNarHash = "sha256:def",+ NarInfo.niNarSize = 200,+ NarInfo.niReferences = ["aaaa-hello-1.0"],+ NarInfo.niDeriver = Nothing,+ NarInfo.niSystem = Nothing,+ NarInfo.niSigs = [],+ NarInfo.niCA = Nothing+ }++-- ---------------------------------------------------------------------------+-- Compression tests+-- ---------------------------------------------------------------------------++testCompression :: IO Bool+testCompression =+ runGroup+ "Compression"+ [ test "compress/decompress roundtrip" $+ let input = BS.pack [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]+ compressed = Compression.compressXz input+ decompressed = Compression.decompressXz compressed+ in assertEqual "roundtrip" input decompressed,+ test "compress/decompress roundtrip (empty)" $+ let compressed = Compression.compressXz BS.empty+ decompressed = Compression.decompressXz compressed+ in assertEqual "roundtrip empty" BS.empty decompressed,+ test "compressed is smaller for repetitive data" $+ let input = BS.replicate 10000 0x42+ compressed = Compression.compressXz input+ in assertTrue "smaller" (BS.length compressed < BS.length input)+ ]++-- ---------------------------------------------------------------------------+-- FileStore tests+-- ---------------------------------------------------------------------------++testFileStore :: IO Bool+testFileStore =+ runGroup+ "FileStore"+ [ test "narinfo write/read roundtrip" $ do+ tmpDir <- createTestDir+ store <- Store.newFileStore tmpDir+ let hashKey = "testhash123"+ content = TE.encodeUtf8 ("StorePath: /nix/store/test\n" :: Text)+ Store.writeNarInfo store hashKey content+ result <- Store.readNarInfo store hashKey+ removeDirectoryRecursive tmpDir+ assertEqual "narinfo roundtrip" (Just content) result,+ test "nar write/read roundtrip" $ do+ tmpDir <- createTestDir+ store <- Store.newFileStore tmpDir+ let fileName = "test.nar.xz"+ content = BS.pack [1, 2, 3, 4, 5]+ Store.writeNar store fileName content+ result <- Store.readNar store fileName+ removeDirectoryRecursive tmpDir+ assertEqual "nar roundtrip" (Just content) result,+ test "read nonexistent returns Nothing" $ do+ tmpDir <- createTestDir+ store <- Store.newFileStore tmpDir+ result <- Store.readNarInfo store "nonexistent"+ removeDirectoryRecursive tmpDir+ assertEqual "not found" Nothing result,+ test "cacheInfo defaults" $ do+ tmpDir <- createTestDir+ store <- Store.newFileStore tmpDir+ let (storeDir, wantMass, priority) = Store.getCacheInfo store+ removeDirectoryRecursive tmpDir+ ok1 <- assertEqual "storeDir" "/nix/store" storeDir+ ok2 <- assertTrue "wantMassQuery" wantMass+ ok3 <- assertEqual "priority" 30 priority+ pure (ok1 && ok2 && ok3),+ test "sanitizePath rejects traversal" $+ assertEqual "dotdot" Nothing (Store.sanitizePath ".."),+ test "sanitizePath rejects slash" $+ assertEqual "slash" Nothing (Store.sanitizePath "../../etc/passwd"),+ test "sanitizePath rejects backslash" $+ assertEqual "backslash" Nothing (Store.sanitizePath "..\\..\\etc\\passwd"),+ test "sanitizePath rejects empty" $+ assertEqual "empty" Nothing (Store.sanitizePath ""),+ test "sanitizePath accepts valid hash" $+ assertEqual "valid" (Just "abc123def456") (Store.sanitizePath "abc123def456"),+ test "read rejects traversal" $ do+ tmpDir <- createTestDir+ store <- Store.newFileStore tmpDir+ result <- Store.readNarInfo store "../../etc/passwd"+ removeDirectoryRecursive tmpDir+ assertEqual "blocked" Nothing result+ ]++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++-- | Create a temporary test directory.+createTestDir :: IO FilePath+createTestDir = do+ let dir = "/tmp/nova-cache-test"+ createDirectoryIfMissing True dir+ pure dir++-- | Generate a test Ed25519 secret key using crypton.+generateTestSecretKey :: IO Signing.SecretKey+generateTestSecretKey = do+ sk <- Ed25519.generateSecretKey+ let pk = Ed25519.toPublic sk+ skBytes = convert sk <> (convert pk :: ByteString)+ pure+ Signing.SecretKey+ { Signing.skName = "test-key",+ Signing.skBytes = skBytes+ }++-- | Derive the public key from a test secret key.+deriveTestPublicKey :: Signing.SecretKey -> Signing.PublicKey+deriveTestPublicKey sk =+ Signing.PublicKey+ { Signing.pkName = Signing.skName sk,+ Signing.pkBytes = BS.drop 32 (Signing.skBytes sk)+ }