nova-cache 0.3.2.0 → 0.3.2.1
raw patch · 8 files changed
+136/−36 lines, 8 filesdep +wai-extra
Dependencies added: wai-extra
Files
- CHANGELOG.md +20/−1
- README.md +2/−1
- exe/Main.hs +63/−24
- nova-cache.cabal +2/−1
- src/NovaCache/Base64.hs +1/−1
- src/NovaCache/NarInfo.hs +1/−3
- src/NovaCache/Signing.hs +1/−1
- src/NovaCache/Store.hs +46/−4
CHANGELOG.md view
@@ -1,7 +1,26 @@ # Changelog +## 0.3.2.1 — 2026-03-18++- Replace partial `decodeUtf8` with total `decodeLatin1` in `Base64.encode`+ and `Signing.sign` — provably safe on base64 ASCII output, eliminates last+ partial function usage+- Fix redundant `T.breakOn` call in `NarInfo.parseLine`+- No API changes+ ## 0.3.2.0 — 2026-03-18 +### Server logging++- **Request logging** — Apache Combined format via `wai-extra` middleware,+ configurable with `LOG_REQUESTS=0` to disable.+- **Error logging** — auth rejections, validation failures, oversized uploads,+ and bad paths now logged to stderr with request method and path context.+- **`withLimitedBody` combinator** — extracted common body-limiting pattern from+ PUT handlers, reducing duplication.+- **`notFound` named constant** — replaces three inline 404 responses.+- Fixed `loadSigningKey` warnings going to stdout instead of stderr.+ ### Seed action fixes - **Resolve runtime outputs, not derivations** — `nix-instantiate` replaced@@ -14,7 +33,7 @@ - **Fix xargs line-too-long** — large path lists passed via file instead of inline. - **Diagnostic output** — upload failure HTTP codes now printed; `nix copy` errors no longer silenced.-- **Parallelism** — default reduced from 32 to 8; per-upload `--max-time` added.+- **Parallelism** — per-upload `--max-time` added (120s for NARs, 30s for narinfos). ### Server validation
README.md view
@@ -230,6 +230,7 @@ | `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). | ### Endpoints @@ -403,7 +404,7 @@ | `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: 8) |+| `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.
exe/Main.hs view
@@ -3,6 +3,7 @@ import Data.Bifunctor (first) import Data.ByteArray (constEq) import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy as BL import Data.Maybe (fromMaybe) import Data.Text (Text)@@ -23,6 +24,7 @@ strictRequestBody, ) import qualified Network.Wai.Handler.Warp as Warp+import Network.Wai.Middleware.RequestLogger (logStdout) import NovaCache.NarInfo (NarInfo (..), parseNarInfo, renderNarInfo) import NovaCache.Signing (SecretKey, parseSecretKey, sign) import NovaCache.Store (FileStore, getCacheInfo, listNarInfoHashes, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo)@@ -51,6 +53,10 @@ signingKeyEnvVar :: String signingKeyEnvVar = "SIGNING_KEY_FILE" +-- | Environment variable to disable request logging.+requestLogEnvVar :: String+requestLogEnvVar = "LOG_REQUESTS"+ -- | Maximum allowed request body size (100 MB). maxBodySize :: Int maxBodySize = 100 * 1024 * 1024@@ -77,6 +83,7 @@ storeEnv <- lookupEnv "NIX_CACHE_DIR" apiKeyEnv <- lookupEnv apiKeyEnvVar sigKeyPath <- lookupEnv signingKeyEnvVar+ logRequestsEnv <- lookupEnv requestLogEnvVar let port = case args of ("--port" : p : _) -> fromMaybe defaultPort (readMaybe p)@@ -95,11 +102,15 @@ cfgSigningKey = sigKey } + let logRequests = logRequestsEnv /= Just "0"+ requestLogger = if logRequests then logStdout else id+ 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)+ putStrLn ("request logging: " ++ if logRequests then "enabled" else "disabled (LOG_REQUESTS=0)")+ Warp.run port (requestLogger (app cfg)) -- | Load a signing key from a file, if configured. loadSigningKey :: Maybe FilePath -> IO (Maybe SecretKey)@@ -108,7 +119,7 @@ raw <- BS.readFile path case first show (TE.decodeUtf8' raw) >>= parseSecretKey . T.strip of Left err -> do- putStrLn ("WARNING: failed to load signing key: " ++ err)+ hPutStrLn stderr ("WARNING: failed to load signing key: " ++ err) pure Nothing Right sk -> pure (Just sk) @@ -135,7 +146,7 @@ Just content -> respond (responseLBS HTTP.status200 narInfoHeaders (BL.fromStrict content)) Nothing ->- respond (responseLBS HTTP.status404 textHeaders "not found")+ respond notFound -- GET /nar/<file> ("GET", ["nar", fileName]) -> do result <- readNar (cfgStore cfg) fileName@@ -143,39 +154,37 @@ Just content -> respond (responseLBS HTTP.status200 octetHeaders (BL.fromStrict content)) Nothing ->- respond (responseLBS HTTP.status404 textHeaders "not found")+ respond notFound -- PUT /<hash>.narinfo (auth required, validated) ("PUT", [hashNarinfo]) | Just hashKey <- T.stripSuffix ".narinfo" hashNarinfo ->- requireAuth cfg req respond $ do- bodyResult <- readBodyLimited req- case bodyResult of- Nothing ->- respond (responseLBS HTTP.status413 textHeaders "request body too large")- Just body -> case decodeAndValidate body of- Left err ->+ requireAuth cfg req respond $+ withLimitedBody req respond $ \body ->+ case decodeAndValidate body of+ Left err -> do+ logWarn req ("INVALID: " <> T.unpack err) respond (badRequest err) Right ni -> do signed <- signNarInfo (cfgSigningKey cfg) ni ok <- writeNarInfo (cfgStore cfg) hashKey signed if ok then respond (responseLBS HTTP.status200 textHeaders "ok")- else respond (badRequest "invalid path")+ else do+ logWarn req "BADPATH"+ respond (badRequest "invalid path") -- PUT /nar/<file> (auth required) ("PUT", ["nar", fileName]) ->- requireAuth cfg req respond $ do- bodyResult <- readBodyLimited req- case bodyResult of- Nothing ->- respond (responseLBS HTTP.status413 textHeaders "request body too large")- Just body -> do- ok <- writeNar (cfgStore cfg) fileName body- if ok- then respond (responseLBS HTTP.status200 textHeaders "ok")- else respond (badRequest "invalid path")+ requireAuth cfg req respond $+ withLimitedBody req respond $ \body -> do+ ok <- writeNar (cfgStore cfg) fileName body+ if ok+ then respond (responseLBS HTTP.status200 textHeaders "ok")+ else do+ logWarn req "BADPATH"+ respond (badRequest "invalid path") -- Fallback _ ->- respond (responseLBS HTTP.status404 textHeaders "not found")+ respond notFound -- --------------------------------------------------------------------------- -- Validation pipeline@@ -209,6 +218,16 @@ then pure Nothing else pure (Just body) +-- | Run an action with the limited request body, responding 413 if too large.+withLimitedBody :: Request -> (Response -> IO ResponseReceived) -> (BS.ByteString -> IO ResponseReceived) -> IO ResponseReceived+withLimitedBody req respond action = do+ bodyResult <- readBodyLimited req+ case bodyResult of+ Nothing -> do+ logWarn req "OVERLIMIT"+ respond (responseLBS HTTP.status413 textHeaders "request body too large")+ Just body -> action body+ -- --------------------------------------------------------------------------- -- Auth -- ---------------------------------------------------------------------------@@ -226,7 +245,9 @@ expectedHeader = "Bearer " <> expected in if maybe False (constEq expectedHeader) provided then action- else respond (responseLBS HTTP.status401 textHeaders "unauthorized")+ else do+ logWarn req "REJECTED"+ respond (responseLBS HTTP.status401 textHeaders "unauthorized") -- --------------------------------------------------------------------------- -- Signing@@ -251,6 +272,20 @@ renderNarInfoBytes = TE.encodeUtf8 . renderNarInfo -- ---------------------------------------------------------------------------+-- Logging+-- ---------------------------------------------------------------------------++-- | Log a server-side warning to stderr with request context.+logWarn :: Request -> String -> IO ()+logWarn req msg =+ hPutStrLn stderr $+ msg+ <> " "+ <> BS8.unpack (requestMethod req)+ <> " /"+ <> T.unpack (T.intercalate "/" (pathInfo req))++-- --------------------------------------------------------------------------- -- Response helpers -- --------------------------------------------------------------------------- @@ -269,6 +304,10 @@ boolText :: Bool -> Text boolText True = "1" boolText False = "0"++-- | 404 Not Found response.+notFound :: Response+notFound = responseLBS HTTP.status404 textHeaders "not found" -- | 400 Bad Request with a text error message. badRequest :: Text -> Response
nova-cache.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: nova-cache-version: 0.3.2.0+version: 0.3.2.1 synopsis: Pure Nix binary cache protocol library description: A focused, minimal, pure-first library implementing the full Nix binary@@ -92,6 +92,7 @@ , http-types >= 0.12 && < 0.13 , text >= 2.0 && < 2.2 , wai >= 3.2 && < 3.3+ , wai-extra >= 3.1 && < 3.2 , warp >= 3.3 && < 3.5 test-suite nova-cache-test
src/NovaCache/Base64.hs view
@@ -15,7 +15,7 @@ -- | Encode bytes to base64 'Text'. encode :: ByteString -> Text-encode = TE.decodeUtf8 . B64.encode+encode = TE.decodeLatin1 . B64.encode -- | Decode base64 'Text' to bytes. decode :: Text -> Either String ByteString
src/NovaCache/NarInfo.hs view
@@ -135,9 +135,7 @@ 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)+ (key, rest) -> Just (key, T.drop kvSeparatorLen rest) -- | Render a key-value pair. kv :: Text -> Text -> Text
src/NovaCache/Signing.hs view
@@ -148,7 +148,7 @@ let pk = Ed25519.toPublic sk msg = TE.encodeUtf8 (fingerprint ni) sig = Ed25519.sign sk pk msg- sigB64 = TE.decodeUtf8 (B64.encode (convert sig))+ sigB64 = TE.decodeLatin1 (B64.encode (convert sig)) pure (keyName <> keySeparator <> sigB64) -- | Verify a @keyname:base64sig@ signature against a narinfo and public key.
src/NovaCache/Store.hs view
@@ -19,6 +19,7 @@ ) where +import Control.Exception (SomeException, catch, onException) import Data.ByteString (ByteString) import qualified Data.ByteString as BS import Data.Text (Text)@@ -27,8 +28,11 @@ ( createDirectoryIfMissing, doesFileExist, listDirectory,+ removeFile,+ renameFile, )-import System.FilePath ((</>))+import System.FilePath (takeDirectory, (</>))+import System.IO (hClose, openBinaryTempFile) -- --------------------------------------------------------------------------- -- Types@@ -64,6 +68,10 @@ defaultStoreDir :: Text defaultStoreDir = "/nix/store" +-- | Prefix for temporary files used during atomic writes.+tempFilePrefix :: String+tempFilePrefix = ".nova.tmp"+ -- --------------------------------------------------------------------------- -- Initialization -- ---------------------------------------------------------------------------@@ -96,11 +104,13 @@ -- | Write a narinfo by its store path hash. --+-- Uses atomic write-to-temp-then-rename so concurrent readers never+-- see partial content. -- Returns 'False' for path components containing traversal sequences. writeNarInfo :: FileStore -> Text -> ByteString -> IO Bool writeNarInfo fs hashKey body = case sanitizePath hashKey of Nothing -> pure False- Just safe -> BS.writeFile (fsRoot fs </> narinfoSubdir </> safe) body >> pure True+ Just safe -> atomicWriteFile (fsRoot fs </> narinfoSubdir </> safe) body >> pure True -- --------------------------------------------------------------------------- -- NAR operations@@ -116,11 +126,13 @@ -- | Write a NAR file by its filename. --+-- Uses atomic write-to-temp-then-rename so concurrent readers never+-- see partial content. -- Returns 'False' for path components containing traversal sequences. writeNar :: FileStore -> Text -> ByteString -> IO Bool writeNar fs fileName body = case sanitizePath fileName of Nothing -> pure False- Just safe -> BS.writeFile (fsRoot fs </> narSubdir </> safe) body >> pure True+ Just safe -> atomicWriteFile (fsRoot fs </> narSubdir </> safe) body >> pure True -- --------------------------------------------------------------------------- -- Listing@@ -129,8 +141,11 @@ -- | List all narinfo hashes currently stored. -- -- Returns the filenames in the @narinfo/@ subdirectory as 'Text' values.+-- Filters out temporary files from in-progress atomic writes. listNarInfoHashes :: FileStore -> IO [Text]-listNarInfoHashes fs = map T.pack <$> listDirectory (fsRoot fs </> narinfoSubdir)+listNarInfoHashes fs =+ filter (not . T.isPrefixOf ".") . map T.pack+ <$> listDirectory (fsRoot fs </> narinfoSubdir) -- --------------------------------------------------------------------------- -- Cache metadata@@ -161,6 +176,33 @@ -- --------------------------------------------------------------------------- -- Internal -- ---------------------------------------------------------------------------++-- | Write a file atomically via write-to-temp-then-rename.+--+-- Writes to a temporary file in the same directory, then renames to+-- the target path. On POSIX, @rename@ is atomic — readers see either+-- the old content or the new content, never a partial write.+-- Cleans up the temporary file on failure.+atomicWriteFile :: FilePath -> ByteString -> IO ()+atomicWriteFile target content = do+ let dir = takeDirectory target+ (tmpPath, h) <- openBinaryTempFile dir tempFilePrefix+ let cleanup = do+ ignoringExceptions (hClose h)+ ignoringExceptions (removeFile tmpPath)+ ( do+ BS.hPut h content+ hClose h+ renameFile tmpPath target+ )+ `onException` cleanup++-- | Swallow all exceptions from a cleanup action.+ignoringExceptions :: IO () -> IO ()+ignoringExceptions action = action `catch` silenceException+ where+ silenceException :: SomeException -> IO ()+ silenceException _ = pure () -- | Read a file if it exists, returning 'Nothing' otherwise. readIfExists :: FilePath -> IO (Maybe ByteString)