1use 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#[derive(Clone)]
26pub struct AppState {
27 pub storage: Arc<dyn StorageBackend>,
29 pub config: CacheConfig,
31 pub signer: Option<Arc<CacheSigner>>,
40}
41
42#[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 .layer(DefaultBodyLimit::disable())
54 .with_state(state)
55}
56
57pub async fn serve(config: CacheConfig, storage: Arc<dyn StorageBackend>) -> Result<(), crate::CacheError> {
63 let listen = config.listen.clone();
64
65 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
109fn 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 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
135async 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
150async 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
174async 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 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
211async 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
238async 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 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 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 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 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 #[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 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 #[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 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}