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)) => (
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) => {
177 tracing::error!(
178 hash = %hash,
179 error = %e,
180 "get_narinfo: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
181 client rebuilds instead of aborting; the backend needs attention",
182 );
183 StatusCode::NOT_FOUND.into_response()
184 }
185 }
186}
187
188async fn put_narinfo(
190 State(state): State<AppState>,
191 Path(hash_narinfo): Path<String>,
192 body: Bytes,
193) -> impl IntoResponse {
194 let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
195 return StatusCode::BAD_REQUEST.into_response();
196 };
197
198 let content = match String::from_utf8(body.to_vec()) {
199 Ok(s) => s,
200 Err(_) => return StatusCode::BAD_REQUEST.into_response(),
201 };
202
203 if let Some(url) = crate::advertised_url_line(&content) {
209 if !crate::is_addressable_nar_path(url) {
210 tracing::warn!(
211 hash = %hash, url = %url,
212 "put_narinfo: refusing a narinfo whose URL is not an addressable relative path",
213 );
214 return StatusCode::BAD_REQUEST.into_response();
215 }
216 }
217
218 let to_store = match &state.signer {
221 Some(signer) => match sign_narinfo_text(signer, &content) {
222 Ok(signed) => signed,
223 Err(e) => {
224 tracing::error!("put_narinfo signing error: {e}");
225 return StatusCode::BAD_REQUEST.into_response();
226 }
227 },
228 None => content,
229 };
230
231 match state.storage.put_narinfo(hash, &to_store).await {
247 Ok(()) => StatusCode::OK.into_response(),
248 Err(e) => {
249 tracing::error!(
250 hash = %hash,
251 error = %e,
252 "put_narinfo: EVERY durable tier rejected the write — nothing stored; \
253 reporting failure rather than falsely acknowledging the upload",
254 );
255 StatusCode::INTERNAL_SERVER_ERROR.into_response()
256 }
257 }
258}
259
260fn nar_content_type(path: &str) -> &'static str {
262 if path.ends_with(".xz") {
263 "application/x-xz"
264 } else if path.ends_with(".zstd") || path.ends_with(".zst") {
265 "application/zstd"
266 } else {
267 "application/x-nix-nar"
268 }
269}
270
271async fn get_nar(
283 State(state): State<AppState>,
284 Path(path): Path<String>,
285) -> impl IntoResponse {
286 let nar_path = format!("nar/{path}");
287 match state.storage.get_nar_stream(&nar_path).await {
288 Ok(Some(stream)) => {
289 let mut headers = HeaderMap::new();
290 headers.insert("content-type", nar_content_type(&path).parse().unwrap());
291 (StatusCode::OK, headers, Body::from_stream(stream)).into_response()
292 }
293 Ok(None) => StatusCode::NOT_FOUND.into_response(),
294 Err(e) => {
297 tracing::error!(
298 nar_path = %nar_path,
299 error = %e,
300 "get_nar: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
301 client rebuilds instead of aborting; the backend needs attention",
302 );
303 StatusCode::NOT_FOUND.into_response()
304 }
305 }
306}
307
308async fn put_nar(
321 State(state): State<AppState>,
322 Path(path): Path<String>,
323 body: Body,
324) -> impl IntoResponse {
325 let nar_path = format!("nar/{path}");
326
327 let src = match sui_castore::spool_or_buffer(
328 body.into_data_stream(),
329 &std::env::temp_dir(),
330 sui_castore::DEFAULT_INGEST_MEMORY_CAP,
331 )
332 .await
333 {
334 Ok(src) => src,
335 Err(e) => {
336 tracing::error!(
337 nar_path = %nar_path,
338 error = %e,
339 "put_nar: could not stage the upload (spool write failed, or it exceeded \
340 the in-memory fallback cap) — nothing stored",
341 );
342 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
343 }
344 };
345
346 match state.storage.put_nar_stream(&nar_path, src.as_ref()).await {
349 Ok(()) => StatusCode::OK.into_response(),
350 Err(e) => {
351 tracing::error!(
352 nar_path = %nar_path,
353 error = %e,
354 "put_nar: EVERY durable tier rejected the write — nothing stored; \
355 reporting failure rather than falsely acknowledging the upload",
356 );
357 StatusCode::INTERNAL_SERVER_ERROR.into_response()
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::config::BackendConfig;
366 use crate::LocalStorage;
367 use axum::body::Body;
368 use http_body_util::BodyExt;
369 use tower::ServiceExt;
370
371 fn test_app(dir: &std::path::Path) -> Router {
372 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
373 let config = CacheConfig {
374 listen: "127.0.0.1:0".to_string(),
375 backend: BackendConfig::Local {
376 path: dir.to_path_buf(),
377 },
378 priority: 40,
379 want_mass_query: true,
380 store_dir: "/nix/store".to_string(),
381 signing_key: None,
382 require_sigs: false,
383 };
384 build_router(AppState { storage, config, signer: None })
385 }
386
387 async fn body_string(response: axum::http::Response<Body>) -> String {
388 let body = response.into_body();
389 let bytes = body.collect().await.unwrap().to_bytes();
390 String::from_utf8(bytes.to_vec()).unwrap()
391 }
392
393 async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
394 let body = response.into_body();
395 body.collect().await.unwrap().to_bytes().to_vec()
396 }
397
398 #[tokio::test]
399 async fn cache_info_endpoint() {
400 let dir = tempfile::tempdir().unwrap();
401 let app = test_app(dir.path());
402
403 let req = axum::http::Request::builder()
404 .uri("/nix-cache-info")
405 .body(Body::empty())
406 .unwrap();
407
408 let resp = app.oneshot(req).await.unwrap();
409 assert_eq!(resp.status(), StatusCode::OK);
410
411 let body = body_string(resp).await;
412 assert!(body.contains("StoreDir: /nix/store"));
413 assert!(body.contains("WantMassQuery: 1"));
414 assert!(body.contains("Priority: 40"));
415 }
416
417 #[tokio::test]
418 async fn get_narinfo_not_found() {
419 let dir = tempfile::tempdir().unwrap();
420 let app = test_app(dir.path());
421
422 let req = axum::http::Request::builder()
423 .uri("/abc.narinfo")
424 .body(Body::empty())
425 .unwrap();
426
427 let resp = app.oneshot(req).await.unwrap();
428 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
429 }
430
431 #[tokio::test]
432 async fn put_then_get_narinfo() {
433 let dir = tempfile::tempdir().unwrap();
434 let app = test_app(dir.path());
435
436 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";
437
438 let req = axum::http::Request::builder()
440 .method("PUT")
441 .uri("/abc.narinfo")
442 .body(Body::from(narinfo.to_string()))
443 .unwrap();
444
445 let resp = app.clone().oneshot(req).await.unwrap();
446 assert_eq!(resp.status(), StatusCode::OK);
447
448 let req = axum::http::Request::builder()
450 .uri("/abc.narinfo")
451 .body(Body::empty())
452 .unwrap();
453
454 let resp = app.oneshot(req).await.unwrap();
455 assert_eq!(resp.status(), StatusCode::OK);
456
457 let body = body_string(resp).await;
458 assert!(body.contains("StorePath: /nix/store/abc-hello"));
459 }
460
461 #[tokio::test]
462 async fn get_nar_not_found() {
463 let dir = tempfile::tempdir().unwrap();
464 let app = test_app(dir.path());
465
466 let req = axum::http::Request::builder()
467 .uri("/nar/abc.nar.xz")
468 .body(Body::empty())
469 .unwrap();
470
471 let resp = app.oneshot(req).await.unwrap();
472 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
473 }
474
475 #[tokio::test]
476 async fn put_then_get_nar() {
477 let dir = tempfile::tempdir().unwrap();
478 let app = test_app(dir.path());
479
480 let nar_data = b"fake nar blob data";
481
482 let req = axum::http::Request::builder()
484 .method("PUT")
485 .uri("/nar/xyz.nar.xz")
486 .body(Body::from(nar_data.to_vec()))
487 .unwrap();
488
489 let resp = app.clone().oneshot(req).await.unwrap();
490 assert_eq!(resp.status(), StatusCode::OK);
491
492 let req = axum::http::Request::builder()
494 .uri("/nar/xyz.nar.xz")
495 .body(Body::empty())
496 .unwrap();
497
498 let resp = app.oneshot(req).await.unwrap();
499 assert_eq!(resp.status(), StatusCode::OK);
500
501 let body = body_bytes(resp).await;
502 assert_eq!(body, nar_data);
503 }
504
505 #[tokio::test]
506 async fn get_narinfo_content_type() {
507 let dir = tempfile::tempdir().unwrap();
508 let storage = LocalStorage::new(dir.path());
509 storage
510 .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")
511 .await
512 .unwrap();
513
514 let app = test_app(dir.path());
515 let req = axum::http::Request::builder()
516 .uri("/ct.narinfo")
517 .body(Body::empty())
518 .unwrap();
519
520 let resp = app.oneshot(req).await.unwrap();
521 assert_eq!(resp.status(), StatusCode::OK);
522 assert_eq!(
523 resp.headers().get("content-type").unwrap(),
524 "text/x-nix-narinfo"
525 );
526 }
527
528 #[tokio::test]
529 async fn get_nar_xz_content_type() {
530 let dir = tempfile::tempdir().unwrap();
531 let storage = LocalStorage::new(dir.path());
532 storage
533 .put_nar("nar/test.nar.xz", b"data")
534 .await
535 .unwrap();
536
537 let app = test_app(dir.path());
538 let req = axum::http::Request::builder()
539 .uri("/nar/test.nar.xz")
540 .body(Body::empty())
541 .unwrap();
542
543 let resp = app.oneshot(req).await.unwrap();
544 assert_eq!(resp.status(), StatusCode::OK);
545 assert_eq!(
546 resp.headers().get("content-type").unwrap(),
547 "application/x-xz"
548 );
549 }
550
551 #[tokio::test]
552 async fn cache_info_custom_priority() {
553 let dir = tempfile::tempdir().unwrap();
554 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
555 let config = CacheConfig {
556 priority: 10,
557 want_mass_query: false,
558 ..CacheConfig::default()
559 };
560 let app = build_router(AppState {
561 storage,
562 config,
563 signer: None,
564 });
565
566 let req = axum::http::Request::builder()
567 .uri("/nix-cache-info")
568 .body(Body::empty())
569 .unwrap();
570
571 let resp = app.oneshot(req).await.unwrap();
572 let body = body_string(resp).await;
573 assert!(body.contains("Priority: 10"));
574 assert!(body.contains("WantMassQuery: 0"));
575 }
576
577 #[tokio::test]
582 async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
583 use crate::signing::{verify_narinfo_signature, CacheSigner};
584
585 let dir = tempfile::tempdir().unwrap();
586 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
587 let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
588 let pk = signer.public_key_string();
589 let config = CacheConfig {
590 listen: "127.0.0.1:0".to_string(),
591 backend: BackendConfig::Local { path: dir.path().to_path_buf() },
592 priority: 40,
593 want_mass_query: true,
594 store_dir: "/nix/store".to_string(),
595 signing_key: None,
596 require_sigs: false,
597 };
598 let app = build_router(AppState { storage, config, signer: Some(signer.clone()) });
599
600 let narinfo = "StorePath: /nix/store/abc-hello\n\
602 URL: nar/abc.nar.xz\n\
603 Compression: xz\n\
604 FileHash: sha256:aaa\n\
605 FileSize: 100\n\
606 NarHash: sha256:bbb\n\
607 NarSize: 200\n\
608 References: zzz-b aaa-a\n";
609
610 let req = axum::http::Request::builder()
611 .method("PUT")
612 .uri("/abc.narinfo")
613 .body(Body::from(narinfo))
614 .unwrap();
615 let resp = app.clone().oneshot(req).await.unwrap();
616 assert_eq!(resp.status(), StatusCode::OK);
617
618 let req = axum::http::Request::builder()
619 .uri("/abc.narinfo")
620 .body(Body::empty())
621 .unwrap();
622 let resp = app.oneshot(req).await.unwrap();
623 assert_eq!(resp.status(), StatusCode::OK);
624 let body = body_string(resp).await;
625
626 let parsed = NarInfo::parse(&body).unwrap();
627 assert_eq!(parsed.signatures.len(), 1, "GET must return a signed narinfo");
628 assert!(parsed.signatures[0].starts_with("ingest-key:"));
629 assert!(
630 verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
631 "the ingest signature must verify against the signer public key",
632 );
633 }
634
635 #[tokio::test]
637 async fn put_narinfo_is_idempotent_under_our_key() {
638 use crate::signing::CacheSigner;
639
640 let dir = tempfile::tempdir().unwrap();
641 let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
642 let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
643 let config = CacheConfig {
644 listen: "127.0.0.1:0".to_string(),
645 backend: BackendConfig::Local { path: dir.path().to_path_buf() },
646 priority: 40,
647 want_mass_query: true,
648 store_dir: "/nix/store".to_string(),
649 signing_key: None,
650 require_sigs: false,
651 };
652 let app = build_router(AppState { storage, config, signer: Some(signer) });
653
654 let narinfo = "StorePath: /nix/store/def-x\n\
655 URL: nar/def.nar.xz\n\
656 Compression: xz\n\
657 FileHash: sha256:a\n\
658 FileSize: 1\n\
659 NarHash: sha256:b\n\
660 NarSize: 2\n\
661 References: \n";
662
663 for uri in ["/def.narinfo"] {
665 let req = axum::http::Request::builder()
666 .method("PUT").uri(uri).body(Body::from(narinfo)).unwrap();
667 assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
668 }
669 let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
670 let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
671
672 let req = axum::http::Request::builder()
673 .method("PUT").uri("/def.narinfo").body(Body::from(signed.clone())).unwrap();
674 assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
675
676 let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
677 let final_text = body_string(app.oneshot(req).await.unwrap()).await;
678 let parsed = NarInfo::parse(&final_text).unwrap();
679 assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
680 }
681
682 #[derive(Default)]
687 struct BrokenStorage {
688 nar_refs: crate::MemNarRefIndex,
692 }
693
694 #[async_trait::async_trait]
695 impl StorageBackend for BrokenStorage {
696 async fn get_narinfo(&self, _hash: &str) -> Result<Option<String>, crate::CacheError> {
697 Err(crate::CacheError::Io(std::io::Error::other(
698 "postgres: error returned from database: relation \"sui_cache_narinfo\" does not exist",
699 )))
700 }
701 async fn put_narinfo_record(
702 &self,
703 _hash: &str,
704 _content: &str,
705 ) -> Result<(), crate::CacheError> {
706 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
707 }
708 async fn delete_narinfo_record(&self, _hash: &str) -> Result<(), crate::CacheError> {
709 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
710 }
711 async fn delete_nar_record(&self, _nar_path: &str) -> Result<(), crate::CacheError> {
712 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
713 }
714 fn nar_ref_index(&self) -> &dyn crate::NarRefIndex {
715 &self.nar_refs
716 }
717 async fn get_nar(&self, _path: &str) -> Result<Option<Vec<u8>>, crate::CacheError> {
718 Err(crate::CacheError::Io(std::io::Error::other(
719 "postgres: error returned from database: relation \"sui_cache_nar\" does not exist",
720 )))
721 }
722 async fn put_nar(&self, _path: &str, _data: &[u8]) -> Result<(), crate::CacheError> {
723 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
724 }
725 fn nar_residency(&self) -> crate::NarResidency {
729 crate::NarResidency::WholeValue
730 }
731
732 async fn list_narinfos(&self) -> Result<Vec<String>, crate::CacheError> {
733 Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
734 }
735 }
736
737 fn broken_app() -> Router {
738 let storage: Arc<dyn StorageBackend> = Arc::new(BrokenStorage::default());
739 build_router(AppState {
740 storage,
741 config: CacheConfig::default(),
742 signer: None,
743 })
744 }
745
746 #[tokio::test]
747 async fn broken_backend_narinfo_read_is_a_miss_not_a_server_error() {
748 let resp = broken_app()
752 .oneshot(
753 axum::http::Request::builder()
754 .uri("/abc.narinfo")
755 .body(Body::empty())
756 .unwrap(),
757 )
758 .await
759 .unwrap();
760 assert_eq!(
761 resp.status(),
762 StatusCode::NOT_FOUND,
763 "a backend that cannot answer must report a MISS, never a 500",
764 );
765 assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
766 }
767
768 #[tokio::test]
769 async fn broken_backend_nar_read_is_a_miss_not_a_server_error() {
770 let resp = broken_app()
771 .oneshot(
772 axum::http::Request::builder()
773 .uri("/nar/abc.nar.xz")
774 .body(Body::empty())
775 .unwrap(),
776 )
777 .await
778 .unwrap();
779 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
780 }
781
782 #[tokio::test]
783 async fn cache_info_still_answers_while_the_backend_is_broken() {
784 let resp = broken_app()
787 .oneshot(
788 axum::http::Request::builder()
789 .uri("/nix-cache-info")
790 .body(Body::empty())
791 .unwrap(),
792 )
793 .await
794 .unwrap();
795 assert_eq!(resp.status(), StatusCode::OK);
796 }
797
798 #[tokio::test]
799 async fn a_totally_failed_write_still_reports_failure() {
800 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";
804 let resp = broken_app()
805 .oneshot(
806 axum::http::Request::builder()
807 .method("PUT")
808 .uri("/abc.narinfo")
809 .body(Body::from(narinfo))
810 .unwrap(),
811 )
812 .await
813 .unwrap();
814 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
815 }
816
817 #[tokio::test]
824 async fn put_narinfo_with_a_traversal_url_is_rejected() {
825 let dir = tempfile::tempdir().unwrap();
826 let app = test_app(dir.path());
827 let evil = "StorePath: /nix/store/abc-hello\nURL: ../../escape.nar\nCompression: xz\n\
828 FileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
829
830 let req = axum::http::Request::builder()
831 .method("PUT")
832 .uri("/abc.narinfo")
833 .body(Body::from(evil))
834 .unwrap();
835 let resp = app.clone().oneshot(req).await.unwrap();
836 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
837
838 let get = axum::http::Request::builder()
839 .uri("/abc.narinfo")
840 .body(Body::empty())
841 .unwrap();
842 assert_eq!(
843 app.oneshot(get).await.unwrap().status(),
844 StatusCode::NOT_FOUND,
845 "a rejected narinfo must not have been stored",
846 );
847 }
848
849 #[tokio::test]
850 async fn put_narinfo_bad_utf8() {
851 let dir = tempfile::tempdir().unwrap();
852 let app = test_app(dir.path());
853
854 let req = axum::http::Request::builder()
855 .method("PUT")
856 .uri("/bad.narinfo")
857 .body(Body::from(vec![0xFF, 0xFE, 0xFD]))
858 .unwrap();
859
860 let resp = app.oneshot(req).await.unwrap();
861 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
862 }
863}