1use std::sync::Arc;
11
12use axum::Router;
13use axum::body::{Body, Bytes};
14use axum::extract::{DefaultBodyLimit, Path, State};
15use axum::http::{HeaderMap, StatusCode};
16use axum::response::IntoResponse;
17use axum::routing::get;
18
19use crate::StorageBackend;
20use crate::config::CacheConfig;
21use crate::signing::CacheSigner;
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(
63 config: CacheConfig,
64 storage: Arc<dyn StorageBackend>,
65) -> Result<(), crate::CacheError> {
66 let listen = config.listen.clone();
67
68 let signer = match &config.signing_key {
74 Some(path) => {
75 let key_str = std::fs::read_to_string(path).map_err(crate::CacheError::Io)?;
76 let signer = CacheSigner::from_secret_key_string(key_str.trim())?;
77 tracing::info!(
78 key_name = signer.key_name(),
79 public_key = %signer.public_key_string(),
80 "sui-cache signing ENABLED — every ingested narinfo is signed; \
81 distribute the public key to consumers as a trusted-public-key",
82 );
83 Some(Arc::new(signer))
84 }
85 None => {
86 tracing::warn!(
87 "sui-cache signing DISABLED (no signing_key configured) — narinfo \
88 served unsigned; consumers cannot verify integrity. Set a \
89 cofre/ESO-backed signing key to close the poisoned-write hole.",
90 );
91 None
92 }
93 };
94
95 let state = AppState {
96 storage,
97 config,
98 signer,
99 };
100 let app = build_router(state);
101
102 tracing::info!("sui-cache listening on {listen}");
103 let listener = tokio::net::TcpListener::bind(&listen)
104 .await
105 .map_err(crate::CacheError::Io)?;
106 axum::serve(listener, app)
107 .await
108 .map_err(crate::CacheError::Io)?;
109 Ok(())
110}
111
112fn sign_narinfo_text(signer: &CacheSigner, content: &str) -> Result<String, crate::CacheError> {
124 let mut info =
125 NarInfo::parse(content).map_err(|e| crate::CacheError::NarInfo(e.to_string()))?;
126
127 let key_prefix = format!("{}:", signer.key_name());
128 if info.signatures.iter().any(|s| s.starts_with(&key_prefix)) {
129 return Ok(content.to_string());
131 }
132
133 let sig = signer.sign_narinfo(&info);
134 info.signatures.push(sig);
135 Ok(info.serialize())
136}
137
138async fn cache_info(State(state): State<AppState>) -> impl IntoResponse {
140 let body = format!(
141 "StoreDir: {}\nWantMassQuery: {}\nPriority: {}\n",
142 state.config.store_dir,
143 if state.config.want_mass_query { 1 } else { 0 },
144 state.config.priority,
145 );
146 (
147 StatusCode::OK,
148 [("content-type", "text/x-nix-cache-info")],
149 body,
150 )
151}
152
153async fn get_narinfo(
155 State(state): State<AppState>,
156 Path(hash_narinfo): Path<String>,
157) -> impl IntoResponse {
158 let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
159 return StatusCode::NOT_FOUND.into_response();
160 };
161
162 match state.storage.get_narinfo(hash).await {
163 Ok(Some(content)) if !crate::is_servable_narinfo(&content) => {
183 tracing::error!(
184 hash = %hash,
185 len = content.len(),
186 "get_narinfo: stored narinfo is empty or has no StorePath — SERVING 404 so the \
187 client treats it as a miss instead of aborting; this entry is poison and should \
188 be evicted",
189 );
190 StatusCode::NOT_FOUND.into_response()
191 }
192 Ok(Some(content)) => (
193 StatusCode::OK,
194 [("content-type", "text/x-nix-narinfo")],
195 content,
196 )
197 .into_response(),
198 Ok(None) => StatusCode::NOT_FOUND.into_response(),
199 Err(e) => {
209 tracing::error!(
210 hash = %hash,
211 error = %e,
212 "get_narinfo: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
213 client rebuilds instead of aborting; the backend needs attention",
214 );
215 StatusCode::NOT_FOUND.into_response()
216 }
217 }
218}
219
220async fn put_narinfo(
222 State(state): State<AppState>,
223 Path(hash_narinfo): Path<String>,
224 body: Bytes,
225) -> impl IntoResponse {
226 let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
227 return StatusCode::BAD_REQUEST.into_response();
228 };
229
230 let content = match String::from_utf8(body.to_vec()) {
231 Ok(s) => s,
232 Err(_) => return StatusCode::BAD_REQUEST.into_response(),
233 };
234
235 if !crate::is_servable_narinfo(&content) {
246 tracing::warn!(
247 hash = %hash,
248 len = content.len(),
249 "put_narinfo: refusing a narinfo with no StorePath line — an entry that cannot be \
250 served is worse than an absent one, because nix aborts on it instead of missing",
251 );
252 return StatusCode::BAD_REQUEST.into_response();
253 }
254
255 if let Some(url) = crate::advertised_url_line(&content) {
261 if !crate::is_addressable_nar_path(url) {
262 tracing::warn!(
263 hash = %hash, url = %url,
264 "put_narinfo: refusing a narinfo whose URL is not an addressable relative path",
265 );
266 return StatusCode::BAD_REQUEST.into_response();
267 }
268 }
269
270 let to_store = match &state.signer {
273 Some(signer) => match sign_narinfo_text(signer, &content) {
274 Ok(signed) => signed,
275 Err(e) => {
276 tracing::error!("put_narinfo signing error: {e}");
277 return StatusCode::BAD_REQUEST.into_response();
278 }
279 },
280 None => content,
281 };
282
283 match state.storage.put_narinfo(hash, &to_store).await {
299 Ok(()) => StatusCode::OK.into_response(),
300 Err(e) => {
301 tracing::error!(
302 hash = %hash,
303 error = %e,
304 "put_narinfo: EVERY durable tier rejected the write — nothing stored; \
305 reporting failure rather than falsely acknowledging the upload",
306 );
307 StatusCode::INTERNAL_SERVER_ERROR.into_response()
308 }
309 }
310}
311
312fn nar_content_type(path: &str) -> &'static str {
314 if path.ends_with(".xz") {
315 "application/x-xz"
316 } else if path.ends_with(".zstd") || path.ends_with(".zst") {
317 "application/zstd"
318 } else {
319 "application/x-nix-nar"
320 }
321}
322
323async fn get_nar(State(state): State<AppState>, Path(path): Path<String>) -> 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::LocalStorage;
415 use crate::config::BackendConfig;
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 ..CacheConfig::default()
435 };
436 build_router(AppState {
437 storage,
438 config,
439 signer: None,
440 })
441 }
442
443 async fn body_string(response: axum::http::Response<Body>) -> String {
444 let body = response.into_body();
445 let bytes = body.collect().await.unwrap().to_bytes();
446 String::from_utf8(bytes.to_vec()).unwrap()
447 }
448
449 async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
450 let body = response.into_body();
451 body.collect().await.unwrap().to_bytes().to_vec()
452 }
453
454 #[tokio::test]
455 async fn cache_info_endpoint() {
456 let dir = tempfile::tempdir().unwrap();
457 let app = test_app(dir.path());
458
459 let req = axum::http::Request::builder()
460 .uri("/nix-cache-info")
461 .body(Body::empty())
462 .unwrap();
463
464 let resp = app.oneshot(req).await.unwrap();
465 assert_eq!(resp.status(), StatusCode::OK);
466
467 let body = body_string(resp).await;
468 assert!(body.contains("StoreDir: /nix/store"));
469 assert!(body.contains("WantMassQuery: 1"));
470 assert!(body.contains("Priority: 40"));
471 }
472
473 #[tokio::test]
474 async fn get_narinfo_not_found() {
475 let dir = tempfile::tempdir().unwrap();
476 let app = test_app(dir.path());
477
478 let req = axum::http::Request::builder()
479 .uri("/abc.narinfo")
480 .body(Body::empty())
481 .unwrap();
482
483 let resp = app.oneshot(req).await.unwrap();
484 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
485 }
486
487 #[tokio::test]
488 async fn put_then_get_narinfo() {
489 let dir = tempfile::tempdir().unwrap();
490 let app = test_app(dir.path());
491
492 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";
493
494 let req = axum::http::Request::builder()
496 .method("PUT")
497 .uri("/abc.narinfo")
498 .body(Body::from(narinfo.to_string()))
499 .unwrap();
500
501 let resp = app.clone().oneshot(req).await.unwrap();
502 assert_eq!(resp.status(), StatusCode::OK);
503
504 let req = axum::http::Request::builder()
506 .uri("/abc.narinfo")
507 .body(Body::empty())
508 .unwrap();
509
510 let resp = app.oneshot(req).await.unwrap();
511 assert_eq!(resp.status(), StatusCode::OK);
512
513 let body = body_string(resp).await;
514 assert!(body.contains("StorePath: /nix/store/abc-hello"));
515 }
516
517 #[tokio::test]
518 async fn get_nar_not_found() {
519 let dir = tempfile::tempdir().unwrap();
520 let app = test_app(dir.path());
521
522 let req = axum::http::Request::builder()
523 .uri("/nar/abc.nar.xz")
524 .body(Body::empty())
525 .unwrap();
526
527 let resp = app.oneshot(req).await.unwrap();
528 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
529 }
530
531 #[tokio::test]
532 async fn put_then_get_nar() {
533 let dir = tempfile::tempdir().unwrap();
534 let app = test_app(dir.path());
535
536 let nar_data = b"fake nar blob data";
537
538 let req = axum::http::Request::builder()
540 .method("PUT")
541 .uri("/nar/xyz.nar.xz")
542 .body(Body::from(nar_data.to_vec()))
543 .unwrap();
544
545 let resp = app.clone().oneshot(req).await.unwrap();
546 assert_eq!(resp.status(), StatusCode::OK);
547
548 let req = axum::http::Request::builder()
550 .uri("/nar/xyz.nar.xz")
551 .body(Body::empty())
552 .unwrap();
553
554 let resp = app.oneshot(req).await.unwrap();
555 assert_eq!(resp.status(), StatusCode::OK);
556
557 let body = body_bytes(resp).await;
558 assert_eq!(body, nar_data);
559 }
560
561 #[tokio::test]
570 async fn get_narinfo_serves_a_poisoned_entry_as_a_miss() {
571 let dir = tempfile::tempdir().unwrap();
572 let storage = LocalStorage::new(dir.path());
573 storage.put_narinfo("poison", "").await.unwrap();
574
575 let app = test_app(dir.path());
576 let req = axum::http::Request::builder()
577 .uri("/poison.narinfo")
578 .body(Body::empty())
579 .unwrap();
580
581 let resp = app.oneshot(req).await.unwrap();
582 assert_eq!(
583 resp.status(),
584 StatusCode::NOT_FOUND,
585 "an unusable hit must degrade to a miss; a 200 with an empty body makes the client \
586 ABORT rather than build, which is strictly worse than not having the entry"
587 );
588 }
589
590 #[tokio::test]
592 async fn put_narinfo_refuses_a_body_with_no_store_path() {
593 let dir = tempfile::tempdir().unwrap();
594 let app = test_app(dir.path());
595
596 let req = axum::http::Request::builder()
597 .method("PUT")
598 .uri("/empty.narinfo")
599 .body(Body::from(""))
600 .unwrap();
601
602 let resp = app.oneshot(req).await.unwrap();
603 assert_eq!(
604 resp.status(),
605 StatusCode::BAD_REQUEST,
606 "an empty body cannot become a servable narinfo, so it is the client's error"
607 );
608 }
609
610 #[tokio::test]
613 async fn put_then_get_a_well_formed_narinfo_still_works() {
614 let dir = tempfile::tempdir().unwrap();
615 let app = test_app(dir.path());
616 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";
617
618 let put = axum::http::Request::builder()
619 .method("PUT")
620 .uri("/ok.narinfo")
621 .body(Body::from(good))
622 .unwrap();
623 let resp = app.clone().oneshot(put).await.unwrap();
624 assert!(
625 resp.status().is_success(),
626 "a valid narinfo must still be accepted"
627 );
628
629 let get = axum::http::Request::builder()
630 .uri("/ok.narinfo")
631 .body(Body::empty())
632 .unwrap();
633 let resp = app.oneshot(get).await.unwrap();
634 assert_eq!(resp.status(), StatusCode::OK, "and must still be served");
635 }
636
637 #[tokio::test]
638 async fn get_narinfo_content_type() {
639 let dir = tempfile::tempdir().unwrap();
640 let storage = LocalStorage::new(dir.path());
641 storage
642 .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")
643 .await
644 .unwrap();
645
646 let app = test_app(dir.path());
647 let req = axum::http::Request::builder()
648 .uri("/ct.narinfo")
649 .body(Body::empty())
650 .unwrap();
651
652 let resp = app.oneshot(req).await.unwrap();
653 assert_eq!(resp.status(), StatusCode::OK);
654 assert_eq!(
655 resp.headers().get("content-type").unwrap(),
656 "text/x-nix-narinfo"
657 );
658 }
659
660 #[tokio::test]
661 async fn get_nar_xz_content_type() {
662 let dir = tempfile::tempdir().unwrap();
663 let storage = LocalStorage::new(dir.path());
664 storage.put_nar("nar/test.nar.xz", b"data").await.unwrap();
665
666 let app = test_app(dir.path());
667 let req = axum::http::Request::builder()
668 .uri("/nar/test.nar.xz")
669 .body(Body::empty())
670 .unwrap();
671
672 let resp = app.oneshot(req).await.unwrap();
673 assert_eq!(resp.status(), StatusCode::OK);
674 assert_eq!(
675 resp.headers().get("content-type").unwrap(),
676 "application/x-xz"
677 );
678 }
679
680 #[tokio::test]
681 async fn cache_info_custom_priority() {
682 let dir = tempfile::tempdir().unwrap();
683 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
684 let config = CacheConfig {
685 priority: 10,
686 want_mass_query: false,
687 ..CacheConfig::default()
688 };
689 let app = build_router(AppState {
690 storage,
691 config,
692 signer: None,
693 });
694
695 let req = axum::http::Request::builder()
696 .uri("/nix-cache-info")
697 .body(Body::empty())
698 .unwrap();
699
700 let resp = app.oneshot(req).await.unwrap();
701 let body = body_string(resp).await;
702 assert!(body.contains("Priority: 10"));
703 assert!(body.contains("WantMassQuery: 0"));
704 }
705
706 #[tokio::test]
711 async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
712 use crate::signing::{CacheSigner, verify_narinfo_signature};
713
714 let dir = tempfile::tempdir().unwrap();
715 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
716 let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
717 let pk = signer.public_key_string();
718 let config = CacheConfig {
719 listen: "127.0.0.1:0".to_string(),
720 backend: BackendConfig::Local {
721 path: dir.path().to_path_buf(),
722 },
723 priority: 40,
724 want_mass_query: true,
725 store_dir: "/nix/store".to_string(),
726 signing_key: None,
727 require_sigs: false,
728 ..CacheConfig::default()
729 };
730 let app = build_router(AppState {
731 storage,
732 config,
733 signer: Some(signer.clone()),
734 });
735
736 let narinfo = "StorePath: /nix/store/abc-hello\n\
738 URL: nar/abc.nar.xz\n\
739 Compression: xz\n\
740 FileHash: sha256:aaa\n\
741 FileSize: 100\n\
742 NarHash: sha256:bbb\n\
743 NarSize: 200\n\
744 References: zzz-b aaa-a\n";
745
746 let req = axum::http::Request::builder()
747 .method("PUT")
748 .uri("/abc.narinfo")
749 .body(Body::from(narinfo))
750 .unwrap();
751 let resp = app.clone().oneshot(req).await.unwrap();
752 assert_eq!(resp.status(), StatusCode::OK);
753
754 let req = axum::http::Request::builder()
755 .uri("/abc.narinfo")
756 .body(Body::empty())
757 .unwrap();
758 let resp = app.oneshot(req).await.unwrap();
759 assert_eq!(resp.status(), StatusCode::OK);
760 let body = body_string(resp).await;
761
762 let parsed = NarInfo::parse(&body).unwrap();
763 assert_eq!(
764 parsed.signatures.len(),
765 1,
766 "GET must return a signed narinfo"
767 );
768 assert!(parsed.signatures[0].starts_with("ingest-key:"));
769 assert!(
770 verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
771 "the ingest signature must verify against the signer public key",
772 );
773 }
774
775 #[tokio::test]
777 async fn put_narinfo_is_idempotent_under_our_key() {
778 use crate::signing::CacheSigner;
779
780 let dir = tempfile::tempdir().unwrap();
781 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
782 let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
783 let config = CacheConfig {
784 listen: "127.0.0.1:0".to_string(),
785 backend: BackendConfig::Local {
786 path: dir.path().to_path_buf(),
787 },
788 priority: 40,
789 want_mass_query: true,
790 store_dir: "/nix/store".to_string(),
791 signing_key: None,
792 require_sigs: false,
793 ..CacheConfig::default()
794 };
795 let app = build_router(AppState {
796 storage,
797 config,
798 signer: Some(signer),
799 });
800
801 let narinfo = "StorePath: /nix/store/def-x\n\
802 URL: nar/def.nar.xz\n\
803 Compression: xz\n\
804 FileHash: sha256:a\n\
805 FileSize: 1\n\
806 NarHash: sha256:b\n\
807 NarSize: 2\n\
808 References: \n";
809
810 for uri in ["/def.narinfo"] {
812 let req = axum::http::Request::builder()
813 .method("PUT")
814 .uri(uri)
815 .body(Body::from(narinfo))
816 .unwrap();
817 assert_eq!(
818 app.clone().oneshot(req).await.unwrap().status(),
819 StatusCode::OK
820 );
821 }
822 let req = axum::http::Request::builder()
823 .uri("/def.narinfo")
824 .body(Body::empty())
825 .unwrap();
826 let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
827
828 let req = axum::http::Request::builder()
829 .method("PUT")
830 .uri("/def.narinfo")
831 .body(Body::from(signed.clone()))
832 .unwrap();
833 assert_eq!(
834 app.clone().oneshot(req).await.unwrap().status(),
835 StatusCode::OK
836 );
837
838 let req = axum::http::Request::builder()
839 .uri("/def.narinfo")
840 .body(Body::empty())
841 .unwrap();
842 let final_text = body_string(app.oneshot(req).await.unwrap()).await;
843 let parsed = NarInfo::parse(&final_text).unwrap();
844 assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
845 }
846
847 #[derive(Default)]
852 struct BrokenStorage {
853 nar_refs: crate::MemNarRefIndex,
857 }
858
859 #[async_trait::async_trait]
860 impl StorageBackend for BrokenStorage {
861 async fn get_narinfo(&self, _hash: &str) -> Result<Option<String>, crate::CacheError> {
862 Err(crate::CacheError::Io(std::io::Error::other(
863 "postgres: error returned from database: relation \"sui_cache_narinfo\" does not exist",
864 )))
865 }
866 async fn put_narinfo_record(
867 &self,
868 _hash: &str,
869 _content: &str,
870 ) -> Result<(), crate::CacheError> {
871 Err(crate::CacheError::Io(std::io::Error::other(
872 "postgres: down",
873 )))
874 }
875 async fn delete_narinfo_record(&self, _hash: &str) -> Result<(), crate::CacheError> {
876 Err(crate::CacheError::Io(std::io::Error::other(
877 "postgres: down",
878 )))
879 }
880 async fn delete_nar_record(&self, _nar_path: &str) -> Result<(), crate::CacheError> {
881 Err(crate::CacheError::Io(std::io::Error::other(
882 "postgres: down",
883 )))
884 }
885 fn nar_ref_index(&self) -> &dyn crate::NarRefIndex {
886 &self.nar_refs
887 }
888 async fn get_nar(&self, _path: &str) -> Result<Option<Vec<u8>>, crate::CacheError> {
889 Err(crate::CacheError::Io(std::io::Error::other(
890 "postgres: error returned from database: relation \"sui_cache_nar\" does not exist",
891 )))
892 }
893 async fn put_nar(&self, _path: &str, _data: &[u8]) -> Result<(), crate::CacheError> {
894 Err(crate::CacheError::Io(std::io::Error::other(
895 "postgres: down",
896 )))
897 }
898 fn nar_residency(&self) -> crate::NarResidency {
902 crate::NarResidency::WholeValue
903 }
904
905 async fn list_narinfos(&self) -> Result<Vec<String>, crate::CacheError> {
906 Err(crate::CacheError::Io(std::io::Error::other(
907 "postgres: down",
908 )))
909 }
910 }
911
912 fn broken_app() -> Router {
913 let storage: Arc<dyn StorageBackend> = Arc::new(BrokenStorage::default());
914 build_router(AppState {
915 storage,
916 config: CacheConfig::default(),
917 signer: None,
918 })
919 }
920
921 #[tokio::test]
922 async fn broken_backend_narinfo_read_is_a_miss_not_a_server_error() {
923 let resp = broken_app()
927 .oneshot(
928 axum::http::Request::builder()
929 .uri("/abc.narinfo")
930 .body(Body::empty())
931 .unwrap(),
932 )
933 .await
934 .unwrap();
935 assert_eq!(
936 resp.status(),
937 StatusCode::NOT_FOUND,
938 "a backend that cannot answer must report a MISS, never a 500",
939 );
940 assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
941 }
942
943 #[tokio::test]
944 async fn broken_backend_nar_read_is_a_miss_not_a_server_error() {
945 let resp = broken_app()
946 .oneshot(
947 axum::http::Request::builder()
948 .uri("/nar/abc.nar.xz")
949 .body(Body::empty())
950 .unwrap(),
951 )
952 .await
953 .unwrap();
954 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
955 }
956
957 #[tokio::test]
958 async fn cache_info_still_answers_while_the_backend_is_broken() {
959 let resp = broken_app()
962 .oneshot(
963 axum::http::Request::builder()
964 .uri("/nix-cache-info")
965 .body(Body::empty())
966 .unwrap(),
967 )
968 .await
969 .unwrap();
970 assert_eq!(resp.status(), StatusCode::OK);
971 }
972
973 #[tokio::test]
974 async fn a_totally_failed_write_still_reports_failure() {
975 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";
979 let resp = broken_app()
980 .oneshot(
981 axum::http::Request::builder()
982 .method("PUT")
983 .uri("/abc.narinfo")
984 .body(Body::from(narinfo))
985 .unwrap(),
986 )
987 .await
988 .unwrap();
989 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
990 }
991
992 #[tokio::test]
999 async fn put_narinfo_with_a_traversal_url_is_rejected() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let app = test_app(dir.path());
1002 let evil = "StorePath: /nix/store/abc-hello\nURL: ../../escape.nar\nCompression: xz\n\
1003 FileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
1004
1005 let req = axum::http::Request::builder()
1006 .method("PUT")
1007 .uri("/abc.narinfo")
1008 .body(Body::from(evil))
1009 .unwrap();
1010 let resp = app.clone().oneshot(req).await.unwrap();
1011 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1012
1013 let get = axum::http::Request::builder()
1014 .uri("/abc.narinfo")
1015 .body(Body::empty())
1016 .unwrap();
1017 assert_eq!(
1018 app.oneshot(get).await.unwrap().status(),
1019 StatusCode::NOT_FOUND,
1020 "a rejected narinfo must not have been stored",
1021 );
1022 }
1023
1024 #[tokio::test]
1025 async fn put_narinfo_bad_utf8() {
1026 let dir = tempfile::tempdir().unwrap();
1027 let app = test_app(dir.path());
1028
1029 let req = axum::http::Request::builder()
1030 .method("PUT")
1031 .uri("/bad.narinfo")
1032 .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
1033 .unwrap();
1034
1035 let resp = app.oneshot(req).await.unwrap();
1036 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1037 }
1038}