1use 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#[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)) if !crate::is_servable_narinfo(&content) => {
180 tracing::error!(
181 hash = %hash,
182 len = content.len(),
183 "get_narinfo: stored narinfo is empty or has no StorePath — SERVING 404 so the \
184 client treats it as a miss instead of aborting; this entry is poison and should \
185 be evicted",
186 );
187 StatusCode::NOT_FOUND.into_response()
188 }
189 Ok(Some(content)) => (
190 StatusCode::OK,
191 [("content-type", "text/x-nix-narinfo")],
192 content,
193 )
194 .into_response(),
195 Ok(None) => StatusCode::NOT_FOUND.into_response(),
196 Err(e) => {
206 tracing::error!(
207 hash = %hash,
208 error = %e,
209 "get_narinfo: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
210 client rebuilds instead of aborting; the backend needs attention",
211 );
212 StatusCode::NOT_FOUND.into_response()
213 }
214 }
215}
216
217async fn put_narinfo(
219 State(state): State<AppState>,
220 Path(hash_narinfo): Path<String>,
221 body: Bytes,
222) -> impl IntoResponse {
223 let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
224 return StatusCode::BAD_REQUEST.into_response();
225 };
226
227 let content = match String::from_utf8(body.to_vec()) {
228 Ok(s) => s,
229 Err(_) => return StatusCode::BAD_REQUEST.into_response(),
230 };
231
232 if !crate::is_servable_narinfo(&content) {
243 tracing::warn!(
244 hash = %hash,
245 len = content.len(),
246 "put_narinfo: refusing a narinfo with no StorePath line — an entry that cannot be \
247 served is worse than an absent one, because nix aborts on it instead of missing",
248 );
249 return StatusCode::BAD_REQUEST.into_response();
250 }
251
252 if let Some(url) = crate::advertised_url_line(&content) {
258 if !crate::is_addressable_nar_path(url) {
259 tracing::warn!(
260 hash = %hash, url = %url,
261 "put_narinfo: refusing a narinfo whose URL is not an addressable relative path",
262 );
263 return StatusCode::BAD_REQUEST.into_response();
264 }
265 }
266
267 let to_store = match &state.signer {
270 Some(signer) => match sign_narinfo_text(signer, &content) {
271 Ok(signed) => signed,
272 Err(e) => {
273 tracing::error!("put_narinfo signing error: {e}");
274 return StatusCode::BAD_REQUEST.into_response();
275 }
276 },
277 None => content,
278 };
279
280 match state.storage.put_narinfo(hash, &to_store).await {
296 Ok(()) => StatusCode::OK.into_response(),
297 Err(e) => {
298 tracing::error!(
299 hash = %hash,
300 error = %e,
301 "put_narinfo: EVERY durable tier rejected the write — nothing stored; \
302 reporting failure rather than falsely acknowledging the upload",
303 );
304 StatusCode::INTERNAL_SERVER_ERROR.into_response()
305 }
306 }
307}
308
309fn nar_content_type(path: &str) -> &'static str {
311 if path.ends_with(".xz") {
312 "application/x-xz"
313 } else if path.ends_with(".zstd") || path.ends_with(".zst") {
314 "application/zstd"
315 } else {
316 "application/x-nix-nar"
317 }
318}
319
320async fn get_nar(
332 State(state): State<AppState>,
333 Path(path): Path<String>,
334) -> impl IntoResponse {
335 let nar_path = format!("nar/{path}");
336 match state.storage.get_nar_stream(&nar_path).await {
337 Ok(Some(stream)) => {
338 let mut headers = HeaderMap::new();
339 headers.insert("content-type", nar_content_type(&path).parse().unwrap());
340 (StatusCode::OK, headers, Body::from_stream(stream)).into_response()
341 }
342 Ok(None) => StatusCode::NOT_FOUND.into_response(),
343 Err(e) => {
346 tracing::error!(
347 nar_path = %nar_path,
348 error = %e,
349 "get_nar: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
350 client rebuilds instead of aborting; the backend needs attention",
351 );
352 StatusCode::NOT_FOUND.into_response()
353 }
354 }
355}
356
357async fn put_nar(
370 State(state): State<AppState>,
371 Path(path): Path<String>,
372 body: Body,
373) -> impl IntoResponse {
374 let nar_path = format!("nar/{path}");
375
376 let src = match sui_castore::spool_or_buffer(
377 body.into_data_stream(),
378 &std::env::temp_dir(),
379 sui_castore::DEFAULT_INGEST_MEMORY_CAP,
380 )
381 .await
382 {
383 Ok(src) => src,
384 Err(e) => {
385 tracing::error!(
386 nar_path = %nar_path,
387 error = %e,
388 "put_nar: could not stage the upload (spool write failed, or it exceeded \
389 the in-memory fallback cap) — nothing stored",
390 );
391 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
392 }
393 };
394
395 match state.storage.put_nar_stream(&nar_path, src.as_ref()).await {
398 Ok(()) => StatusCode::OK.into_response(),
399 Err(e) => {
400 tracing::error!(
401 nar_path = %nar_path,
402 error = %e,
403 "put_nar: EVERY durable tier rejected the write — nothing stored; \
404 reporting failure rather than falsely acknowledging the upload",
405 );
406 StatusCode::INTERNAL_SERVER_ERROR.into_response()
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::config::BackendConfig;
415 use crate::LocalStorage;
416 use axum::body::Body;
417 use http_body_util::BodyExt;
418 use tower::ServiceExt;
419
420 fn test_app(dir: &std::path::Path) -> Router {
421 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
422 let config = CacheConfig {
423 listen: "127.0.0.1:0".to_string(),
424 backend: BackendConfig::Local {
425 path: dir.to_path_buf(),
426 },
427 priority: 40,
428 want_mass_query: true,
429 store_dir: "/nix/store".to_string(),
430 signing_key: None,
431 require_sigs: false,
432 };
433 build_router(AppState { storage, config, signer: None })
434 }
435
436 async fn body_string(response: axum::http::Response<Body>) -> String {
437 let body = response.into_body();
438 let bytes = body.collect().await.unwrap().to_bytes();
439 String::from_utf8(bytes.to_vec()).unwrap()
440 }
441
442 async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
443 let body = response.into_body();
444 body.collect().await.unwrap().to_bytes().to_vec()
445 }
446
447 #[tokio::test]
448 async fn cache_info_endpoint() {
449 let dir = tempfile::tempdir().unwrap();
450 let app = test_app(dir.path());
451
452 let req = axum::http::Request::builder()
453 .uri("/nix-cache-info")
454 .body(Body::empty())
455 .unwrap();
456
457 let resp = app.oneshot(req).await.unwrap();
458 assert_eq!(resp.status(), StatusCode::OK);
459
460 let body = body_string(resp).await;
461 assert!(body.contains("StoreDir: /nix/store"));
462 assert!(body.contains("WantMassQuery: 1"));
463 assert!(body.contains("Priority: 40"));
464 }
465
466 #[tokio::test]
467 async fn get_narinfo_not_found() {
468 let dir = tempfile::tempdir().unwrap();
469 let app = test_app(dir.path());
470
471 let req = axum::http::Request::builder()
472 .uri("/abc.narinfo")
473 .body(Body::empty())
474 .unwrap();
475
476 let resp = app.oneshot(req).await.unwrap();
477 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
478 }
479
480 #[tokio::test]
481 async fn put_then_get_narinfo() {
482 let dir = tempfile::tempdir().unwrap();
483 let app = test_app(dir.path());
484
485 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";
486
487 let req = axum::http::Request::builder()
489 .method("PUT")
490 .uri("/abc.narinfo")
491 .body(Body::from(narinfo.to_string()))
492 .unwrap();
493
494 let resp = app.clone().oneshot(req).await.unwrap();
495 assert_eq!(resp.status(), StatusCode::OK);
496
497 let req = axum::http::Request::builder()
499 .uri("/abc.narinfo")
500 .body(Body::empty())
501 .unwrap();
502
503 let resp = app.oneshot(req).await.unwrap();
504 assert_eq!(resp.status(), StatusCode::OK);
505
506 let body = body_string(resp).await;
507 assert!(body.contains("StorePath: /nix/store/abc-hello"));
508 }
509
510 #[tokio::test]
511 async fn get_nar_not_found() {
512 let dir = tempfile::tempdir().unwrap();
513 let app = test_app(dir.path());
514
515 let req = axum::http::Request::builder()
516 .uri("/nar/abc.nar.xz")
517 .body(Body::empty())
518 .unwrap();
519
520 let resp = app.oneshot(req).await.unwrap();
521 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
522 }
523
524 #[tokio::test]
525 async fn put_then_get_nar() {
526 let dir = tempfile::tempdir().unwrap();
527 let app = test_app(dir.path());
528
529 let nar_data = b"fake nar blob data";
530
531 let req = axum::http::Request::builder()
533 .method("PUT")
534 .uri("/nar/xyz.nar.xz")
535 .body(Body::from(nar_data.to_vec()))
536 .unwrap();
537
538 let resp = app.clone().oneshot(req).await.unwrap();
539 assert_eq!(resp.status(), StatusCode::OK);
540
541 let req = axum::http::Request::builder()
543 .uri("/nar/xyz.nar.xz")
544 .body(Body::empty())
545 .unwrap();
546
547 let resp = app.oneshot(req).await.unwrap();
548 assert_eq!(resp.status(), StatusCode::OK);
549
550 let body = body_bytes(resp).await;
551 assert_eq!(body, nar_data);
552 }
553
554 #[tokio::test]
563 async fn get_narinfo_serves_a_poisoned_entry_as_a_miss() {
564 let dir = tempfile::tempdir().unwrap();
565 let storage = LocalStorage::new(dir.path());
566 storage.put_narinfo("poison", "").await.unwrap();
567
568 let app = test_app(dir.path());
569 let req = axum::http::Request::builder()
570 .uri("/poison.narinfo")
571 .body(Body::empty())
572 .unwrap();
573
574 let resp = app.oneshot(req).await.unwrap();
575 assert_eq!(
576 resp.status(),
577 StatusCode::NOT_FOUND,
578 "an unusable hit must degrade to a miss; a 200 with an empty body makes the client \
579 ABORT rather than build, which is strictly worse than not having the entry"
580 );
581 }
582
583 #[tokio::test]
585 async fn put_narinfo_refuses_a_body_with_no_store_path() {
586 let dir = tempfile::tempdir().unwrap();
587 let app = test_app(dir.path());
588
589 let req = axum::http::Request::builder()
590 .method("PUT")
591 .uri("/empty.narinfo")
592 .body(Body::from(""))
593 .unwrap();
594
595 let resp = app.oneshot(req).await.unwrap();
596 assert_eq!(
597 resp.status(),
598 StatusCode::BAD_REQUEST,
599 "an empty body cannot become a servable narinfo, so it is the client's error"
600 );
601 }
602
603 #[tokio::test]
606 async fn put_then_get_a_well_formed_narinfo_still_works() {
607 let dir = tempfile::tempdir().unwrap();
608 let app = test_app(dir.path());
609 let good = "StorePath: /nix/store/ok-pkg\nURL: nar/ok.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
610
611 let put = axum::http::Request::builder()
612 .method("PUT")
613 .uri("/ok.narinfo")
614 .body(Body::from(good))
615 .unwrap();
616 let resp = app.clone().oneshot(put).await.unwrap();
617 assert!(resp.status().is_success(), "a valid narinfo must still be accepted");
618
619 let get = axum::http::Request::builder()
620 .uri("/ok.narinfo")
621 .body(Body::empty())
622 .unwrap();
623 let resp = app.oneshot(get).await.unwrap();
624 assert_eq!(resp.status(), StatusCode::OK, "and must still be served");
625 }
626
627 #[tokio::test]
628 async fn get_narinfo_content_type() {
629 let dir = tempfile::tempdir().unwrap();
630 let storage = LocalStorage::new(dir.path());
631 storage
632 .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")
633 .await
634 .unwrap();
635
636 let app = test_app(dir.path());
637 let req = axum::http::Request::builder()
638 .uri("/ct.narinfo")
639 .body(Body::empty())
640 .unwrap();
641
642 let resp = app.oneshot(req).await.unwrap();
643 assert_eq!(resp.status(), StatusCode::OK);
644 assert_eq!(
645 resp.headers().get("content-type").unwrap(),
646 "text/x-nix-narinfo"
647 );
648 }
649
650 #[tokio::test]
651 async fn get_nar_xz_content_type() {
652 let dir = tempfile::tempdir().unwrap();
653 let storage = LocalStorage::new(dir.path());
654 storage
655 .put_nar("nar/test.nar.xz", b"data")
656 .await
657 .unwrap();
658
659 let app = test_app(dir.path());
660 let req = axum::http::Request::builder()
661 .uri("/nar/test.nar.xz")
662 .body(Body::empty())
663 .unwrap();
664
665 let resp = app.oneshot(req).await.unwrap();
666 assert_eq!(resp.status(), StatusCode::OK);
667 assert_eq!(
668 resp.headers().get("content-type").unwrap(),
669 "application/x-xz"
670 );
671 }
672
673 #[tokio::test]
674 async fn cache_info_custom_priority() {
675 let dir = tempfile::tempdir().unwrap();
676 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
677 let config = CacheConfig {
678 priority: 10,
679 want_mass_query: false,
680 ..CacheConfig::default()
681 };
682 let app = build_router(AppState {
683 storage,
684 config,
685 signer: None,
686 });
687
688 let req = axum::http::Request::builder()
689 .uri("/nix-cache-info")
690 .body(Body::empty())
691 .unwrap();
692
693 let resp = app.oneshot(req).await.unwrap();
694 let body = body_string(resp).await;
695 assert!(body.contains("Priority: 10"));
696 assert!(body.contains("WantMassQuery: 0"));
697 }
698
699 #[tokio::test]
704 async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
705 use crate::signing::{verify_narinfo_signature, CacheSigner};
706
707 let dir = tempfile::tempdir().unwrap();
708 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
709 let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
710 let pk = signer.public_key_string();
711 let config = CacheConfig {
712 listen: "127.0.0.1:0".to_string(),
713 backend: BackendConfig::Local { path: dir.path().to_path_buf() },
714 priority: 40,
715 want_mass_query: true,
716 store_dir: "/nix/store".to_string(),
717 signing_key: None,
718 require_sigs: false,
719 };
720 let app = build_router(AppState { storage, config, signer: Some(signer.clone()) });
721
722 let narinfo = "StorePath: /nix/store/abc-hello\n\
724 URL: nar/abc.nar.xz\n\
725 Compression: xz\n\
726 FileHash: sha256:aaa\n\
727 FileSize: 100\n\
728 NarHash: sha256:bbb\n\
729 NarSize: 200\n\
730 References: zzz-b aaa-a\n";
731
732 let req = axum::http::Request::builder()
733 .method("PUT")
734 .uri("/abc.narinfo")
735 .body(Body::from(narinfo))
736 .unwrap();
737 let resp = app.clone().oneshot(req).await.unwrap();
738 assert_eq!(resp.status(), StatusCode::OK);
739
740 let req = axum::http::Request::builder()
741 .uri("/abc.narinfo")
742 .body(Body::empty())
743 .unwrap();
744 let resp = app.oneshot(req).await.unwrap();
745 assert_eq!(resp.status(), StatusCode::OK);
746 let body = body_string(resp).await;
747
748 let parsed = NarInfo::parse(&body).unwrap();
749 assert_eq!(parsed.signatures.len(), 1, "GET must return a signed narinfo");
750 assert!(parsed.signatures[0].starts_with("ingest-key:"));
751 assert!(
752 verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
753 "the ingest signature must verify against the signer public key",
754 );
755 }
756
757 #[tokio::test]
759 async fn put_narinfo_is_idempotent_under_our_key() {
760 use crate::signing::CacheSigner;
761
762 let dir = tempfile::tempdir().unwrap();
763 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
764 let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
765 let config = CacheConfig {
766 listen: "127.0.0.1:0".to_string(),
767 backend: BackendConfig::Local { path: dir.path().to_path_buf() },
768 priority: 40,
769 want_mass_query: true,
770 store_dir: "/nix/store".to_string(),
771 signing_key: None,
772 require_sigs: false,
773 };
774 let app = build_router(AppState { storage, config, signer: Some(signer) });
775
776 let narinfo = "StorePath: /nix/store/def-x\n\
777 URL: nar/def.nar.xz\n\
778 Compression: xz\n\
779 FileHash: sha256:a\n\
780 FileSize: 1\n\
781 NarHash: sha256:b\n\
782 NarSize: 2\n\
783 References: \n";
784
785 for uri in ["/def.narinfo"] {
787 let req = axum::http::Request::builder()
788 .method("PUT").uri(uri).body(Body::from(narinfo)).unwrap();
789 assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
790 }
791 let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
792 let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
793
794 let req = axum::http::Request::builder()
795 .method("PUT").uri("/def.narinfo").body(Body::from(signed.clone())).unwrap();
796 assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
797
798 let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
799 let final_text = body_string(app.oneshot(req).await.unwrap()).await;
800 let parsed = NarInfo::parse(&final_text).unwrap();
801 assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
802 }
803
804 #[derive(Default)]
809 struct BrokenStorage {
810 nar_refs: crate::MemNarRefIndex,
814 }
815
816 #[async_trait::async_trait]
817 impl StorageBackend for BrokenStorage {
818 async fn get_narinfo(&self, _hash: &str) -> Result<Option<String>, crate::CacheError> {
819 Err(crate::CacheError::Io(std::io::Error::other(
820 "postgres: error returned from database: relation \"sui_cache_narinfo\" does not exist",
821 )))
822 }
823 async fn put_narinfo_record(
824 &self,
825 _hash: &str,
826 _content: &str,
827 ) -> Result<(), crate::CacheError> {
828 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
829 }
830 async fn delete_narinfo_record(&self, _hash: &str) -> Result<(), crate::CacheError> {
831 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
832 }
833 async fn delete_nar_record(&self, _nar_path: &str) -> Result<(), crate::CacheError> {
834 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
835 }
836 fn nar_ref_index(&self) -> &dyn crate::NarRefIndex {
837 &self.nar_refs
838 }
839 async fn get_nar(&self, _path: &str) -> Result<Option<Vec<u8>>, crate::CacheError> {
840 Err(crate::CacheError::Io(std::io::Error::other(
841 "postgres: error returned from database: relation \"sui_cache_nar\" does not exist",
842 )))
843 }
844 async fn put_nar(&self, _path: &str, _data: &[u8]) -> Result<(), crate::CacheError> {
845 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
846 }
847 fn nar_residency(&self) -> crate::NarResidency {
851 crate::NarResidency::WholeValue
852 }
853
854 async fn list_narinfos(&self) -> Result<Vec<String>, crate::CacheError> {
855 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
856 }
857 }
858
859 fn broken_app() -> Router {
860 let storage: Arc<dyn StorageBackend> = Arc::new(BrokenStorage::default());
861 build_router(AppState {
862 storage,
863 config: CacheConfig::default(),
864 signer: None,
865 })
866 }
867
868 #[tokio::test]
869 async fn broken_backend_narinfo_read_is_a_miss_not_a_server_error() {
870 let resp = broken_app()
874 .oneshot(
875 axum::http::Request::builder()
876 .uri("/abc.narinfo")
877 .body(Body::empty())
878 .unwrap(),
879 )
880 .await
881 .unwrap();
882 assert_eq!(
883 resp.status(),
884 StatusCode::NOT_FOUND,
885 "a backend that cannot answer must report a MISS, never a 500",
886 );
887 assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
888 }
889
890 #[tokio::test]
891 async fn broken_backend_nar_read_is_a_miss_not_a_server_error() {
892 let resp = broken_app()
893 .oneshot(
894 axum::http::Request::builder()
895 .uri("/nar/abc.nar.xz")
896 .body(Body::empty())
897 .unwrap(),
898 )
899 .await
900 .unwrap();
901 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
902 }
903
904 #[tokio::test]
905 async fn cache_info_still_answers_while_the_backend_is_broken() {
906 let resp = broken_app()
909 .oneshot(
910 axum::http::Request::builder()
911 .uri("/nix-cache-info")
912 .body(Body::empty())
913 .unwrap(),
914 )
915 .await
916 .unwrap();
917 assert_eq!(resp.status(), StatusCode::OK);
918 }
919
920 #[tokio::test]
921 async fn a_totally_failed_write_still_reports_failure() {
922 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";
926 let resp = broken_app()
927 .oneshot(
928 axum::http::Request::builder()
929 .method("PUT")
930 .uri("/abc.narinfo")
931 .body(Body::from(narinfo))
932 .unwrap(),
933 )
934 .await
935 .unwrap();
936 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
937 }
938
939 #[tokio::test]
946 async fn put_narinfo_with_a_traversal_url_is_rejected() {
947 let dir = tempfile::tempdir().unwrap();
948 let app = test_app(dir.path());
949 let evil = "StorePath: /nix/store/abc-hello\nURL: ../../escape.nar\nCompression: xz\n\
950 FileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
951
952 let req = axum::http::Request::builder()
953 .method("PUT")
954 .uri("/abc.narinfo")
955 .body(Body::from(evil))
956 .unwrap();
957 let resp = app.clone().oneshot(req).await.unwrap();
958 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
959
960 let get = axum::http::Request::builder()
961 .uri("/abc.narinfo")
962 .body(Body::empty())
963 .unwrap();
964 assert_eq!(
965 app.oneshot(get).await.unwrap().status(),
966 StatusCode::NOT_FOUND,
967 "a rejected narinfo must not have been stored",
968 );
969 }
970
971 #[tokio::test]
972 async fn put_narinfo_bad_utf8() {
973 let dir = tempfile::tempdir().unwrap();
974 let app = test_app(dir.path());
975
976 let req = axum::http::Request::builder()
977 .method("PUT")
978 .uri("/bad.narinfo")
979 .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
980 .unwrap();
981
982 let resp = app.oneshot(req).await.unwrap();
983 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
984 }
985}