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::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::storage::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        Err(e) => {
168            tracing::error!("get_narinfo error: {e}");
169            StatusCode::INTERNAL_SERVER_ERROR.into_response()
170        }
171    }
172}
173
174/// `PUT /{hash}.narinfo` — uploads narinfo text.
175async fn put_narinfo(
176    State(state): State<AppState>,
177    Path(hash_narinfo): Path<String>,
178    body: Bytes,
179) -> impl IntoResponse {
180    let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
181        return StatusCode::BAD_REQUEST.into_response();
182    };
183
184    let content = match String::from_utf8(body.to_vec()) {
185        Ok(s) => s,
186        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
187    };
188
189    // Sign at ingest when a signer is configured, so the durable tier stores
190    // the signed narinfo and every serving tier inherits the `Sig:`.
191    let to_store = match &state.signer {
192        Some(signer) => match sign_narinfo_text(signer, &content) {
193            Ok(signed) => signed,
194            Err(e) => {
195                tracing::error!("put_narinfo signing error: {e}");
196                return StatusCode::BAD_REQUEST.into_response();
197            }
198        },
199        None => content,
200    };
201
202    match state.storage.put_narinfo(hash, &to_store).await {
203        Ok(()) => StatusCode::OK.into_response(),
204        Err(e) => {
205            tracing::error!("put_narinfo error: {e}");
206            StatusCode::INTERNAL_SERVER_ERROR.into_response()
207        }
208    }
209}
210
211/// `GET /nar/{path}` — returns a compressed NAR blob.
212async fn get_nar(
213    State(state): State<AppState>,
214    Path(path): Path<String>,
215) -> impl IntoResponse {
216    let nar_path = format!("nar/{path}");
217    match state.storage.get_nar(&nar_path).await {
218        Ok(Some(data)) => {
219            let content_type = if path.ends_with(".xz") {
220                "application/x-xz"
221            } else if path.ends_with(".zstd") || path.ends_with(".zst") {
222                "application/zstd"
223            } else {
224                "application/x-nix-nar"
225            };
226            let mut headers = HeaderMap::new();
227            headers.insert("content-type", content_type.parse().unwrap());
228            (StatusCode::OK, headers, data).into_response()
229        }
230        Ok(None) => StatusCode::NOT_FOUND.into_response(),
231        Err(e) => {
232            tracing::error!("get_nar error: {e}");
233            StatusCode::INTERNAL_SERVER_ERROR.into_response()
234        }
235    }
236}
237
238/// `PUT /nar/{path}` — uploads a compressed NAR blob.
239async fn put_nar(
240    State(state): State<AppState>,
241    Path(path): Path<String>,
242    body: Bytes,
243) -> impl IntoResponse {
244    let nar_path = format!("nar/{path}");
245    match state.storage.put_nar(&nar_path, &body).await {
246        Ok(()) => StatusCode::OK.into_response(),
247        Err(e) => {
248            tracing::error!("put_nar error: {e}");
249            StatusCode::INTERNAL_SERVER_ERROR.into_response()
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::config::BackendConfig;
258    use crate::storage::local::LocalStorage;
259    use axum::body::Body;
260    use http_body_util::BodyExt;
261    use tower::ServiceExt;
262
263    fn test_app(dir: &std::path::Path) -> Router {
264        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
265        let config = CacheConfig {
266            listen: "127.0.0.1:0".to_string(),
267            backend: BackendConfig::Local {
268                path: dir.to_path_buf(),
269            },
270            priority: 40,
271            want_mass_query: true,
272            store_dir: "/nix/store".to_string(),
273            signing_key: None,
274            require_sigs: false,
275        };
276        build_router(AppState { storage, config, signer: None })
277    }
278
279    async fn body_string(response: axum::http::Response<Body>) -> String {
280        let body = response.into_body();
281        let bytes = body.collect().await.unwrap().to_bytes();
282        String::from_utf8(bytes.to_vec()).unwrap()
283    }
284
285    async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
286        let body = response.into_body();
287        body.collect().await.unwrap().to_bytes().to_vec()
288    }
289
290    #[tokio::test]
291    async fn cache_info_endpoint() {
292        let dir = tempfile::tempdir().unwrap();
293        let app = test_app(dir.path());
294
295        let req = axum::http::Request::builder()
296            .uri("/nix-cache-info")
297            .body(Body::empty())
298            .unwrap();
299
300        let resp = app.oneshot(req).await.unwrap();
301        assert_eq!(resp.status(), StatusCode::OK);
302
303        let body = body_string(resp).await;
304        assert!(body.contains("StoreDir: /nix/store"));
305        assert!(body.contains("WantMassQuery: 1"));
306        assert!(body.contains("Priority: 40"));
307    }
308
309    #[tokio::test]
310    async fn get_narinfo_not_found() {
311        let dir = tempfile::tempdir().unwrap();
312        let app = test_app(dir.path());
313
314        let req = axum::http::Request::builder()
315            .uri("/abc.narinfo")
316            .body(Body::empty())
317            .unwrap();
318
319        let resp = app.oneshot(req).await.unwrap();
320        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
321    }
322
323    #[tokio::test]
324    async fn put_then_get_narinfo() {
325        let dir = tempfile::tempdir().unwrap();
326        let app = test_app(dir.path());
327
328        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";
329
330        // PUT narinfo.
331        let req = axum::http::Request::builder()
332            .method("PUT")
333            .uri("/abc.narinfo")
334            .body(Body::from(narinfo.to_string()))
335            .unwrap();
336
337        let resp = app.clone().oneshot(req).await.unwrap();
338        assert_eq!(resp.status(), StatusCode::OK);
339
340        // GET narinfo.
341        let req = axum::http::Request::builder()
342            .uri("/abc.narinfo")
343            .body(Body::empty())
344            .unwrap();
345
346        let resp = app.oneshot(req).await.unwrap();
347        assert_eq!(resp.status(), StatusCode::OK);
348
349        let body = body_string(resp).await;
350        assert!(body.contains("StorePath: /nix/store/abc-hello"));
351    }
352
353    #[tokio::test]
354    async fn get_nar_not_found() {
355        let dir = tempfile::tempdir().unwrap();
356        let app = test_app(dir.path());
357
358        let req = axum::http::Request::builder()
359            .uri("/nar/abc.nar.xz")
360            .body(Body::empty())
361            .unwrap();
362
363        let resp = app.oneshot(req).await.unwrap();
364        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
365    }
366
367    #[tokio::test]
368    async fn put_then_get_nar() {
369        let dir = tempfile::tempdir().unwrap();
370        let app = test_app(dir.path());
371
372        let nar_data = b"fake nar blob data";
373
374        // PUT NAR.
375        let req = axum::http::Request::builder()
376            .method("PUT")
377            .uri("/nar/xyz.nar.xz")
378            .body(Body::from(nar_data.to_vec()))
379            .unwrap();
380
381        let resp = app.clone().oneshot(req).await.unwrap();
382        assert_eq!(resp.status(), StatusCode::OK);
383
384        // GET NAR.
385        let req = axum::http::Request::builder()
386            .uri("/nar/xyz.nar.xz")
387            .body(Body::empty())
388            .unwrap();
389
390        let resp = app.oneshot(req).await.unwrap();
391        assert_eq!(resp.status(), StatusCode::OK);
392
393        let body = body_bytes(resp).await;
394        assert_eq!(body, nar_data);
395    }
396
397    #[tokio::test]
398    async fn get_narinfo_content_type() {
399        let dir = tempfile::tempdir().unwrap();
400        let storage = LocalStorage::new(dir.path());
401        storage
402            .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")
403            .await
404            .unwrap();
405
406        let app = test_app(dir.path());
407        let req = axum::http::Request::builder()
408            .uri("/ct.narinfo")
409            .body(Body::empty())
410            .unwrap();
411
412        let resp = app.oneshot(req).await.unwrap();
413        assert_eq!(resp.status(), StatusCode::OK);
414        assert_eq!(
415            resp.headers().get("content-type").unwrap(),
416            "text/x-nix-narinfo"
417        );
418    }
419
420    #[tokio::test]
421    async fn get_nar_xz_content_type() {
422        let dir = tempfile::tempdir().unwrap();
423        let storage = LocalStorage::new(dir.path());
424        storage
425            .put_nar("nar/test.nar.xz", b"data")
426            .await
427            .unwrap();
428
429        let app = test_app(dir.path());
430        let req = axum::http::Request::builder()
431            .uri("/nar/test.nar.xz")
432            .body(Body::empty())
433            .unwrap();
434
435        let resp = app.oneshot(req).await.unwrap();
436        assert_eq!(resp.status(), StatusCode::OK);
437        assert_eq!(
438            resp.headers().get("content-type").unwrap(),
439            "application/x-xz"
440        );
441    }
442
443    #[tokio::test]
444    async fn cache_info_custom_priority() {
445        let dir = tempfile::tempdir().unwrap();
446        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
447        let config = CacheConfig {
448            priority: 10,
449            want_mass_query: false,
450            ..CacheConfig::default()
451        };
452        let app = build_router(AppState {
453            storage,
454            config,
455            signer: None,
456        });
457
458        let req = axum::http::Request::builder()
459            .uri("/nix-cache-info")
460            .body(Body::empty())
461            .unwrap();
462
463        let resp = app.oneshot(req).await.unwrap();
464        let body = body_string(resp).await;
465        assert!(body.contains("Priority: 10"));
466        assert!(body.contains("WantMassQuery: 0"));
467    }
468
469    /// Sign-on-ingest proof: with a signer configured, a `PUT`-then-`GET`
470    /// narinfo comes back carrying a `Sig:` that verifies against the
471    /// signer's public key. This exercises the exact serve-path wiring
472    /// (`put_narinfo` → `sign_narinfo_text`), not just the library.
473    #[tokio::test]
474    async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
475        use crate::signing::{verify_narinfo_signature, CacheSigner};
476
477        let dir = tempfile::tempdir().unwrap();
478        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
479        let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
480        let pk = signer.public_key_string();
481        let config = CacheConfig {
482            listen: "127.0.0.1:0".to_string(),
483            backend: BackendConfig::Local { path: dir.path().to_path_buf() },
484            priority: 40,
485            want_mass_query: true,
486            store_dir: "/nix/store".to_string(),
487            signing_key: None,
488            require_sigs: false,
489        };
490        let app = build_router(AppState { storage, config, signer: Some(signer.clone()) });
491
492        // Unsigned narinfo (references deliberately unsorted).
493        let narinfo = "StorePath: /nix/store/abc-hello\n\
494                       URL: nar/abc.nar.xz\n\
495                       Compression: xz\n\
496                       FileHash: sha256:aaa\n\
497                       FileSize: 100\n\
498                       NarHash: sha256:bbb\n\
499                       NarSize: 200\n\
500                       References: zzz-b aaa-a\n";
501
502        let req = axum::http::Request::builder()
503            .method("PUT")
504            .uri("/abc.narinfo")
505            .body(Body::from(narinfo))
506            .unwrap();
507        let resp = app.clone().oneshot(req).await.unwrap();
508        assert_eq!(resp.status(), StatusCode::OK);
509
510        let req = axum::http::Request::builder()
511            .uri("/abc.narinfo")
512            .body(Body::empty())
513            .unwrap();
514        let resp = app.oneshot(req).await.unwrap();
515        assert_eq!(resp.status(), StatusCode::OK);
516        let body = body_string(resp).await;
517
518        let parsed = NarInfo::parse(&body).unwrap();
519        assert_eq!(parsed.signatures.len(), 1, "GET must return a signed narinfo");
520        assert!(parsed.signatures[0].starts_with("ingest-key:"));
521        assert!(
522            verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
523            "the ingest signature must verify against the signer public key",
524        );
525    }
526
527    /// Re-`PUT` of an already-signed narinfo does not double-sign.
528    #[tokio::test]
529    async fn put_narinfo_is_idempotent_under_our_key() {
530        use crate::signing::CacheSigner;
531
532        let dir = tempfile::tempdir().unwrap();
533        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
534        let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
535        let config = CacheConfig {
536            listen: "127.0.0.1:0".to_string(),
537            backend: BackendConfig::Local { path: dir.path().to_path_buf() },
538            priority: 40,
539            want_mass_query: true,
540            store_dir: "/nix/store".to_string(),
541            signing_key: None,
542            require_sigs: false,
543        };
544        let app = build_router(AppState { storage, config, signer: Some(signer) });
545
546        let narinfo = "StorePath: /nix/store/def-x\n\
547                       URL: nar/def.nar.xz\n\
548                       Compression: xz\n\
549                       FileHash: sha256:a\n\
550                       FileSize: 1\n\
551                       NarHash: sha256:b\n\
552                       NarSize: 2\n\
553                       References: \n";
554
555        // First PUT (signs), GET the signed text, PUT it back.
556        for uri in ["/def.narinfo"] {
557            let req = axum::http::Request::builder()
558                .method("PUT").uri(uri).body(Body::from(narinfo)).unwrap();
559            assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
560        }
561        let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
562        let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
563
564        let req = axum::http::Request::builder()
565            .method("PUT").uri("/def.narinfo").body(Body::from(signed.clone())).unwrap();
566        assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
567
568        let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
569        let final_text = body_string(app.oneshot(req).await.unwrap()).await;
570        let parsed = NarInfo::parse(&final_text).unwrap();
571        assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
572    }
573
574    #[tokio::test]
575    async fn put_narinfo_bad_utf8() {
576        let dir = tempfile::tempdir().unwrap();
577        let app = test_app(dir.path());
578
579        let req = axum::http::Request::builder()
580            .method("PUT")
581            .uri("/bad.narinfo")
582            .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
583            .unwrap();
584
585        let resp = app.oneshot(req).await.unwrap();
586        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
587    }
588}