packages feed

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