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::{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::storage::StorageBackend;
21
22/// Shared application state for all handlers.
23#[derive(Clone)]
24pub struct AppState {
25    /// The storage backend.
26    pub storage: Arc<dyn StorageBackend>,
27    /// Cache configuration.
28    pub config: CacheConfig,
29}
30
31/// Build the axum router for the binary cache server.
32#[must_use]
33pub fn build_router(state: AppState) -> Router {
34    Router::new()
35        .route("/nix-cache-info", get(cache_info))
36        .route("/{hash_narinfo}", get(get_narinfo).put(put_narinfo))
37        .route("/nar/{*path}", get(get_nar).put(put_nar))
38        .with_state(state)
39}
40
41/// Start the cache server and listen for connections.
42///
43/// # Errors
44///
45/// Returns an error if binding or serving fails.
46pub async fn serve(config: CacheConfig, storage: Arc<dyn StorageBackend>) -> Result<(), crate::CacheError> {
47    let listen = config.listen.clone();
48    let state = AppState {
49        storage,
50        config,
51    };
52    let app = build_router(state);
53
54    tracing::info!("sui-cache listening on {listen}");
55    let listener = tokio::net::TcpListener::bind(&listen)
56        .await
57        .map_err(crate::CacheError::Io)?;
58    axum::serve(listener, app)
59        .await
60        .map_err(crate::CacheError::Io)?;
61    Ok(())
62}
63
64/// `GET /nix-cache-info` — returns cache metadata.
65async fn cache_info(State(state): State<AppState>) -> impl IntoResponse {
66    let body = format!(
67        "StoreDir: {}\nWantMassQuery: {}\nPriority: {}\n",
68        state.config.store_dir,
69        if state.config.want_mass_query { 1 } else { 0 },
70        state.config.priority,
71    );
72    (
73        StatusCode::OK,
74        [("content-type", "text/x-nix-cache-info")],
75        body,
76    )
77}
78
79/// `GET /{hash}.narinfo` — returns narinfo text.
80async fn get_narinfo(
81    State(state): State<AppState>,
82    Path(hash_narinfo): Path<String>,
83) -> impl IntoResponse {
84    let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
85        return StatusCode::NOT_FOUND.into_response();
86    };
87
88    match state.storage.get_narinfo(hash).await {
89        Ok(Some(content)) => (
90            StatusCode::OK,
91            [("content-type", "text/x-nix-narinfo")],
92            content,
93        )
94            .into_response(),
95        Ok(None) => StatusCode::NOT_FOUND.into_response(),
96        Err(e) => {
97            tracing::error!("get_narinfo error: {e}");
98            StatusCode::INTERNAL_SERVER_ERROR.into_response()
99        }
100    }
101}
102
103/// `PUT /{hash}.narinfo` — uploads narinfo text.
104async fn put_narinfo(
105    State(state): State<AppState>,
106    Path(hash_narinfo): Path<String>,
107    body: Bytes,
108) -> impl IntoResponse {
109    let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
110        return StatusCode::BAD_REQUEST.into_response();
111    };
112
113    let content = match String::from_utf8(body.to_vec()) {
114        Ok(s) => s,
115        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
116    };
117
118    match state.storage.put_narinfo(hash, &content).await {
119        Ok(()) => StatusCode::OK.into_response(),
120        Err(e) => {
121            tracing::error!("put_narinfo error: {e}");
122            StatusCode::INTERNAL_SERVER_ERROR.into_response()
123        }
124    }
125}
126
127/// `GET /nar/{path}` — returns a compressed NAR blob.
128async fn get_nar(
129    State(state): State<AppState>,
130    Path(path): Path<String>,
131) -> impl IntoResponse {
132    let nar_path = format!("nar/{path}");
133    match state.storage.get_nar(&nar_path).await {
134        Ok(Some(data)) => {
135            let content_type = if path.ends_with(".xz") {
136                "application/x-xz"
137            } else if path.ends_with(".zstd") || path.ends_with(".zst") {
138                "application/zstd"
139            } else {
140                "application/x-nix-nar"
141            };
142            let mut headers = HeaderMap::new();
143            headers.insert("content-type", content_type.parse().unwrap());
144            (StatusCode::OK, headers, data).into_response()
145        }
146        Ok(None) => StatusCode::NOT_FOUND.into_response(),
147        Err(e) => {
148            tracing::error!("get_nar error: {e}");
149            StatusCode::INTERNAL_SERVER_ERROR.into_response()
150        }
151    }
152}
153
154/// `PUT /nar/{path}` — uploads a compressed NAR blob.
155async fn put_nar(
156    State(state): State<AppState>,
157    Path(path): Path<String>,
158    body: Bytes,
159) -> impl IntoResponse {
160    let nar_path = format!("nar/{path}");
161    match state.storage.put_nar(&nar_path, &body).await {
162        Ok(()) => StatusCode::OK.into_response(),
163        Err(e) => {
164            tracing::error!("put_nar error: {e}");
165            StatusCode::INTERNAL_SERVER_ERROR.into_response()
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::config::BackendConfig;
174    use crate::storage::local::LocalStorage;
175    use axum::body::Body;
176    use http_body_util::BodyExt;
177    use tower::ServiceExt;
178
179    fn test_app(dir: &std::path::Path) -> Router {
180        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
181        let config = CacheConfig {
182            listen: "127.0.0.1:0".to_string(),
183            backend: BackendConfig::Local {
184                path: dir.to_path_buf(),
185            },
186            priority: 40,
187            want_mass_query: true,
188            store_dir: "/nix/store".to_string(),
189            signing_key: None,
190        };
191        build_router(AppState { storage, config })
192    }
193
194    async fn body_string(response: axum::http::Response<Body>) -> String {
195        let body = response.into_body();
196        let bytes = body.collect().await.unwrap().to_bytes();
197        String::from_utf8(bytes.to_vec()).unwrap()
198    }
199
200    async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
201        let body = response.into_body();
202        body.collect().await.unwrap().to_bytes().to_vec()
203    }
204
205    #[tokio::test]
206    async fn cache_info_endpoint() {
207        let dir = tempfile::tempdir().unwrap();
208        let app = test_app(dir.path());
209
210        let req = axum::http::Request::builder()
211            .uri("/nix-cache-info")
212            .body(Body::empty())
213            .unwrap();
214
215        let resp = app.oneshot(req).await.unwrap();
216        assert_eq!(resp.status(), StatusCode::OK);
217
218        let body = body_string(resp).await;
219        assert!(body.contains("StoreDir: /nix/store"));
220        assert!(body.contains("WantMassQuery: 1"));
221        assert!(body.contains("Priority: 40"));
222    }
223
224    #[tokio::test]
225    async fn get_narinfo_not_found() {
226        let dir = tempfile::tempdir().unwrap();
227        let app = test_app(dir.path());
228
229        let req = axum::http::Request::builder()
230            .uri("/abc.narinfo")
231            .body(Body::empty())
232            .unwrap();
233
234        let resp = app.oneshot(req).await.unwrap();
235        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
236    }
237
238    #[tokio::test]
239    async fn put_then_get_narinfo() {
240        let dir = tempfile::tempdir().unwrap();
241        let app = test_app(dir.path());
242
243        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";
244
245        // PUT narinfo.
246        let req = axum::http::Request::builder()
247            .method("PUT")
248            .uri("/abc.narinfo")
249            .body(Body::from(narinfo.to_string()))
250            .unwrap();
251
252        let resp = app.clone().oneshot(req).await.unwrap();
253        assert_eq!(resp.status(), StatusCode::OK);
254
255        // GET narinfo.
256        let req = axum::http::Request::builder()
257            .uri("/abc.narinfo")
258            .body(Body::empty())
259            .unwrap();
260
261        let resp = app.oneshot(req).await.unwrap();
262        assert_eq!(resp.status(), StatusCode::OK);
263
264        let body = body_string(resp).await;
265        assert!(body.contains("StorePath: /nix/store/abc-hello"));
266    }
267
268    #[tokio::test]
269    async fn get_nar_not_found() {
270        let dir = tempfile::tempdir().unwrap();
271        let app = test_app(dir.path());
272
273        let req = axum::http::Request::builder()
274            .uri("/nar/abc.nar.xz")
275            .body(Body::empty())
276            .unwrap();
277
278        let resp = app.oneshot(req).await.unwrap();
279        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
280    }
281
282    #[tokio::test]
283    async fn put_then_get_nar() {
284        let dir = tempfile::tempdir().unwrap();
285        let app = test_app(dir.path());
286
287        let nar_data = b"fake nar blob data";
288
289        // PUT NAR.
290        let req = axum::http::Request::builder()
291            .method("PUT")
292            .uri("/nar/xyz.nar.xz")
293            .body(Body::from(nar_data.to_vec()))
294            .unwrap();
295
296        let resp = app.clone().oneshot(req).await.unwrap();
297        assert_eq!(resp.status(), StatusCode::OK);
298
299        // GET NAR.
300        let req = axum::http::Request::builder()
301            .uri("/nar/xyz.nar.xz")
302            .body(Body::empty())
303            .unwrap();
304
305        let resp = app.oneshot(req).await.unwrap();
306        assert_eq!(resp.status(), StatusCode::OK);
307
308        let body = body_bytes(resp).await;
309        assert_eq!(body, nar_data);
310    }
311
312    #[tokio::test]
313    async fn get_narinfo_content_type() {
314        let dir = tempfile::tempdir().unwrap();
315        let storage = LocalStorage::new(dir.path());
316        storage
317            .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")
318            .await
319            .unwrap();
320
321        let app = test_app(dir.path());
322        let req = axum::http::Request::builder()
323            .uri("/ct.narinfo")
324            .body(Body::empty())
325            .unwrap();
326
327        let resp = app.oneshot(req).await.unwrap();
328        assert_eq!(resp.status(), StatusCode::OK);
329        assert_eq!(
330            resp.headers().get("content-type").unwrap(),
331            "text/x-nix-narinfo"
332        );
333    }
334
335    #[tokio::test]
336    async fn get_nar_xz_content_type() {
337        let dir = tempfile::tempdir().unwrap();
338        let storage = LocalStorage::new(dir.path());
339        storage
340            .put_nar("nar/test.nar.xz", b"data")
341            .await
342            .unwrap();
343
344        let app = test_app(dir.path());
345        let req = axum::http::Request::builder()
346            .uri("/nar/test.nar.xz")
347            .body(Body::empty())
348            .unwrap();
349
350        let resp = app.oneshot(req).await.unwrap();
351        assert_eq!(resp.status(), StatusCode::OK);
352        assert_eq!(
353            resp.headers().get("content-type").unwrap(),
354            "application/x-xz"
355        );
356    }
357
358    #[tokio::test]
359    async fn cache_info_custom_priority() {
360        let dir = tempfile::tempdir().unwrap();
361        let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
362        let config = CacheConfig {
363            priority: 10,
364            want_mass_query: false,
365            ..CacheConfig::default()
366        };
367        let app = build_router(AppState {
368            storage,
369            config,
370        });
371
372        let req = axum::http::Request::builder()
373            .uri("/nix-cache-info")
374            .body(Body::empty())
375            .unwrap();
376
377        let resp = app.oneshot(req).await.unwrap();
378        let body = body_string(resp).await;
379        assert!(body.contains("Priority: 10"));
380        assert!(body.contains("WantMassQuery: 0"));
381    }
382
383    #[tokio::test]
384    async fn put_narinfo_bad_utf8() {
385        let dir = tempfile::tempdir().unwrap();
386        let app = test_app(dir.path());
387
388        let req = axum::http::Request::builder()
389            .method("PUT")
390            .uri("/bad.narinfo")
391            .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
392            .unwrap();
393
394        let resp = app.oneshot(req).await.unwrap();
395        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
396    }
397}