Skip to main content

sui_cache/
server.rs

1//! Axum HTTP server implementing the Nix binary cache protocol.
2//!
3//! Endpoints:
4//! - `GET /nix-cache-info` — cache metadata
5//! - `GET /{hash}.narinfo` — narinfo metadata
6//! - `PUT /{hash}.narinfo` — upload narinfo
7//! - `GET /nar/{path}` — download NAR blob
8//! - `PUT /nar/{path}` — upload NAR blob
9
10use std::sync::Arc;
11
12use axum::body::{Body, Bytes};
13use axum::extract::{DefaultBodyLimit, Path, State};
14use axum::http::{HeaderMap, StatusCode};
15use axum::response::IntoResponse;
16use axum::routing::get;
17use axum::Router;
18
19use crate::config::CacheConfig;
20use crate::signing::CacheSigner;
21use crate::StorageBackend;
22use sui_compat::narinfo::NarInfo;
23
24/// Shared application state for all handlers.
25#[derive(Clone)]
26pub struct AppState {
27    /// The storage backend.
28    pub storage: Arc<dyn StorageBackend>,
29    /// Cache configuration.
30    pub config: CacheConfig,
31    /// The ed25519 signer, loaded from `config.signing_key` at startup.
32    ///
33    /// When present, every narinfo is signed at ingest (`put_narinfo`) so
34    /// the durable tier carries a `Sig:` field and every serving tier
35    /// inherits it — the signature is content-addressed with the store path
36    /// (the fingerprint is over the path), so it deduplicates for free. When
37    /// `None`, the cache serves narinfo bytes verbatim (the legacy
38    /// pass-through, fail-open behavior).
39    pub signer: Option<Arc<CacheSigner>>,
40}
41
42/// Build the axum router for the binary cache server.
43#[must_use]
44pub fn build_router(state: AppState) -> Router {
45    Router::new()
46        .route("/nix-cache-info", get(cache_info))
47        .route("/{hash_narinfo}", get(get_narinfo).put(put_narinfo))
48        .route("/nar/{*path}", get(get_nar).put(put_nar))
49        // Real Nix NARs routinely exceed axum's default 2 MiB body limit
50        // (Go binaries, dockerTools image layers). Disable it so
51        // `nix copy --to http://<sui>` write-through stores large NARs
52        // instead of returning HTTP 413. (Closes ground-truth Gap B.)
53        .layer(DefaultBodyLimit::disable())
54        .with_state(state)
55}
56
57/// Start the cache server and listen for connections.
58///
59/// # Errors
60///
61/// Returns an error if binding or serving fails.
62pub async fn serve(config: CacheConfig, storage: Arc<dyn StorageBackend>) -> Result<(), crate::CacheError> {
63    let listen = config.listen.clone();
64
65    // Load the ed25519 signing key (if configured) at startup. The key is
66    // sourced from a file path — in production that path is a cofre/ESO-
67    // materialized Kubernetes Secret mount, never a plaintext literal. When
68    // no key is configured the daemon serves unsigned (the legacy behavior);
69    // a warning is logged so the fail-open posture is never silent.
70    let signer = match &config.signing_key {
71        Some(path) => {
72            let key_str = std::fs::read_to_string(path).map_err(crate::CacheError::Io)?;
73            let signer = CacheSigner::from_secret_key_string(key_str.trim())?;
74            tracing::info!(
75                key_name = signer.key_name(),
76                public_key = %signer.public_key_string(),
77                "sui-cache signing ENABLED — every ingested narinfo is signed; \
78                 distribute the public key to consumers as a trusted-public-key",
79            );
80            Some(Arc::new(signer))
81        }
82        None => {
83            tracing::warn!(
84                "sui-cache signing DISABLED (no signing_key configured) — narinfo \
85                 served unsigned; consumers cannot verify integrity. Set a \
86                 cofre/ESO-backed signing key to close the poisoned-write hole.",
87            );
88            None
89        }
90    };
91
92    let state = AppState {
93        storage,
94        config,
95        signer,
96    };
97    let app = build_router(state);
98
99    tracing::info!("sui-cache listening on {listen}");
100    let listener = tokio::net::TcpListener::bind(&listen)
101        .await
102        .map_err(crate::CacheError::Io)?;
103    axum::serve(listener, app)
104        .await
105        .map_err(crate::CacheError::Io)?;
106    Ok(())
107}
108
109/// Sign narinfo text at ingest, returning the signed text.
110///
111/// Idempotent: if the narinfo already carries a signature under this
112/// signer's key name, the text is returned unchanged (so a re-`put` of an
113/// already-signed path does not double-sign). Otherwise the signer's
114/// `keyname:base64sig` is appended and the narinfo re-serialized.
115///
116/// # Errors
117///
118/// Returns [`CacheError::NarInfo`](crate::CacheError::NarInfo) if the text
119/// cannot be parsed as a narinfo.
120fn sign_narinfo_text(signer: &CacheSigner, content: &str) -> Result<String, crate::CacheError> {
121    let mut info = NarInfo::parse(content)
122        .map_err(|e| crate::CacheError::NarInfo(e.to_string()))?;
123
124    let key_prefix = format!("{}:", signer.key_name());
125    if info.signatures.iter().any(|s| s.starts_with(&key_prefix)) {
126        // Already signed by us — do not double-sign; return as-is.
127        return Ok(content.to_string());
128    }
129
130    let sig = signer.sign_narinfo(&info);
131    info.signatures.push(sig);
132    Ok(info.serialize())
133}
134
135/// `GET /nix-cache-info` — returns cache metadata.
136async fn cache_info(State(state): State<AppState>) -> impl IntoResponse {
137    let body = format!(
138        "StoreDir: {}\nWantMassQuery: {}\nPriority: {}\n",
139        state.config.store_dir,
140        if state.config.want_mass_query { 1 } else { 0 },
141        state.config.priority,
142    );
143    (
144        StatusCode::OK,
145        [("content-type", "text/x-nix-cache-info")],
146        body,
147    )
148}
149
150/// `GET /{hash}.narinfo` — returns narinfo text.
151async fn get_narinfo(
152    State(state): State<AppState>,
153    Path(hash_narinfo): Path<String>,
154) -> impl IntoResponse {
155    let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
156        return StatusCode::NOT_FOUND.into_response();
157    };
158
159    match state.storage.get_narinfo(hash).await {
160        // A STORED-BUT-UNUSABLE narinfo is served as a MISS, never as a hit.
161        //
162        // Measured on camelot-eks 2026-08-05: two rows in the durable tier held a
163        // zero-length value, and this arm happily returned them as
164        // `200 text/x-nix-narinfo` with an empty body. Nix parses that as a
165        // narinfo, finds no `StorePath:`, and fails the whole operation:
166        //
167        //     error: NAR info file 'kkknnnlv5xplv4ilsfskdvccmvi4ia7i.narinfo'
168        //            is corrupt: StorePath missing
169        //
170        // Two poisoned rows out of 6898 were enough to abort EVERY
171        // `nix copy --to` against this cache, because the client hits the bad
172        // entry while querying which paths the destination already has.
173        //
174        // That is strictly worse than not having the entry at all, and it is the
175        // same argument the `Err` arm below already makes: a 404 is a miss and
176        // the build proceeds; anything else converts a cold accelerator into a
177        // hard dependency. An unusable hit is a miss that lies, so it is
178        // classified with the misses.
179        Ok(Some(content)) if !crate::is_servable_narinfo(&content) => {
180            tracing::error!(
181                hash = %hash,
182                len = content.len(),
183                "get_narinfo: stored narinfo is empty or has no StorePath — SERVING 404 so the \
184                 client treats it as a miss instead of aborting; this entry is poison and should \
185                 be evicted",
186            );
187            StatusCode::NOT_FOUND.into_response()
188        }
189        Ok(Some(content)) => (
190            StatusCode::OK,
191            [("content-type", "text/x-nix-narinfo")],
192            content,
193        )
194            .into_response(),
195        Ok(None) => StatusCode::NOT_FOUND.into_response(),
196        // A cache read is DEFINITIONALLY optional: if the storage cannot answer,
197        // the honest answer to the client is "I don't have it" (404), never
198        // "something is broken" (500). Nix treats a 404 as a cache miss and
199        // builds; it treats a 500 as fatal, retries, and aborts the build. So a
200        // 500 here converts a cold accelerator into a hard dependency and takes
201        // down every consuming pipeline — which is exactly what it did.
202        //
203        // Loud at ERROR so the degradation is never silent: the request
204        // survives, the fault stays visible.
205        Err(e) => {
206            tracing::error!(
207                hash = %hash,
208                error = %e,
209                "get_narinfo: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
210                 client rebuilds instead of aborting; the backend needs attention",
211            );
212            StatusCode::NOT_FOUND.into_response()
213        }
214    }
215}
216
217/// `PUT /{hash}.narinfo` — uploads narinfo text.
218async fn put_narinfo(
219    State(state): State<AppState>,
220    Path(hash_narinfo): Path<String>,
221    body: Bytes,
222) -> impl IntoResponse {
223    let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
224        return StatusCode::BAD_REQUEST.into_response();
225    };
226
227    let content = match String::from_utf8(body.to_vec()) {
228        Ok(s) => s,
229        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
230    };
231
232    // REFUSE AT INGEST what can never be served. `StorePath:` is what makes a
233    // narinfo a narinfo — nix's own reader fails with "corrupt: StorePath
234    // missing" without it — so a body lacking one is a malformed upload, which
235    // is the client's fault and belongs with the other 400s above.
236    //
237    // This is the CAUSE half of the pair; the read path also refuses to serve an
238    // unusable entry, because entries predating this check are already in the
239    // durable tier and a cache that can be poisoned once will be again. Fixing
240    // only the read would leave the tier accumulating garbage; fixing only the
241    // write would leave the existing garbage fatal.
242    if !crate::is_servable_narinfo(&content) {
243        tracing::warn!(
244            hash = %hash,
245            len = content.len(),
246            "put_narinfo: refusing a narinfo with no StorePath line — an entry that cannot be \
247             served is worse than an absent one, because nix aborts on it instead of missing",
248        );
249        return StatusCode::BAD_REQUEST.into_response();
250    }
251
252    // A narinfo's `URL:` becomes a storage key, and on the local tier a path
253    // joined onto the cache root — so `URL: ../../etc/passwd` has to be refused
254    // rather than sanitized at each of those uses. The backend refuses it too,
255    // but a refusal there arrives as a 500 (a server fault); a malformed upload
256    // is the client's, so it is classified here.
257    if let Some(url) = crate::advertised_url_line(&content) {
258        if !crate::is_addressable_nar_path(url) {
259            tracing::warn!(
260                hash = %hash, url = %url,
261                "put_narinfo: refusing a narinfo whose URL is not an addressable relative path",
262            );
263            return StatusCode::BAD_REQUEST.into_response();
264        }
265    }
266
267    // Sign at ingest when a signer is configured, so the durable tier stores
268    // the signed narinfo and every serving tier inherits the `Sig:`.
269    let to_store = match &state.signer {
270        Some(signer) => match sign_narinfo_text(signer, &content) {
271            Ok(signed) => signed,
272            Err(e) => {
273                tracing::error!("put_narinfo signing error: {e}");
274                return StatusCode::BAD_REQUEST.into_response();
275            }
276        },
277        None => content,
278    };
279
280    // WRITE-PATH POLICY — deliberately NOT symmetric with the read path.
281    //
282    // A read has a well-defined "I don't have it" answer in the Nix binary-cache
283    // protocol (404), and the client's correct response to it is to build. A
284    // write has NO "I did not store it" success answer: returning 200 on a
285    // failed write tells the client the path is cached when it is not, so the
286    // push pipeline silently does nothing forever and no operator ever learns
287    // the cache stopped filling. That is the silent-degradation bug this whole
288    // change is against, just pointed the other way.
289    //
290    // So a failed write stays a 5xx — but the failure it reports is now much
291    // rarer and much more honest: `TieredBackend` attempts EVERY durable tier
292    // and succeeds if any one accepted the write, so this fires only when
293    // nothing was stored anywhere. One broken durable tier (the Postgres-OOM
294    // case) no longer fails the push.
295    match state.storage.put_narinfo(hash, &to_store).await {
296        Ok(()) => StatusCode::OK.into_response(),
297        Err(e) => {
298            tracing::error!(
299                hash = %hash,
300                error = %e,
301                "put_narinfo: EVERY durable tier rejected the write — nothing stored; \
302                 reporting failure rather than falsely acknowledging the upload",
303            );
304            StatusCode::INTERNAL_SERVER_ERROR.into_response()
305        }
306    }
307}
308
309/// The NAR media type implied by a URL suffix.
310fn nar_content_type(path: &str) -> &'static str {
311    if path.ends_with(".xz") {
312        "application/x-xz"
313    } else if path.ends_with(".zstd") || path.ends_with(".zst") {
314        "application/zstd"
315    } else {
316        "application/x-nix-nar"
317    }
318}
319
320/// `GET /nar/{path}` — **streams** a compressed NAR blob.
321///
322/// The body is wired straight from the backend's chunk stream to the socket, so
323/// serving a 2 GiB NAR costs this process one chunk, not 2 GiB. It used to
324/// collect the blob into a `Vec<u8>` and hand axum the whole thing.
325///
326/// The cost of streaming: the status line is committed before the bytes are
327/// known-good, so a fault *mid-body* can no longer become a 404. It shows up as
328/// a truncated response, which Nix rejects on the NarHash it already has from
329/// the narinfo — a loud client-side failure, not a silent bad substitution. A
330/// fault *before* the first byte still degrades to a miss exactly as before.
331async fn get_nar(
332    State(state): State<AppState>,
333    Path(path): Path<String>,
334) -> impl IntoResponse {
335    let nar_path = format!("nar/{path}");
336    match state.storage.get_nar_stream(&nar_path).await {
337        Ok(Some(stream)) => {
338            let mut headers = HeaderMap::new();
339            headers.insert("content-type", nar_content_type(&path).parse().unwrap());
340            (StatusCode::OK, headers, Body::from_stream(stream)).into_response()
341        }
342        Ok(None) => StatusCode::NOT_FOUND.into_response(),
343        // Same rule as `get_narinfo`: an unanswerable read is a miss, not a
344        // server error. See that handler for why 500 here is load-bearing-fatal.
345        Err(e) => {
346            tracing::error!(
347                nar_path = %nar_path,
348                error = %e,
349                "get_nar: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
350                 client rebuilds instead of aborting; the backend needs attention",
351            );
352            StatusCode::NOT_FOUND.into_response()
353        }
354    }
355}
356
357/// `PUT /nar/{path}` — **streams** a compressed NAR blob into storage.
358///
359/// The request body is spooled in bounded chunks (see
360/// [`spool_or_buffer`](sui_castore::spool_or_buffer)) and handed to the backend
361/// as a re-openable source. It used to arrive as `body: Bytes` — axum collecting
362/// every frame and concatenating them — which put the whole NAR in the heap
363/// *before* storage even saw it, on top of whatever each tier then copied.
364///
365/// The spool directory is `TMPDIR` (via `std::env::temp_dir()`), so an operator
366/// points it at a real volume without a code change. If no spool file can be
367/// created the ingest falls back to a **capped** in-memory buffer and NARs above
368/// the cap are refused — bounded either way, never unbounded.
369async fn put_nar(
370    State(state): State<AppState>,
371    Path(path): Path<String>,
372    body: Body,
373) -> impl IntoResponse {
374    let nar_path = format!("nar/{path}");
375
376    let src = match sui_castore::spool_or_buffer(
377        body.into_data_stream(),
378        &std::env::temp_dir(),
379        sui_castore::DEFAULT_INGEST_MEMORY_CAP,
380    )
381    .await
382    {
383        Ok(src) => src,
384        Err(e) => {
385            tracing::error!(
386                nar_path = %nar_path,
387                error = %e,
388                "put_nar: could not stage the upload (spool write failed, or it exceeded \
389                 the in-memory fallback cap) — nothing stored",
390            );
391            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
392        }
393    };
394
395    // See `put_narinfo` for the write-path policy and why it is deliberately
396    // asymmetric with the read path.
397    match state.storage.put_nar_stream(&nar_path, src.as_ref()).await {
398        Ok(()) => StatusCode::OK.into_response(),
399        Err(e) => {
400            tracing::error!(
401                nar_path = %nar_path,
402                error = %e,
403                "put_nar: EVERY durable tier rejected the write — nothing stored; \
404                 reporting failure rather than falsely acknowledging the upload",
405            );
406            StatusCode::INTERNAL_SERVER_ERROR.into_response()
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::config::BackendConfig;
415    use crate::LocalStorage;
416    use axum::body::Body;
417    use http_body_util::BodyExt;
418    use tower::ServiceExt;
419
420    fn test_app(dir: &std::path::Path) -> Router {
421        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
422        let config = CacheConfig {
423            listen: "127.0.0.1:0".to_string(),
424            backend: BackendConfig::Local {
425                path: dir.to_path_buf(),
426            },
427            priority: 40,
428            want_mass_query: true,
429            store_dir: "/nix/store".to_string(),
430            signing_key: None,
431            require_sigs: false,
432        };
433        build_router(AppState { storage, config, signer: None })
434    }
435
436    async fn body_string(response: axum::http::Response<Body>) -> String {
437        let body = response.into_body();
438        let bytes = body.collect().await.unwrap().to_bytes();
439        String::from_utf8(bytes.to_vec()).unwrap()
440    }
441
442    async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
443        let body = response.into_body();
444        body.collect().await.unwrap().to_bytes().to_vec()
445    }
446
447    #[tokio::test]
448    async fn cache_info_endpoint() {
449        let dir = tempfile::tempdir().unwrap();
450        let app = test_app(dir.path());
451
452        let req = axum::http::Request::builder()
453            .uri("/nix-cache-info")
454            .body(Body::empty())
455            .unwrap();
456
457        let resp = app.oneshot(req).await.unwrap();
458        assert_eq!(resp.status(), StatusCode::OK);
459
460        let body = body_string(resp).await;
461        assert!(body.contains("StoreDir: /nix/store"));
462        assert!(body.contains("WantMassQuery: 1"));
463        assert!(body.contains("Priority: 40"));
464    }
465
466    #[tokio::test]
467    async fn get_narinfo_not_found() {
468        let dir = tempfile::tempdir().unwrap();
469        let app = test_app(dir.path());
470
471        let req = axum::http::Request::builder()
472            .uri("/abc.narinfo")
473            .body(Body::empty())
474            .unwrap();
475
476        let resp = app.oneshot(req).await.unwrap();
477        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
478    }
479
480    #[tokio::test]
481    async fn put_then_get_narinfo() {
482        let dir = tempfile::tempdir().unwrap();
483        let app = test_app(dir.path());
484
485        let narinfo = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nFileHash: sha256:aaa\nFileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
486
487        // PUT narinfo.
488        let req = axum::http::Request::builder()
489            .method("PUT")
490            .uri("/abc.narinfo")
491            .body(Body::from(narinfo.to_string()))
492            .unwrap();
493
494        let resp = app.clone().oneshot(req).await.unwrap();
495        assert_eq!(resp.status(), StatusCode::OK);
496
497        // GET narinfo.
498        let req = axum::http::Request::builder()
499            .uri("/abc.narinfo")
500            .body(Body::empty())
501            .unwrap();
502
503        let resp = app.oneshot(req).await.unwrap();
504        assert_eq!(resp.status(), StatusCode::OK);
505
506        let body = body_string(resp).await;
507        assert!(body.contains("StorePath: /nix/store/abc-hello"));
508    }
509
510    #[tokio::test]
511    async fn get_nar_not_found() {
512        let dir = tempfile::tempdir().unwrap();
513        let app = test_app(dir.path());
514
515        let req = axum::http::Request::builder()
516            .uri("/nar/abc.nar.xz")
517            .body(Body::empty())
518            .unwrap();
519
520        let resp = app.oneshot(req).await.unwrap();
521        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
522    }
523
524    #[tokio::test]
525    async fn put_then_get_nar() {
526        let dir = tempfile::tempdir().unwrap();
527        let app = test_app(dir.path());
528
529        let nar_data = b"fake nar blob data";
530
531        // PUT NAR.
532        let req = axum::http::Request::builder()
533            .method("PUT")
534            .uri("/nar/xyz.nar.xz")
535            .body(Body::from(nar_data.to_vec()))
536            .unwrap();
537
538        let resp = app.clone().oneshot(req).await.unwrap();
539        assert_eq!(resp.status(), StatusCode::OK);
540
541        // GET NAR.
542        let req = axum::http::Request::builder()
543            .uri("/nar/xyz.nar.xz")
544            .body(Body::empty())
545            .unwrap();
546
547        let resp = app.oneshot(req).await.unwrap();
548        assert_eq!(resp.status(), StatusCode::OK);
549
550        let body = body_bytes(resp).await;
551        assert_eq!(body, nar_data);
552    }
553
554    /// A zero-length stored narinfo must read as a MISS, not as a 200.
555    ///
556    /// This is the exact camelot-eks poison: the durable tier held two
557    /// zero-length values and served them as `200` with an empty body, and nix
558    /// aborted every `nix copy --to` with "corrupt: StorePath missing". The
559    /// entry is written straight through the storage backend, bypassing
560    /// `put_narinfo`, precisely because the ingest guard now refuses it —
561    /// entries predating that guard still exist and must not be fatal.
562    #[tokio::test]
563    async fn get_narinfo_serves_a_poisoned_entry_as_a_miss() {
564        let dir = tempfile::tempdir().unwrap();
565        let storage = LocalStorage::new(dir.path());
566        storage.put_narinfo("poison", "").await.unwrap();
567
568        let app = test_app(dir.path());
569        let req = axum::http::Request::builder()
570            .uri("/poison.narinfo")
571            .body(Body::empty())
572            .unwrap();
573
574        let resp = app.oneshot(req).await.unwrap();
575        assert_eq!(
576            resp.status(),
577            StatusCode::NOT_FOUND,
578            "an unusable hit must degrade to a miss; a 200 with an empty body makes the client \
579             ABORT rather than build, which is strictly worse than not having the entry"
580        );
581    }
582
583    /// The ingest half: a narinfo with no StorePath is refused at the door.
584    #[tokio::test]
585    async fn put_narinfo_refuses_a_body_with_no_store_path() {
586        let dir = tempfile::tempdir().unwrap();
587        let app = test_app(dir.path());
588
589        let req = axum::http::Request::builder()
590            .method("PUT")
591            .uri("/empty.narinfo")
592            .body(Body::from(""))
593            .unwrap();
594
595        let resp = app.oneshot(req).await.unwrap();
596        assert_eq!(
597            resp.status(),
598            StatusCode::BAD_REQUEST,
599            "an empty body cannot become a servable narinfo, so it is the client's error"
600        );
601    }
602
603    /// Control: a well-formed narinfo still round-trips, so the two guards
604    /// above cannot be passing by rejecting everything.
605    #[tokio::test]
606    async fn put_then_get_a_well_formed_narinfo_still_works() {
607        let dir = tempfile::tempdir().unwrap();
608        let app = test_app(dir.path());
609        let good = "StorePath: /nix/store/ok-pkg\nURL: nar/ok.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
610
611        let put = axum::http::Request::builder()
612            .method("PUT")
613            .uri("/ok.narinfo")
614            .body(Body::from(good))
615            .unwrap();
616        let resp = app.clone().oneshot(put).await.unwrap();
617        assert!(resp.status().is_success(), "a valid narinfo must still be accepted");
618
619        let get = axum::http::Request::builder()
620            .uri("/ok.narinfo")
621            .body(Body::empty())
622            .unwrap();
623        let resp = app.oneshot(get).await.unwrap();
624        assert_eq!(resp.status(), StatusCode::OK, "and must still be served");
625    }
626
627    #[tokio::test]
628    async fn get_narinfo_content_type() {
629        let dir = tempfile::tempdir().unwrap();
630        let storage = LocalStorage::new(dir.path());
631        storage
632            .put_narinfo("ct", "StorePath: /nix/store/ct-pkg\nURL: nar/ct.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n")
633            .await
634            .unwrap();
635
636        let app = test_app(dir.path());
637        let req = axum::http::Request::builder()
638            .uri("/ct.narinfo")
639            .body(Body::empty())
640            .unwrap();
641
642        let resp = app.oneshot(req).await.unwrap();
643        assert_eq!(resp.status(), StatusCode::OK);
644        assert_eq!(
645            resp.headers().get("content-type").unwrap(),
646            "text/x-nix-narinfo"
647        );
648    }
649
650    #[tokio::test]
651    async fn get_nar_xz_content_type() {
652        let dir = tempfile::tempdir().unwrap();
653        let storage = LocalStorage::new(dir.path());
654        storage
655            .put_nar("nar/test.nar.xz", b"data")
656            .await
657            .unwrap();
658
659        let app = test_app(dir.path());
660        let req = axum::http::Request::builder()
661            .uri("/nar/test.nar.xz")
662            .body(Body::empty())
663            .unwrap();
664
665        let resp = app.oneshot(req).await.unwrap();
666        assert_eq!(resp.status(), StatusCode::OK);
667        assert_eq!(
668            resp.headers().get("content-type").unwrap(),
669            "application/x-xz"
670        );
671    }
672
673    #[tokio::test]
674    async fn cache_info_custom_priority() {
675        let dir = tempfile::tempdir().unwrap();
676        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
677        let config = CacheConfig {
678            priority: 10,
679            want_mass_query: false,
680            ..CacheConfig::default()
681        };
682        let app = build_router(AppState {
683            storage,
684            config,
685            signer: None,
686        });
687
688        let req = axum::http::Request::builder()
689            .uri("/nix-cache-info")
690            .body(Body::empty())
691            .unwrap();
692
693        let resp = app.oneshot(req).await.unwrap();
694        let body = body_string(resp).await;
695        assert!(body.contains("Priority: 10"));
696        assert!(body.contains("WantMassQuery: 0"));
697    }
698
699    /// Sign-on-ingest proof: with a signer configured, a `PUT`-then-`GET`
700    /// narinfo comes back carrying a `Sig:` that verifies against the
701    /// signer's public key. This exercises the exact serve-path wiring
702    /// (`put_narinfo` → `sign_narinfo_text`), not just the library.
703    #[tokio::test]
704    async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
705        use crate::signing::{verify_narinfo_signature, CacheSigner};
706
707        let dir = tempfile::tempdir().unwrap();
708        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
709        let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
710        let pk = signer.public_key_string();
711        let config = CacheConfig {
712            listen: "127.0.0.1:0".to_string(),
713            backend: BackendConfig::Local { path: dir.path().to_path_buf() },
714            priority: 40,
715            want_mass_query: true,
716            store_dir: "/nix/store".to_string(),
717            signing_key: None,
718            require_sigs: false,
719        };
720        let app = build_router(AppState { storage, config, signer: Some(signer.clone()) });
721
722        // Unsigned narinfo (references deliberately unsorted).
723        let narinfo = "StorePath: /nix/store/abc-hello\n\
724                       URL: nar/abc.nar.xz\n\
725                       Compression: xz\n\
726                       FileHash: sha256:aaa\n\
727                       FileSize: 100\n\
728                       NarHash: sha256:bbb\n\
729                       NarSize: 200\n\
730                       References: zzz-b aaa-a\n";
731
732        let req = axum::http::Request::builder()
733            .method("PUT")
734            .uri("/abc.narinfo")
735            .body(Body::from(narinfo))
736            .unwrap();
737        let resp = app.clone().oneshot(req).await.unwrap();
738        assert_eq!(resp.status(), StatusCode::OK);
739
740        let req = axum::http::Request::builder()
741            .uri("/abc.narinfo")
742            .body(Body::empty())
743            .unwrap();
744        let resp = app.oneshot(req).await.unwrap();
745        assert_eq!(resp.status(), StatusCode::OK);
746        let body = body_string(resp).await;
747
748        let parsed = NarInfo::parse(&body).unwrap();
749        assert_eq!(parsed.signatures.len(), 1, "GET must return a signed narinfo");
750        assert!(parsed.signatures[0].starts_with("ingest-key:"));
751        assert!(
752            verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
753            "the ingest signature must verify against the signer public key",
754        );
755    }
756
757    /// Re-`PUT` of an already-signed narinfo does not double-sign.
758    #[tokio::test]
759    async fn put_narinfo_is_idempotent_under_our_key() {
760        use crate::signing::CacheSigner;
761
762        let dir = tempfile::tempdir().unwrap();
763        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
764        let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
765        let config = CacheConfig {
766            listen: "127.0.0.1:0".to_string(),
767            backend: BackendConfig::Local { path: dir.path().to_path_buf() },
768            priority: 40,
769            want_mass_query: true,
770            store_dir: "/nix/store".to_string(),
771            signing_key: None,
772            require_sigs: false,
773        };
774        let app = build_router(AppState { storage, config, signer: Some(signer) });
775
776        let narinfo = "StorePath: /nix/store/def-x\n\
777                       URL: nar/def.nar.xz\n\
778                       Compression: xz\n\
779                       FileHash: sha256:a\n\
780                       FileSize: 1\n\
781                       NarHash: sha256:b\n\
782                       NarSize: 2\n\
783                       References: \n";
784
785        // First PUT (signs), GET the signed text, PUT it back.
786        for uri in ["/def.narinfo"] {
787            let req = axum::http::Request::builder()
788                .method("PUT").uri(uri).body(Body::from(narinfo)).unwrap();
789            assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
790        }
791        let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
792        let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
793
794        let req = axum::http::Request::builder()
795            .method("PUT").uri("/def.narinfo").body(Body::from(signed.clone())).unwrap();
796        assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
797
798        let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
799        let final_text = body_string(app.oneshot(req).await.unwrap()).await;
800        let parsed = NarInfo::parse(&final_text).unwrap();
801        assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
802    }
803
804    // ── a broken backend degrades to a MISS, never a 500 (the incident) ────
805
806    /// A backend that is reachable but cannot answer — the exact shape of the
807    /// Postgres L2 whose tables were destroyed with its `emptyDir`.
808    #[derive(Default)]
809    struct BrokenStorage {
810        /// Unreachable in practice — every verb above it errors first — but the
811        /// trait requires a decision, and "an empty index" is the honest one for
812        /// a backend that stores nothing.
813        nar_refs: crate::MemNarRefIndex,
814    }
815
816    #[async_trait::async_trait]
817    impl StorageBackend for BrokenStorage {
818        async fn get_narinfo(&self, _hash: &str) -> Result<Option<String>, crate::CacheError> {
819            Err(crate::CacheError::Io(std::io::Error::other(
820                "postgres: error returned from database: relation \"sui_cache_narinfo\" does not exist",
821            )))
822        }
823        async fn put_narinfo_record(
824            &self,
825            _hash: &str,
826            _content: &str,
827        ) -> Result<(), crate::CacheError> {
828            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
829        }
830        async fn delete_narinfo_record(&self, _hash: &str) -> Result<(), crate::CacheError> {
831            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
832        }
833        async fn delete_nar_record(&self, _nar_path: &str) -> Result<(), crate::CacheError> {
834            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
835        }
836        fn nar_ref_index(&self) -> &dyn crate::NarRefIndex {
837            &self.nar_refs
838        }
839        async fn get_nar(&self, _path: &str) -> Result<Option<Vec<u8>>, crate::CacheError> {
840            Err(crate::CacheError::Io(std::io::Error::other(
841                "postgres: error returned from database: relation \"sui_cache_nar\" does not exist",
842            )))
843        }
844        async fn put_nar(&self, _path: &str, _data: &[u8]) -> Result<(), crate::CacheError> {
845            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
846        }
847        /// An in-memory test double holds whole values by construction. The
848        /// declaration is required precisely so a *production* backend cannot
849        /// inherit this path by omission.
850        fn nar_residency(&self) -> crate::NarResidency {
851            crate::NarResidency::WholeValue
852        }
853
854        async fn list_narinfos(&self) -> Result<Vec<String>, crate::CacheError> {
855            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
856        }
857    }
858
859    fn broken_app() -> Router {
860        let storage: Arc<dyn StorageBackend> = Arc::new(BrokenStorage::default());
861        build_router(AppState {
862            storage,
863            config: CacheConfig::default(),
864            signer: None,
865        })
866    }
867
868    #[tokio::test]
869    async fn broken_backend_narinfo_read_is_a_miss_not_a_server_error() {
870        // THE defect. nix treats 404 as a cache miss and builds; it treats 500
871        // as fatal, retries 5x, and aborts the build ~35s in before compiling
872        // anything. An optional accelerator must never be able to do that.
873        let resp = broken_app()
874            .oneshot(
875                axum::http::Request::builder()
876                    .uri("/abc.narinfo")
877                    .body(Body::empty())
878                    .unwrap(),
879            )
880            .await
881            .unwrap();
882        assert_eq!(
883            resp.status(),
884            StatusCode::NOT_FOUND,
885            "a backend that cannot answer must report a MISS, never a 500",
886        );
887        assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
888    }
889
890    #[tokio::test]
891    async fn broken_backend_nar_read_is_a_miss_not_a_server_error() {
892        let resp = broken_app()
893            .oneshot(
894                axum::http::Request::builder()
895                    .uri("/nar/abc.nar.xz")
896                    .body(Body::empty())
897                    .unwrap(),
898            )
899            .await
900            .unwrap();
901        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
902    }
903
904    #[tokio::test]
905    async fn cache_info_still_answers_while_the_backend_is_broken() {
906        // The cache must still advertise itself, so nix's substituter probe
907        // succeeds and the miss path is exercised normally.
908        let resp = broken_app()
909            .oneshot(
910                axum::http::Request::builder()
911                    .uri("/nix-cache-info")
912                    .body(Body::empty())
913                    .unwrap(),
914            )
915            .await
916            .unwrap();
917        assert_eq!(resp.status(), StatusCode::OK);
918    }
919
920    #[tokio::test]
921    async fn a_totally_failed_write_still_reports_failure() {
922        // The deliberate asymmetry: there is no "I did not store it" success
923        // answer in the protocol, so acknowledging a write that landed nowhere
924        // would silently stop the cache from ever filling. Writes stay honest.
925        let narinfo = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
926        let resp = broken_app()
927            .oneshot(
928                axum::http::Request::builder()
929                    .method("PUT")
930                    .uri("/abc.narinfo")
931                    .body(Body::from(narinfo))
932                    .unwrap(),
933            )
934            .await
935            .unwrap();
936        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
937    }
938
939    /// A `URL:` that escapes the cache root is a **client** error (400), not a
940    /// server fault (500), and nothing is stored.
941    ///
942    /// The `URL:` becomes a storage key and, on the local tier, a path joined
943    /// onto the cache root — the one narinfo field that is used as a filesystem
944    /// path, so it is validated at the request boundary.
945    #[tokio::test]
946    async fn put_narinfo_with_a_traversal_url_is_rejected() {
947        let dir = tempfile::tempdir().unwrap();
948        let app = test_app(dir.path());
949        let evil = "StorePath: /nix/store/abc-hello\nURL: ../../escape.nar\nCompression: xz\n\
950                    FileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
951
952        let req = axum::http::Request::builder()
953            .method("PUT")
954            .uri("/abc.narinfo")
955            .body(Body::from(evil))
956            .unwrap();
957        let resp = app.clone().oneshot(req).await.unwrap();
958        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
959
960        let get = axum::http::Request::builder()
961            .uri("/abc.narinfo")
962            .body(Body::empty())
963            .unwrap();
964        assert_eq!(
965            app.oneshot(get).await.unwrap().status(),
966            StatusCode::NOT_FOUND,
967            "a rejected narinfo must not have been stored",
968        );
969    }
970
971    #[tokio::test]
972    async fn put_narinfo_bad_utf8() {
973        let dir = tempfile::tempdir().unwrap();
974        let app = test_app(dir.path());
975
976        let req = axum::http::Request::builder()
977            .method("PUT")
978            .uri("/bad.narinfo")
979            .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
980            .unwrap();
981
982        let resp = app.oneshot(req).await.unwrap();
983        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
984    }
985}