Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

# Drop the default GITHUB_TOKEN to read-only; nothing here writes via it.
permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -71,13 +75,10 @@ jobs:
include:
- os: ubuntu-latest
name: Linux
compression: true
- os: macos-latest
name: macOS
compression: true
- os: windows-latest
name: Windows
compression: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand All @@ -95,15 +96,13 @@ jobs:
key: cabal-${{ runner.os }}-${{ hashFiles('nova-cache.cabal') }}
restore-keys: cabal-${{ runner.os }}-

- name: Install liblzma (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y liblzma-dev

# all + -f server + --enable-tests so the server executable and the
# test suites hit the same -Werror bar as the library on every OS.
- name: Build
run: cabal build --ghc-options="-Werror" ${{ matrix.compression == false && '-f-compression' || '' }}
run: cabal build all -f server --enable-tests --ghc-options="-Werror"

- name: Test
run: cabal test ${{ matrix.compression == false && '-f-compression' || '' }}
run: cabal test -f server --ghc-options="-Werror"

# ---------------------------------------------------------------------------
# Packaging check (Linux-only)
Expand All @@ -121,6 +120,40 @@ jobs:
cabal-version: 'latest'
- run: cabal check

# ---------------------------------------------------------------------------
# Build from the sdist, exactly as a Hackage user does (Linux-only).
# CI otherwise builds the git tree; the tarball is what users install,
# and any divergence (files missing from the sdist, dev-only project
# settings) is invisible without this job.
# ---------------------------------------------------------------------------

sdist:
name: Build from sdist
needs: [lint, format]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: haskell-actions/setup@v2
id: setup
with:
ghc-version: '9.8'
cabal-version: 'latest'

- name: Cache Cabal store
uses: actions/cache@v5
with:
path: ${{ steps.setup.outputs.cabal-store }}
key: cabal-sdist-${{ hashFiles('nova-cache.cabal') }}
restore-keys: cabal-sdist-

- name: Build the tarball a Hackage user gets
run: |
cabal sdist -o "$RUNNER_TEMP/sdist"
tar -xzf "$RUNNER_TEMP"/sdist/nova-cache-*.tar.gz -C "$RUNNER_TEMP"
cd "$RUNNER_TEMP"/nova-cache-*/
cabal build all -f server --enable-tests

# ---------------------------------------------------------------------------
# Deploy (Linux, main branch only)
# ---------------------------------------------------------------------------
Expand All @@ -142,7 +175,10 @@ jobs:
run: |
# Install cloudflared and reach the box through Cloudflare Access:
# its public port 22 is closed, so SSH is Access-only now.
curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared
# Pinned version + checksum: the deploy must not run whatever
# binary "latest" serves on a given day.
curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2026.7.1/cloudflared-linux-amd64 -o /tmp/cloudflared
echo "79a0ade7fc854f62c1aaef48424d9d979e8c2fcd039189d24db82b84cd146be1 /tmp/cloudflared" | sha256sum -c -
sudo install -m 0755 /tmp/cloudflared /usr/local/bin/cloudflared

mkdir -p ~/.ssh
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

# Drop the default GITHUB_TOKEN to read-only; the Hackage upload uses its own
# token (HACKAGE_TOKEN), not GITHUB_TOKEN.
permissions:
contents: read

jobs:
ci:
name: CI
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 0.5.0.0 - 2026-07-12

- **Signing: fingerprints sort and deduplicate references.** C++ Nix computes and verifies narinfo fingerprints over a sorted, deduplicated store-path set; signing in the narinfo's file order produced signatures real Nix clients reject while nova-cache's own `verify` (recomputing from the same order) passed and masked the divergence. References are now sorted by basename and deduplicated before signing.
- **Removed `NovaCache.Compression`, the `compression` flag, and the `lzma` dependency.** The module had no consumer, and the default-on manual flag made every Hackage install require system liblzma dev files, which plain Windows and minimal Linux machines lack - `cabal install` of downstream packages failed while CI (which pins the flag off inside the repo) stayed green. xz support returns with its first real consumer as a size-bounded decoder suitable for untrusted cache data.
- **Wire-format strictness now matches upstream Nix.** `validateNarInfo` requires the StorePath field to be absolute and references to be bare basenames (the other spellings produce narinfos real clients reject at parse time, and a bare StorePath also derived an empty store dir inside the signed fingerprint); store-path names are ASCII-only and capped at 211 characters; NAR parsing rejects backslashes in entry names (the Windows traversal vector) and nonzero string padding; and key parsing rejects an empty name or empty key material at load time instead of producing signatures no trust anchor can match. New `parseStorePathBaseName` and `parseAbsoluteStorePath` expose the per-field parsers.
- **Upstream-optional narinfo fields are now optional.** Only StorePath, URL, NarHash, and NarSize are required; `Compression` defaults to bzip2 as upstream, and `FileHash`/`FileSize` are `Maybe` (breaking record change, covered by the major bump). Valid narinfos from foreign caches no longer fail to parse over absent optional fields.
- **New `NovaCache.Server` module: the cache's HTTP protocol as a WAI `Application`.** Routing, write authentication, request-body limits, and the narinfo validation/signing pipeline move from the server executable into tested library API. Deployment branding stays out of the library: embedders supply their own root-page response, and the bundled executable carries its landing page itself. Adds `wai` and `http-types` to the library dependencies.
- **`GET /narinfo-hashes` requires the write key and is `Cache-Control: no-store`.** The listing enumerates the whole store - something the public cache protocol deliberately never offers - and lists a directory per hit; it exists only for the push tool, which already holds the write key.
- **NAR bodies no longer transit memory.** Uploads stream to a temp file under a running size cap and rename into place atomically (`NovaCache.Store.writeNarStreaming`); downloads are served from disk via WAI's `responseFile` (`NovaCache.Store.narFilePath`). A multi-GB NAR previously occupied that much RAM per request in both directions.
- **`HEAD` is answered wherever `GET` is.** Clients probing narinfo existence with `HEAD` previously got 404.
- **The server refuses to start when `CACHE_API_KEY` normalizes to empty** (BOM or whitespace only - the copy-paste artifact). An empty armed key would authenticate an empty bearer token.
- **The server refuses to start when a configured signing key fails to load.** It previously logged a warning and ran unsigned, persisting narinfos no trust anchor can verify - the same misconfiguration class the key parser now rejects, closed at the process boundary too.
- **Configurable bind host: `--host` / `HOST`.** The default stays all interfaces, so existing deployments do not silently rebind.
- The deploy workflow pins cloudflared by version and checksum instead of pulling `latest`.
- **The sdist ships `NOTICE`.** Apache-2.0 section 4(d) asks redistributions to carry it; the file existed in the repo but not in the released tarball.
- **CI builds from the sdist in isolation**, so tree-vs-tarball divergences (files missing from the tarball, dev-only project settings) fail the pipeline instead of surfacing at install time. CI also compiles the server executable and test suites under `-Werror` on every platform.
- Dropped the server executable's unused `crypton` dependency.
- Workflows run with a read-only `GITHUB_TOKEN`.

## 0.4.2.1 - 2026-06-12

- **Relicensed from BSD-3-Clause to Apache-2.0.** Apache adds an explicit patent grant and trademark terms, and a `NOTICE` file now carries the copyright (Novavero AI Inc.). Earlier releases on Hackage remain under their original licenses.
Expand Down
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div align="center">
<h1>nova-cache</h1>
<p><strong>The Nix binary cache protocol, in Haskell.</strong></p>
<p>nix-base32, NAR archives, narinfo, store paths, and Ed25519 signing - with an optional WAI cache server. A pure core; IO is confined to the compression, storage, and server boundaries.</p>
<p>nix-base32, NAR archives, narinfo, store paths, and Ed25519 signing - with an optional WAI cache server. A pure core; IO is confined to the storage and server boundaries.</p>

[![CI](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml)
[![Hackage](https://img.shields.io/hackage/v/nova-cache.svg)](https://hackage.haskell.org/package/nova-cache)
Expand All @@ -18,9 +18,6 @@
build-depends: nova-cache
```

The `compression` flag (on by default) requires the system `liblzma`. Build
with `-f-compression` if you only need hashing, NAR, or narinfo.

## Usage

```haskell
Expand Down Expand Up @@ -57,11 +54,16 @@ case validateFull publicKey ni narBytes fileBytes of
cabal run --flag server nova-cache-server -- --port 5000 --store ./nix-cache
```

The protocol itself lives in the `NovaCache.Server` library module as a WAI
`Application`, so any operator can embed the cache in their own server with
their own root page; the bundled executable is one such embedding.

### Configuration

| Variable | Description |
| --- | --- |
| `PORT` | Listen port (default: 5000) |
| `PORT` | Listen port (default: 5000; also `--port`) |
| `HOST` | Bind host (default: all interfaces; also `--host`) |
| `NIX_CACHE_DIR` | Store directory (default: `./nix-cache`) |
| `CACHE_API_KEY` | Bearer token required for `PUT`. The server refuses to start without it unless `--allow-open-writes` is passed. |
| `SIGNING_KEY_FILE` | Ed25519 secret key file for server-side narinfo signing |
Expand All @@ -73,11 +75,13 @@ cabal run --flag server nova-cache-server -- --port 5000 --store ./nix-cache
| --- | --- | --- |
| `GET` | `/` | Landing page: live stats and the cache public key |
| `GET` | `/nix-cache-info` | Cache metadata |
| `GET` | `/narinfo-hashes` | All cached narinfo hashes, newline-delimited |
| `GET` | `/narinfo-hashes` | All cached narinfo hashes, newline-delimited (authenticated) |
| `GET` | `/<hash>.narinfo` | Fetch a narinfo |
| `GET` | `/nar/<file>` | Fetch a NAR |
| `GET` | `/nar/<file>` | Fetch a NAR (streamed from disk) |
| `PUT` | `/<hash>.narinfo` | Upload a narinfo (authenticated, validated) |
| `PUT` | `/nar/<file>` | Upload a NAR (authenticated) |
| `PUT` | `/nar/<file>` | Upload a NAR (authenticated, streamed to disk) |

`HEAD` is answered wherever `GET` is.

### Public cache

Expand All @@ -95,7 +99,7 @@ cabal build
cabal test
```

Optional flags: `-f-compression` skips the `liblzma` dependency, and `--flag server` builds the cache server. Requires GHC 9.8+ and cabal-install 3.10+.
Optional flag: `--flag server` builds the cache server. Requires GHC 9.8+ and cabal-install 3.10+.

---

Expand Down
110 changes: 110 additions & 0 deletions exe/LandingPage.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
-- | The cache.novavero.ai landing page.
--
-- Deployment branding lives here, in the executable: the
-- "NovaCache.Server" library is brand-free, taking whatever root
-- response its embedder supplies, so other operators running their own
-- cache never ship this page.
module LandingPage (landingResponse) where

import qualified Data.ByteString.Lazy as BL
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 (Response, responseLBS)
import NovaCache.Store (CacheInfo (..), FileStore, getCacheInfo, listNarInfoHashes)

-- | Build the @GET \/@ response: the branded page around live values
-- (store-path count, store dir, signing status, public key).
landingResponse :: FileStore -> Bool -> Maybe Text -> IO Response
landingResponse store signingEnabled pubKey = do
pathCount <- length <$> listNarInfoHashes store
let body = TE.encodeUtf8 (landingHtml (getCacheInfo store) signingEnabled pubKey pathCount)
pure (responseLBS HTTP.status200 htmlHeaders (BL.fromStrict body))

-- | The landing page markup. Static apart from four live values; styled
-- to match novavero.ai. Nothing user-supplied is interpolated - the key
-- line is operator configuration - so no escaping is needed.
landingHtml :: CacheInfo -> Bool -> Maybe Text -> Int -> Text
landingHtml info signingEnabled pubKey pathCount =
T.unlines
[ "<!DOCTYPE html>",
"<html lang=\"en\">",
"<head>",
"<meta charset=\"UTF-8\" />",
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />",
"<title>cache.novavero.ai - Nix binary cache</title>",
"<meta name=\"description\" content=\"The Novavero Nix binary cache, serving store paths for nova-nix - the Windows-native Nix.\" />",
"<link rel=\"icon\" type=\"image/svg+xml\" href=\"data:image/svg+xml," <> novaveroLogoSvgEscaped <> "\" />",
"<style>",
"* { margin: 0; padding: 0; box-sizing: border-box; }",
"body { background: #0a0b0e; color: #d6d9de; -webkit-font-smoothing: antialiased; font-family: 'Inter', system-ui, sans-serif; line-height: 1.75; }",
"body::before { content: ''; position: fixed; inset: 0 0 auto 0; height: 320px; pointer-events: none; background: radial-gradient(ellipse 70% 100% at 50% -20%, rgba(52,211,153,0.07), transparent 70%); }",
".container { max-width: 720px; margin: 0 auto; padding: 80px 24px; position: relative; }",
"a { color: #34d399; text-decoration: none; }",
"a:hover { text-decoration: underline; }",
".brand { display: flex; align-items: center; gap: 14px; margin-bottom: 0.75rem; }",
".brand svg { width: 44px; height: 44px; border-radius: 10px; }",
"h1 { color: #fff; font-size: 1.6rem; font-family: ui-monospace, Consolas, monospace; }",
".tagline { color: #9ca3af; margin-bottom: 2.5rem; }",
".stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 2.5rem; }",
".stat { padding: 16px; border: 1px solid #232733; border-radius: 12px; text-align: center; }",
".stat .value { color: #fff; font-size: 1.4rem; font-weight: 600; }",
".stat .label { color: #6b7280; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }",
"h2 { color: #fff; font-size: 1rem; margin: 2rem 0 0.5rem; }",
"p { font-size: 0.9rem; margin-bottom: 0.75rem; }",
"pre { background: #0d0f14; border: 1px solid #232733; border-radius: 8px; padding: 14px; white-space: pre-wrap; word-break: break-all; margin: 0.75rem 0; }",
"code { font-family: ui-monospace, Consolas, monospace; font-size: 0.85rem; color: #e5e7eb; }",
".footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid #232733; font-size: 0.85rem; color: #6b7280; }",
"</style>",
"</head>",
"<body>",
"<div class=\"container\">",
"<div class=\"brand\">" <> novaveroLogoSvg <> "<h1>cache.novavero.ai</h1></div>",
"<p class=\"tagline\">Nix binary cache - serving store paths for <a href=\"https://github.com/Novavero-AI/nova-nix\">nova-nix</a>, the Windows-native Nix.</p>",
"<div class=\"stats\">",
"<div class=\"stat\"><div class=\"value\">" <> T.pack (show pathCount) <> "</div><div class=\"label\">store paths</div></div>",
"<div class=\"stat\"><div class=\"value\">" <> (if signingEnabled then "ed25519" else "off") <> "</div><div class=\"label\">signing</div></div>",
"<div class=\"stat\"><div class=\"value\">" <> T.pack (show (ciPriority info)) <> "</div><div class=\"label\">priority</div></div>",
"</div>",
"<h2>Use it</h2>",
"<pre><code>substituters = https://cache.novavero.ai" <> trustAnchorLine <> "</code></pre>",
"<p>Store dir: <code>" <> ciStoreDir info <> "</code> &middot; protocol endpoints: <code>/nix-cache-info</code>, <code>/&lt;hash&gt;.narinfo</code>, <code>/nar/&lt;file&gt;</code></p>",
"<h2>What this is</h2>",
"<p>The binary cache behind the Novavero Nix toolchain. Powered by <a href=\"https://github.com/Novavero-AI/nova-cache\">nova-cache</a>, a Haskell implementation of the Nix binary cache protocol. Read about the first package built by Nix natively on Windows on <a href=\"https://novavero.ai/blog/first-native-windows-nix-build.html\">the blog</a>.</p>",
"<div class=\"footer\"><a href=\"https://novavero.ai\">Novavero AI</a> &middot; Waterloo, Canada</div>",
"</div>",
"</body>",
"</html>"
]
where
trustAnchorLine = maybe "" ("\ntrusted-public-keys = " <>) pubKey

-- | The Novavero mark (the novavero.ai favicon), inlined so the page stays
-- a single self-contained response with no external assets.
novaveroLogoSvg :: Text
novaveroLogoSvg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" role=\"img\" aria-label=\"Novavero\">"
<> "<g fill=\"#0a0f1a\"><rect width=\"64\" height=\"64\" rx=\"14\" ry=\"14\"/></g>"
<> "<g fill=\"#ffffff\"><path d=\"M12 54L19 54L23 10L16 10Z\"/><path d=\"M16 10L23 10L48 54L41 54Z\"/><path d=\"M41 54L48 54L52 10L45 10Z\"/></g>"
<> "</svg>"

-- | The same mark, URL-escaped for a data-URI favicon link.
novaveroLogoSvgEscaped :: Text
novaveroLogoSvgEscaped =
T.concatMap escapeForDataUri novaveroLogoSvg
where
escapeForDataUri c = case c of
'<' -> "%3C"
'>' -> "%3E"
'"' -> "%22"
'#' -> "%23"
other -> T.singleton other

-- | Content-Type and caching headers for the landing page. Stats change as
-- paths are added, so it stays briefly cacheable but revalidates.
htmlHeaders :: HTTP.ResponseHeaders
htmlHeaders =
[ (HTTP.hContentType, "text/html; charset=utf-8"),
(HTTP.hCacheControl, "public, max-age=300, must-revalidate")
]
Loading
Loading