1use 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#[derive(Clone)]
24pub struct AppState {
25 pub storage: Arc<dyn StorageBackend>,
27 pub config: CacheConfig,
29}
30
31#[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
41pub 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
64async 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
79async 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
103async 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
127async 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
154async 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 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 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 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 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}