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