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        Ok(Some(content)) => (
161            StatusCode::OK,
162            [("content-type", "text/x-nix-narinfo")],
163            content,
164        )
165            .into_response(),
166        Ok(None) => StatusCode::NOT_FOUND.into_response(),
167        // A cache read is DEFINITIONALLY optional: if the storage cannot answer,
168        // the honest answer to the client is "I don't have it" (404), never
169        // "something is broken" (500). Nix treats a 404 as a cache miss and
170        // builds; it treats a 500 as fatal, retries, and aborts the build. So a
171        // 500 here converts a cold accelerator into a hard dependency and takes
172        // down every consuming pipeline — which is exactly what it did.
173        //
174        // Loud at ERROR so the degradation is never silent: the request
175        // survives, the fault stays visible.
176        Err(e) => {
177            tracing::error!(
178                hash = %hash,
179                error = %e,
180                "get_narinfo: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
181                 client rebuilds instead of aborting; the backend needs attention",
182            );
183            StatusCode::NOT_FOUND.into_response()
184        }
185    }
186}
187
188/// `PUT /{hash}.narinfo` — uploads narinfo text.
189async fn put_narinfo(
190    State(state): State<AppState>,
191    Path(hash_narinfo): Path<String>,
192    body: Bytes,
193) -> impl IntoResponse {
194    let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
195        return StatusCode::BAD_REQUEST.into_response();
196    };
197
198    let content = match String::from_utf8(body.to_vec()) {
199        Ok(s) => s,
200        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
201    };
202
203    // A narinfo's `URL:` becomes a storage key, and on the local tier a path
204    // joined onto the cache root — so `URL: ../../etc/passwd` has to be refused
205    // rather than sanitized at each of those uses. The backend refuses it too,
206    // but a refusal there arrives as a 500 (a server fault); a malformed upload
207    // is the client's, so it is classified here.
208    if let Some(url) = crate::advertised_url_line(&content) {
209        if !crate::is_addressable_nar_path(url) {
210            tracing::warn!(
211                hash = %hash, url = %url,
212                "put_narinfo: refusing a narinfo whose URL is not an addressable relative path",
213            );
214            return StatusCode::BAD_REQUEST.into_response();
215        }
216    }
217
218    // Sign at ingest when a signer is configured, so the durable tier stores
219    // the signed narinfo and every serving tier inherits the `Sig:`.
220    let to_store = match &state.signer {
221        Some(signer) => match sign_narinfo_text(signer, &content) {
222            Ok(signed) => signed,
223            Err(e) => {
224                tracing::error!("put_narinfo signing error: {e}");
225                return StatusCode::BAD_REQUEST.into_response();
226            }
227        },
228        None => content,
229    };
230
231    // WRITE-PATH POLICY — deliberately NOT symmetric with the read path.
232    //
233    // A read has a well-defined "I don't have it" answer in the Nix binary-cache
234    // protocol (404), and the client's correct response to it is to build. A
235    // write has NO "I did not store it" success answer: returning 200 on a
236    // failed write tells the client the path is cached when it is not, so the
237    // push pipeline silently does nothing forever and no operator ever learns
238    // the cache stopped filling. That is the silent-degradation bug this whole
239    // change is against, just pointed the other way.
240    //
241    // So a failed write stays a 5xx — but the failure it reports is now much
242    // rarer and much more honest: `TieredBackend` attempts EVERY durable tier
243    // and succeeds if any one accepted the write, so this fires only when
244    // nothing was stored anywhere. One broken durable tier (the Postgres-OOM
245    // case) no longer fails the push.
246    match state.storage.put_narinfo(hash, &to_store).await {
247        Ok(()) => StatusCode::OK.into_response(),
248        Err(e) => {
249            tracing::error!(
250                hash = %hash,
251                error = %e,
252                "put_narinfo: EVERY durable tier rejected the write — nothing stored; \
253                 reporting failure rather than falsely acknowledging the upload",
254            );
255            StatusCode::INTERNAL_SERVER_ERROR.into_response()
256        }
257    }
258}
259
260/// The NAR media type implied by a URL suffix.
261fn nar_content_type(path: &str) -> &'static str {
262    if path.ends_with(".xz") {
263        "application/x-xz"
264    } else if path.ends_with(".zstd") || path.ends_with(".zst") {
265        "application/zstd"
266    } else {
267        "application/x-nix-nar"
268    }
269}
270
271/// `GET /nar/{path}` — **streams** a compressed NAR blob.
272///
273/// The body is wired straight from the backend's chunk stream to the socket, so
274/// serving a 2 GiB NAR costs this process one chunk, not 2 GiB. It used to
275/// collect the blob into a `Vec<u8>` and hand axum the whole thing.
276///
277/// The cost of streaming: the status line is committed before the bytes are
278/// known-good, so a fault *mid-body* can no longer become a 404. It shows up as
279/// a truncated response, which Nix rejects on the NarHash it already has from
280/// the narinfo — a loud client-side failure, not a silent bad substitution. A
281/// fault *before* the first byte still degrades to a miss exactly as before.
282async fn get_nar(
283    State(state): State<AppState>,
284    Path(path): Path<String>,
285) -> impl IntoResponse {
286    let nar_path = format!("nar/{path}");
287    match state.storage.get_nar_stream(&nar_path).await {
288        Ok(Some(stream)) => {
289            let mut headers = HeaderMap::new();
290            headers.insert("content-type", nar_content_type(&path).parse().unwrap());
291            (StatusCode::OK, headers, Body::from_stream(stream)).into_response()
292        }
293        Ok(None) => StatusCode::NOT_FOUND.into_response(),
294        // Same rule as `get_narinfo`: an unanswerable read is a miss, not a
295        // server error. See that handler for why 500 here is load-bearing-fatal.
296        Err(e) => {
297            tracing::error!(
298                nar_path = %nar_path,
299                error = %e,
300                "get_nar: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
301                 client rebuilds instead of aborting; the backend needs attention",
302            );
303            StatusCode::NOT_FOUND.into_response()
304        }
305    }
306}
307
308/// `PUT /nar/{path}` — **streams** a compressed NAR blob into storage.
309///
310/// The request body is spooled in bounded chunks (see
311/// [`spool_or_buffer`](sui_castore::spool_or_buffer)) and handed to the backend
312/// as a re-openable source. It used to arrive as `body: Bytes` — axum collecting
313/// every frame and concatenating them — which put the whole NAR in the heap
314/// *before* storage even saw it, on top of whatever each tier then copied.
315///
316/// The spool directory is `TMPDIR` (via `std::env::temp_dir()`), so an operator
317/// points it at a real volume without a code change. If no spool file can be
318/// created the ingest falls back to a **capped** in-memory buffer and NARs above
319/// the cap are refused — bounded either way, never unbounded.
320async fn put_nar(
321    State(state): State<AppState>,
322    Path(path): Path<String>,
323    body: Body,
324) -> impl IntoResponse {
325    let nar_path = format!("nar/{path}");
326
327    let src = match sui_castore::spool_or_buffer(
328        body.into_data_stream(),
329        &std::env::temp_dir(),
330        sui_castore::DEFAULT_INGEST_MEMORY_CAP,
331    )
332    .await
333    {
334        Ok(src) => src,
335        Err(e) => {
336            tracing::error!(
337                nar_path = %nar_path,
338                error = %e,
339                "put_nar: could not stage the upload (spool write failed, or it exceeded \
340                 the in-memory fallback cap) — nothing stored",
341            );
342            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
343        }
344    };
345
346    // See `put_narinfo` for the write-path policy and why it is deliberately
347    // asymmetric with the read path.
348    match state.storage.put_nar_stream(&nar_path, src.as_ref()).await {
349        Ok(()) => StatusCode::OK.into_response(),
350        Err(e) => {
351            tracing::error!(
352                nar_path = %nar_path,
353                error = %e,
354                "put_nar: EVERY durable tier rejected the write — nothing stored; \
355                 reporting failure rather than falsely acknowledging the upload",
356            );
357            StatusCode::INTERNAL_SERVER_ERROR.into_response()
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::config::BackendConfig;
366    use crate::LocalStorage;
367    use axum::body::Body;
368    use http_body_util::BodyExt;
369    use tower::ServiceExt;
370
371    fn test_app(dir: &std::path::Path) -> Router {
372        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
373        let config = CacheConfig {
374            listen: "127.0.0.1:0".to_string(),
375            backend: BackendConfig::Local {
376                path: dir.to_path_buf(),
377            },
378            priority: 40,
379            want_mass_query: true,
380            store_dir: "/nix/store".to_string(),
381            signing_key: None,
382            require_sigs: false,
383        };
384        build_router(AppState { storage, config, signer: None })
385    }
386
387    async fn body_string(response: axum::http::Response<Body>) -> String {
388        let body = response.into_body();
389        let bytes = body.collect().await.unwrap().to_bytes();
390        String::from_utf8(bytes.to_vec()).unwrap()
391    }
392
393    async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
394        let body = response.into_body();
395        body.collect().await.unwrap().to_bytes().to_vec()
396    }
397
398    #[tokio::test]
399    async fn cache_info_endpoint() {
400        let dir = tempfile::tempdir().unwrap();
401        let app = test_app(dir.path());
402
403        let req = axum::http::Request::builder()
404            .uri("/nix-cache-info")
405            .body(Body::empty())
406            .unwrap();
407
408        let resp = app.oneshot(req).await.unwrap();
409        assert_eq!(resp.status(), StatusCode::OK);
410
411        let body = body_string(resp).await;
412        assert!(body.contains("StoreDir: /nix/store"));
413        assert!(body.contains("WantMassQuery: 1"));
414        assert!(body.contains("Priority: 40"));
415    }
416
417    #[tokio::test]
418    async fn get_narinfo_not_found() {
419        let dir = tempfile::tempdir().unwrap();
420        let app = test_app(dir.path());
421
422        let req = axum::http::Request::builder()
423            .uri("/abc.narinfo")
424            .body(Body::empty())
425            .unwrap();
426
427        let resp = app.oneshot(req).await.unwrap();
428        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
429    }
430
431    #[tokio::test]
432    async fn put_then_get_narinfo() {
433        let dir = tempfile::tempdir().unwrap();
434        let app = test_app(dir.path());
435
436        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";
437
438        // PUT narinfo.
439        let req = axum::http::Request::builder()
440            .method("PUT")
441            .uri("/abc.narinfo")
442            .body(Body::from(narinfo.to_string()))
443            .unwrap();
444
445        let resp = app.clone().oneshot(req).await.unwrap();
446        assert_eq!(resp.status(), StatusCode::OK);
447
448        // GET narinfo.
449        let req = axum::http::Request::builder()
450            .uri("/abc.narinfo")
451            .body(Body::empty())
452            .unwrap();
453
454        let resp = app.oneshot(req).await.unwrap();
455        assert_eq!(resp.status(), StatusCode::OK);
456
457        let body = body_string(resp).await;
458        assert!(body.contains("StorePath: /nix/store/abc-hello"));
459    }
460
461    #[tokio::test]
462    async fn get_nar_not_found() {
463        let dir = tempfile::tempdir().unwrap();
464        let app = test_app(dir.path());
465
466        let req = axum::http::Request::builder()
467            .uri("/nar/abc.nar.xz")
468            .body(Body::empty())
469            .unwrap();
470
471        let resp = app.oneshot(req).await.unwrap();
472        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
473    }
474
475    #[tokio::test]
476    async fn put_then_get_nar() {
477        let dir = tempfile::tempdir().unwrap();
478        let app = test_app(dir.path());
479
480        let nar_data = b"fake nar blob data";
481
482        // PUT NAR.
483        let req = axum::http::Request::builder()
484            .method("PUT")
485            .uri("/nar/xyz.nar.xz")
486            .body(Body::from(nar_data.to_vec()))
487            .unwrap();
488
489        let resp = app.clone().oneshot(req).await.unwrap();
490        assert_eq!(resp.status(), StatusCode::OK);
491
492        // GET NAR.
493        let req = axum::http::Request::builder()
494            .uri("/nar/xyz.nar.xz")
495            .body(Body::empty())
496            .unwrap();
497
498        let resp = app.oneshot(req).await.unwrap();
499        assert_eq!(resp.status(), StatusCode::OK);
500
501        let body = body_bytes(resp).await;
502        assert_eq!(body, nar_data);
503    }
504
505    #[tokio::test]
506    async fn get_narinfo_content_type() {
507        let dir = tempfile::tempdir().unwrap();
508        let storage = LocalStorage::new(dir.path());
509        storage
510            .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")
511            .await
512            .unwrap();
513
514        let app = test_app(dir.path());
515        let req = axum::http::Request::builder()
516            .uri("/ct.narinfo")
517            .body(Body::empty())
518            .unwrap();
519
520        let resp = app.oneshot(req).await.unwrap();
521        assert_eq!(resp.status(), StatusCode::OK);
522        assert_eq!(
523            resp.headers().get("content-type").unwrap(),
524            "text/x-nix-narinfo"
525        );
526    }
527
528    #[tokio::test]
529    async fn get_nar_xz_content_type() {
530        let dir = tempfile::tempdir().unwrap();
531        let storage = LocalStorage::new(dir.path());
532        storage
533            .put_nar("nar/test.nar.xz", b"data")
534            .await
535            .unwrap();
536
537        let app = test_app(dir.path());
538        let req = axum::http::Request::builder()
539            .uri("/nar/test.nar.xz")
540            .body(Body::empty())
541            .unwrap();
542
543        let resp = app.oneshot(req).await.unwrap();
544        assert_eq!(resp.status(), StatusCode::OK);
545        assert_eq!(
546            resp.headers().get("content-type").unwrap(),
547            "application/x-xz"
548        );
549    }
550
551    #[tokio::test]
552    async fn cache_info_custom_priority() {
553        let dir = tempfile::tempdir().unwrap();
554        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
555        let config = CacheConfig {
556            priority: 10,
557            want_mass_query: false,
558            ..CacheConfig::default()
559        };
560        let app = build_router(AppState {
561            storage,
562            config,
563            signer: None,
564        });
565
566        let req = axum::http::Request::builder()
567            .uri("/nix-cache-info")
568            .body(Body::empty())
569            .unwrap();
570
571        let resp = app.oneshot(req).await.unwrap();
572        let body = body_string(resp).await;
573        assert!(body.contains("Priority: 10"));
574        assert!(body.contains("WantMassQuery: 0"));
575    }
576
577    /// Sign-on-ingest proof: with a signer configured, a `PUT`-then-`GET`
578    /// narinfo comes back carrying a `Sig:` that verifies against the
579    /// signer's public key. This exercises the exact serve-path wiring
580    /// (`put_narinfo` → `sign_narinfo_text`), not just the library.
581    #[tokio::test]
582    async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
583        use crate::signing::{verify_narinfo_signature, CacheSigner};
584
585        let dir = tempfile::tempdir().unwrap();
586        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
587        let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
588        let pk = signer.public_key_string();
589        let config = CacheConfig {
590            listen: "127.0.0.1:0".to_string(),
591            backend: BackendConfig::Local { path: dir.path().to_path_buf() },
592            priority: 40,
593            want_mass_query: true,
594            store_dir: "/nix/store".to_string(),
595            signing_key: None,
596            require_sigs: false,
597        };
598        let app = build_router(AppState { storage, config, signer: Some(signer.clone()) });
599
600        // Unsigned narinfo (references deliberately unsorted).
601        let narinfo = "StorePath: /nix/store/abc-hello\n\
602                       URL: nar/abc.nar.xz\n\
603                       Compression: xz\n\
604                       FileHash: sha256:aaa\n\
605                       FileSize: 100\n\
606                       NarHash: sha256:bbb\n\
607                       NarSize: 200\n\
608                       References: zzz-b aaa-a\n";
609
610        let req = axum::http::Request::builder()
611            .method("PUT")
612            .uri("/abc.narinfo")
613            .body(Body::from(narinfo))
614            .unwrap();
615        let resp = app.clone().oneshot(req).await.unwrap();
616        assert_eq!(resp.status(), StatusCode::OK);
617
618        let req = axum::http::Request::builder()
619            .uri("/abc.narinfo")
620            .body(Body::empty())
621            .unwrap();
622        let resp = app.oneshot(req).await.unwrap();
623        assert_eq!(resp.status(), StatusCode::OK);
624        let body = body_string(resp).await;
625
626        let parsed = NarInfo::parse(&body).unwrap();
627        assert_eq!(parsed.signatures.len(), 1, "GET must return a signed narinfo");
628        assert!(parsed.signatures[0].starts_with("ingest-key:"));
629        assert!(
630            verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
631            "the ingest signature must verify against the signer public key",
632        );
633    }
634
635    /// Re-`PUT` of an already-signed narinfo does not double-sign.
636    #[tokio::test]
637    async fn put_narinfo_is_idempotent_under_our_key() {
638        use crate::signing::CacheSigner;
639
640        let dir = tempfile::tempdir().unwrap();
641        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
642        let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
643        let config = CacheConfig {
644            listen: "127.0.0.1:0".to_string(),
645            backend: BackendConfig::Local { path: dir.path().to_path_buf() },
646            priority: 40,
647            want_mass_query: true,
648            store_dir: "/nix/store".to_string(),
649            signing_key: None,
650            require_sigs: false,
651        };
652        let app = build_router(AppState { storage, config, signer: Some(signer) });
653
654        let narinfo = "StorePath: /nix/store/def-x\n\
655                       URL: nar/def.nar.xz\n\
656                       Compression: xz\n\
657                       FileHash: sha256:a\n\
658                       FileSize: 1\n\
659                       NarHash: sha256:b\n\
660                       NarSize: 2\n\
661                       References: \n";
662
663        // First PUT (signs), GET the signed text, PUT it back.
664        for uri in ["/def.narinfo"] {
665            let req = axum::http::Request::builder()
666                .method("PUT").uri(uri).body(Body::from(narinfo)).unwrap();
667            assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
668        }
669        let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
670        let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
671
672        let req = axum::http::Request::builder()
673            .method("PUT").uri("/def.narinfo").body(Body::from(signed.clone())).unwrap();
674        assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
675
676        let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
677        let final_text = body_string(app.oneshot(req).await.unwrap()).await;
678        let parsed = NarInfo::parse(&final_text).unwrap();
679        assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
680    }
681
682    // ── a broken backend degrades to a MISS, never a 500 (the incident) ────
683
684    /// A backend that is reachable but cannot answer — the exact shape of the
685    /// Postgres L2 whose tables were destroyed with its `emptyDir`.
686    #[derive(Default)]
687    struct BrokenStorage {
688        /// Unreachable in practice — every verb above it errors first — but the
689        /// trait requires a decision, and "an empty index" is the honest one for
690        /// a backend that stores nothing.
691        nar_refs: crate::MemNarRefIndex,
692    }
693
694    #[async_trait::async_trait]
695    impl StorageBackend for BrokenStorage {
696        async fn get_narinfo(&self, _hash: &str) -> Result<Option<String>, crate::CacheError> {
697            Err(crate::CacheError::Io(std::io::Error::other(
698                "postgres: error returned from database: relation \"sui_cache_narinfo\" does not exist",
699            )))
700        }
701        async fn put_narinfo_record(
702            &self,
703            _hash: &str,
704            _content: &str,
705        ) -> Result<(), crate::CacheError> {
706            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
707        }
708        async fn delete_narinfo_record(&self, _hash: &str) -> Result<(), crate::CacheError> {
709            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
710        }
711        async fn delete_nar_record(&self, _nar_path: &str) -> Result<(), crate::CacheError> {
712            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
713        }
714        fn nar_ref_index(&self) -> &dyn crate::NarRefIndex {
715            &self.nar_refs
716        }
717        async fn get_nar(&self, _path: &str) -> Result<Option<Vec<u8>>, crate::CacheError> {
718            Err(crate::CacheError::Io(std::io::Error::other(
719                "postgres: error returned from database: relation \"sui_cache_nar\" does not exist",
720            )))
721        }
722        async fn put_nar(&self, _path: &str, _data: &[u8]) -> Result<(), crate::CacheError> {
723            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
724        }
725        /// An in-memory test double holds whole values by construction. The
726        /// declaration is required precisely so a *production* backend cannot
727        /// inherit this path by omission.
728        fn nar_residency(&self) -> crate::NarResidency {
729            crate::NarResidency::WholeValue
730        }
731
732        async fn list_narinfos(&self) -> Result<Vec<String>, crate::CacheError> {
733            Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
734        }
735    }
736
737    fn broken_app() -> Router {
738        let storage: Arc<dyn StorageBackend> = Arc::new(BrokenStorage::default());
739        build_router(AppState {
740            storage,
741            config: CacheConfig::default(),
742            signer: None,
743        })
744    }
745
746    #[tokio::test]
747    async fn broken_backend_narinfo_read_is_a_miss_not_a_server_error() {
748        // THE defect. nix treats 404 as a cache miss and builds; it treats 500
749        // as fatal, retries 5x, and aborts the build ~35s in before compiling
750        // anything. An optional accelerator must never be able to do that.
751        let resp = broken_app()
752            .oneshot(
753                axum::http::Request::builder()
754                    .uri("/abc.narinfo")
755                    .body(Body::empty())
756                    .unwrap(),
757            )
758            .await
759            .unwrap();
760        assert_eq!(
761            resp.status(),
762            StatusCode::NOT_FOUND,
763            "a backend that cannot answer must report a MISS, never a 500",
764        );
765        assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
766    }
767
768    #[tokio::test]
769    async fn broken_backend_nar_read_is_a_miss_not_a_server_error() {
770        let resp = broken_app()
771            .oneshot(
772                axum::http::Request::builder()
773                    .uri("/nar/abc.nar.xz")
774                    .body(Body::empty())
775                    .unwrap(),
776            )
777            .await
778            .unwrap();
779        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
780    }
781
782    #[tokio::test]
783    async fn cache_info_still_answers_while_the_backend_is_broken() {
784        // The cache must still advertise itself, so nix's substituter probe
785        // succeeds and the miss path is exercised normally.
786        let resp = broken_app()
787            .oneshot(
788                axum::http::Request::builder()
789                    .uri("/nix-cache-info")
790                    .body(Body::empty())
791                    .unwrap(),
792            )
793            .await
794            .unwrap();
795        assert_eq!(resp.status(), StatusCode::OK);
796    }
797
798    #[tokio::test]
799    async fn a_totally_failed_write_still_reports_failure() {
800        // The deliberate asymmetry: there is no "I did not store it" success
801        // answer in the protocol, so acknowledging a write that landed nowhere
802        // would silently stop the cache from ever filling. Writes stay honest.
803        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";
804        let resp = broken_app()
805            .oneshot(
806                axum::http::Request::builder()
807                    .method("PUT")
808                    .uri("/abc.narinfo")
809                    .body(Body::from(narinfo))
810                    .unwrap(),
811            )
812            .await
813            .unwrap();
814        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
815    }
816
817    /// A `URL:` that escapes the cache root is a **client** error (400), not a
818    /// server fault (500), and nothing is stored.
819    ///
820    /// The `URL:` becomes a storage key and, on the local tier, a path joined
821    /// onto the cache root — the one narinfo field that is used as a filesystem
822    /// path, so it is validated at the request boundary.
823    #[tokio::test]
824    async fn put_narinfo_with_a_traversal_url_is_rejected() {
825        let dir = tempfile::tempdir().unwrap();
826        let app = test_app(dir.path());
827        let evil = "StorePath: /nix/store/abc-hello\nURL: ../../escape.nar\nCompression: xz\n\
828                    FileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
829
830        let req = axum::http::Request::builder()
831            .method("PUT")
832            .uri("/abc.narinfo")
833            .body(Body::from(evil))
834            .unwrap();
835        let resp = app.clone().oneshot(req).await.unwrap();
836        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
837
838        let get = axum::http::Request::builder()
839            .uri("/abc.narinfo")
840            .body(Body::empty())
841            .unwrap();
842        assert_eq!(
843            app.oneshot(get).await.unwrap().status(),
844            StatusCode::NOT_FOUND,
845            "a rejected narinfo must not have been stored",
846        );
847    }
848
849    #[tokio::test]
850    async fn put_narinfo_bad_utf8() {
851        let dir = tempfile::tempdir().unwrap();
852        let app = test_app(dir.path());
853
854        let req = axum::http::Request::builder()
855            .method("PUT")
856            .uri("/bad.narinfo")
857            .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
858            .unwrap();
859
860        let resp = app.oneshot(req).await.unwrap();
861        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
862    }
863}