1use std::sync::Arc;
18
19use crate::keys;
20use crate::object_store::azure::AzureStore;
21use crate::object_store::s3::S3Store;
22use crate::object_store::{BoxError, ObjectStore, ObjectStoreError};
23use crate::url::{RemoteUrl, StorageEngine};
24
25pub use crate::url::BackendKind;
26
27#[derive(Debug, thiserror::Error)]
67pub enum BackendError {
68 #[error("{} not found {name}", container_word(*kind))]
71 BucketNotFound {
72 kind: BackendKind,
74 name: String,
76 },
77
78 #[error("user not authorized to perform {action} on {} {name}", container_word(*kind))]
82 NotAuthorized {
83 kind: BackendKind,
85 action: String,
87 name: String,
89 },
90
91 #[error("connection error: {source}")]
99 Network {
100 #[source]
102 source: BoxError,
103 },
104
105 #[error("invalid credentials {source}")]
108 InvalidCredentials {
109 #[source]
111 source: ObjectStoreError,
112 },
113
114 #[error(
120 "{} uses unknown storage engine `{stored}`; \
121 this client supports {}",
122 container_word(*kind),
123 StorageEngine::supported_list_str()
124 )]
125 UnknownStoredEngine {
126 kind: BackendKind,
128 stored: String,
130 },
131
132 #[error(
135 "URL specifies engine `{url_engine}` but this {} uses `{stored_engine}`; \
136 remove the `?engine=` parameter from the remote URL",
137 container_word(*kind)
138 )]
139 EngineMismatch {
140 kind: BackendKind,
142 url_engine: StorageEngine,
144 stored_engine: StorageEngine,
146 },
147}
148
149const fn container_word(kind: BackendKind) -> &'static str {
150 match kind {
151 BackendKind::S3 => "bucket",
152 BackendKind::Azure => "container",
153 }
154}
155
156#[must_use]
174pub fn fatal_message(err: &BackendError) -> String {
175 let mut msg = format!("fatal: {err}");
176 super::append_source_chain(&mut msg, err);
177 msg
178}
179
180fn classify(
184 kind: BackendKind,
185 name: &str,
186 action: &'static str,
187 err: ObjectStoreError,
188) -> BackendError {
189 match err {
190 ObjectStoreError::NotFound(_) => BackendError::BucketNotFound {
191 kind,
192 name: name.to_owned(),
193 },
194 ObjectStoreError::AccessDenied(_) => BackendError::NotAuthorized {
195 kind,
196 action: action.to_owned(),
197 name: name.to_owned(),
198 },
199 ObjectStoreError::Network(inner) => BackendError::Network { source: inner },
200 other => BackendError::InvalidCredentials { source: other },
201 }
202}
203
204pub async fn validate_format(
234 kind: BackendKind,
235 store: &dyn ObjectStore,
236 prefix: &str,
237 url_engine: Option<StorageEngine>,
238) -> Result<StorageEngine, BackendError> {
239 let format_key = keys::join(Some(prefix), "FORMAT");
240 let bytes = match store.get_bytes(&format_key).await {
241 Ok(b) => b,
242 Err(ObjectStoreError::NotFound(_)) => {
246 return Ok(url_engine.unwrap_or(StorageEngine::Bundle));
247 }
248 Err(ObjectStoreError::Network(inner)) => {
249 return Err(BackendError::Network { source: inner });
250 }
251 Err(e) => return Err(BackendError::InvalidCredentials { source: e }),
252 };
253
254 let stored_name =
260 std::str::from_utf8(&bytes).map_err(|_| BackendError::InvalidCredentials {
261 source: ObjectStoreError::Other(Box::new(std::io::Error::other(
262 "FORMAT key contains non-UTF-8 bytes",
263 ))),
264 })?;
265 let stored_name = stored_name.trim();
266
267 let stored_engine =
268 StorageEngine::from_name(stored_name).ok_or_else(|| BackendError::UnknownStoredEngine {
269 kind,
270 stored: stored_name.to_owned(),
271 })?;
272
273 if let Some(url_engine) = url_engine
274 && url_engine != stored_engine
275 {
276 return Err(BackendError::EngineMismatch {
277 kind,
278 url_engine,
279 stored_engine,
280 });
281 }
282
283 Ok(stored_engine)
284}
285
286pub async fn build(
302 remote: &RemoteUrl,
303) -> Result<(Arc<dyn ObjectStore>, StorageEngine), BackendError> {
304 let prefix = remote.prefix().unwrap_or_default();
305 let url_engine = remote.flags().engine;
306 let store: Arc<dyn ObjectStore> = match remote {
307 RemoteUrl::S3 { bucket, .. } => {
308 let store = S3Store::from_remote_url(remote)
309 .await
310 .map_err(|e| classify(BackendKind::S3, bucket, "ListObjectsV2", e))?;
311 store
312 .probe(prefix)
313 .await
314 .map_err(|e| classify(BackendKind::S3, bucket, "ListObjectsV2", e))?;
315 Arc::new(store)
316 }
317 RemoteUrl::Azure { container, .. } => {
318 let store = AzureStore::from_remote_url(remote)
319 .await
320 .map_err(|e| classify(BackendKind::Azure, container, "ListBlobs", e))?;
321 store
322 .probe(prefix)
323 .await
324 .map_err(|e| classify(BackendKind::Azure, container, "ListBlobs", e))?;
325 Arc::new(store)
326 }
327 };
328 let engine = validate_format(remote.kind(), store.as_ref(), prefix, url_engine).await?;
329 Ok((store, engine))
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::object_store::mock::MockStore;
336 use bytes::Bytes;
337
338 fn boxed(message: &str) -> crate::object_store::BoxError {
339 Box::new(std::io::Error::other(message.to_string()))
340 }
341
342 #[test]
343 fn classify_maps_not_found_to_bucket_not_found_for_s3() {
344 let err = classify(
345 BackendKind::S3,
346 "mybucket",
347 "ListObjectsV2",
348 ObjectStoreError::NotFound("mybucket".into()),
349 );
350 assert!(matches!(
351 err,
352 BackendError::BucketNotFound {
353 kind: BackendKind::S3,
354 ref name
355 } if name == "mybucket"
356 ));
357 }
358
359 #[test]
360 fn classify_maps_not_found_to_bucket_not_found_for_azure() {
361 let err = classify(
362 BackendKind::Azure,
363 "mycontainer",
364 "ListBlobs",
365 ObjectStoreError::NotFound("mycontainer".into()),
366 );
367 assert!(matches!(
368 err,
369 BackendError::BucketNotFound {
370 kind: BackendKind::Azure,
371 ref name
372 } if name == "mycontainer"
373 ));
374 }
375
376 #[test]
377 fn classify_maps_access_denied_to_not_authorized() {
378 let err = classify(
379 BackendKind::S3,
380 "mybucket",
381 "ListObjectsV2",
382 ObjectStoreError::AccessDenied("mybucket".into()),
383 );
384 let BackendError::NotAuthorized { kind, action, name } = err else {
385 panic!("expected NotAuthorized");
386 };
387 assert_eq!(kind, BackendKind::S3);
388 assert_eq!(action, "ListObjectsV2");
389 assert_eq!(name, "mybucket");
390 }
391
392 #[test]
393 fn classify_maps_network_to_network_error() {
394 let err = classify(
395 BackendKind::S3,
396 "mybucket",
397 "ListObjectsV2",
398 ObjectStoreError::Network(boxed("dns failure")),
399 );
400 let BackendError::Network { source } = err else {
401 panic!("expected Network, got {err:?}");
402 };
403 assert_eq!(source.to_string(), "dns failure");
406 }
407
408 #[test]
409 fn classify_maps_other_to_invalid_credentials() {
410 let err = classify(
411 BackendKind::Azure,
412 "mycontainer",
413 "ListBlobs",
414 ObjectStoreError::Other(boxed("missing AZ_CRED env var")),
415 );
416 let BackendError::InvalidCredentials { source } = err else {
417 panic!("expected InvalidCredentials");
418 };
419 assert_eq!(source.to_string(), "missing AZ_CRED env var");
420 }
421
422 #[test]
423 fn classify_maps_precondition_failed_to_invalid_credentials() {
424 let err = classify(
427 BackendKind::S3,
428 "mybucket",
429 "ListObjectsV2",
430 ObjectStoreError::PreconditionFailed("mybucket".into()),
431 );
432 assert!(matches!(err, BackendError::InvalidCredentials { .. }));
433 }
434
435 #[test]
436 fn fatal_message_s3_bucket_not_found_renders_expected_wording() {
437 let err = BackendError::BucketNotFound {
438 kind: BackendKind::S3,
439 name: "mybucket".into(),
440 };
441 assert_eq!(fatal_message(&err), "fatal: bucket not found mybucket");
442 }
443
444 #[test]
445 fn fatal_message_azure_container_not_found() {
446 let err = BackendError::BucketNotFound {
447 kind: BackendKind::Azure,
448 name: "mycontainer".into(),
449 };
450 assert_eq!(
451 fatal_message(&err),
452 "fatal: container not found mycontainer"
453 );
454 }
455
456 #[test]
457 fn fatal_message_not_authorized_renders_expected_wording() {
458 let err = BackendError::NotAuthorized {
459 kind: BackendKind::S3,
460 action: "ListObjectsV2".into(),
461 name: "mybucket".into(),
462 };
463 assert_eq!(
464 fatal_message(&err),
465 "fatal: user not authorized to perform ListObjectsV2 on bucket mybucket"
466 );
467 }
468
469 #[test]
470 fn fatal_message_azure_not_authorized_uses_container_word() {
471 let err = BackendError::NotAuthorized {
476 kind: BackendKind::Azure,
477 action: "ListBlobs".into(),
478 name: "mycontainer".into(),
479 };
480 let fatal = fatal_message(&err);
481 assert_eq!(
482 fatal,
483 "fatal: user not authorized to perform ListBlobs on container mycontainer"
484 );
485 assert!(
486 !fatal.contains("bucket"),
487 "Azure path leaked 'bucket': {fatal}"
488 );
489 }
490
491 #[test]
492 fn fatal_message_invalid_credentials_appends_source() {
493 let err = BackendError::InvalidCredentials {
494 source: ObjectStoreError::Other(boxed("credential acquisition failed")),
495 };
496 assert_eq!(
497 fatal_message(&err),
498 "fatal: invalid credentials credential acquisition failed"
499 );
500 }
501
502 #[test]
503 fn fatal_message_network_includes_root_cause() {
504 let err = BackendError::Network {
508 source: boxed("dns lookup failed"),
509 };
510 assert_eq!(
511 fatal_message(&err),
512 "fatal: connection error: dns lookup failed"
513 );
514 }
515
516 #[test]
517 fn fatal_message_walks_full_chain() {
518 use std::error::Error as StdError;
519 use std::fmt;
520
521 #[derive(Debug)]
525 struct WrappedError {
526 msg: &'static str,
527 inner: Box<dyn StdError + Send + Sync + 'static>,
528 }
529 impl fmt::Display for WrappedError {
530 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
531 f.write_str(self.msg)
532 }
533 }
534 impl StdError for WrappedError {
535 fn source(&self) -> Option<&(dyn StdError + 'static)> {
536 Some(self.inner.as_ref())
537 }
538 }
539
540 let err = BackendError::Network {
541 source: Box::new(WrappedError {
542 msg: "dispatch failure",
543 inner: boxed("connection refused"),
544 }),
545 };
546 assert_eq!(
547 fatal_message(&err),
548 "fatal: connection error: dispatch failure: connection refused"
549 );
550 }
551
552 #[test]
553 fn fatal_message_engine_mismatch() {
554 let url_engine = StorageEngine::Packchain;
561 let stored_engine = StorageEngine::Bundle;
562 let err = BackendError::EngineMismatch {
563 kind: BackendKind::S3,
564 url_engine,
565 stored_engine,
566 };
567 let expected = "\
568 fatal: URL specifies engine `packchain` but this bucket uses `bundle`; \
569 remove the `?engine=` parameter from the remote URL";
570 assert_eq!(fatal_message(&err), expected);
571 }
572
573 #[test]
574 fn fatal_message_azure_engine_mismatch_uses_container_word() {
575 let err = BackendError::EngineMismatch {
579 kind: BackendKind::Azure,
580 url_engine: StorageEngine::Packchain,
581 stored_engine: StorageEngine::Bundle,
582 };
583 let fatal = fatal_message(&err);
584 let expected = "\
585 fatal: URL specifies engine `packchain` but this container uses `bundle`; \
586 remove the `?engine=` parameter from the remote URL";
587 assert_eq!(fatal, expected);
588 assert!(
589 !fatal.contains("bucket"),
590 "Azure path leaked 'bucket': {fatal}"
591 );
592 }
593
594 #[tokio::test]
597 async fn validate_format_passes_when_key_absent() {
598 let store = MockStore::new();
599 assert_eq!(
602 validate_format(BackendKind::S3, &store, "", None)
603 .await
604 .unwrap(),
605 StorageEngine::Bundle,
606 );
607 assert_eq!(
608 validate_format(BackendKind::S3, &store, "my-repo", None)
609 .await
610 .unwrap(),
611 StorageEngine::Bundle,
612 );
613 assert_eq!(
615 validate_format(BackendKind::S3, &store, "", Some(StorageEngine::Packchain))
616 .await
617 .unwrap(),
618 StorageEngine::Packchain,
619 );
620 }
621
622 #[tokio::test]
623 async fn validate_format_passes_when_stored_engine_matches_url() {
624 let store = MockStore::new();
625 store.insert("FORMAT", Bytes::from_static(b"bundle"));
626 assert_eq!(
627 validate_format(BackendKind::S3, &store, "", Some(StorageEngine::Bundle))
628 .await
629 .unwrap(),
630 StorageEngine::Bundle,
631 );
632 }
633
634 #[tokio::test]
635 async fn validate_format_passes_when_no_url_engine_declared() {
636 let store = MockStore::new();
637 store.insert("FORMAT", Bytes::from_static(b"bundle"));
638 assert_eq!(
640 validate_format(BackendKind::S3, &store, "", None)
641 .await
642 .unwrap(),
643 StorageEngine::Bundle,
644 );
645 }
646
647 #[tokio::test]
648 async fn validate_format_passes_when_key_has_trailing_newline() {
649 let store = MockStore::new();
650 store.insert("FORMAT", Bytes::from_static(b"bundle\n"));
651 assert_eq!(
652 validate_format(BackendKind::S3, &store, "", Some(StorageEngine::Bundle))
653 .await
654 .unwrap(),
655 StorageEngine::Bundle,
656 );
657 }
658
659 #[tokio::test]
660 async fn validate_format_rejects_url_packchain_against_stored_bundle() {
661 let store = MockStore::new();
665 store.insert("FORMAT", Bytes::from_static(b"bundle"));
666 let err = validate_format(BackendKind::S3, &store, "", Some(StorageEngine::Packchain))
667 .await
668 .unwrap_err();
669 assert!(
670 matches!(
671 err,
672 BackendError::EngineMismatch {
673 kind: BackendKind::S3,
674 url_engine: StorageEngine::Packchain,
675 stored_engine: StorageEngine::Bundle,
676 }
677 ),
678 "expected EngineMismatch(url=packchain, stored=bundle), got {err:?}",
679 );
680 }
681
682 #[tokio::test]
683 async fn validate_format_rejects_url_bundle_against_stored_packchain() {
684 let store = MockStore::new();
687 store.insert("FORMAT", Bytes::from_static(b"packchain"));
688 let err = validate_format(BackendKind::S3, &store, "", Some(StorageEngine::Bundle))
689 .await
690 .unwrap_err();
691 assert!(
692 matches!(
693 err,
694 BackendError::EngineMismatch {
695 kind: BackendKind::S3,
696 url_engine: StorageEngine::Bundle,
697 stored_engine: StorageEngine::Packchain,
698 }
699 ),
700 "expected EngineMismatch(url=bundle, stored=packchain), got {err:?}",
701 );
702 }
703
704 #[tokio::test]
705 async fn validate_format_propagates_azure_kind_into_engine_mismatch() {
706 let store = MockStore::new();
712 store.insert("FORMAT", Bytes::from_static(b"bundle"));
713 let err = validate_format(
714 BackendKind::Azure,
715 &store,
716 "",
717 Some(StorageEngine::Packchain),
718 )
719 .await
720 .unwrap_err();
721 let BackendError::EngineMismatch { kind, .. } = &err else {
722 panic!("expected EngineMismatch, got {err:?}");
723 };
724 assert_eq!(*kind, BackendKind::Azure);
725 let fatal = fatal_message(&err);
726 assert!(
727 fatal.contains("container") && !fatal.contains("bucket"),
728 "Azure EngineMismatch must use 'container', got `{fatal}`",
729 );
730 }
731
732 #[tokio::test]
733 async fn validate_format_passes_stored_packchain_with_no_url_engine() {
734 let store = MockStore::new();
737 store.insert("FORMAT", Bytes::from_static(b"packchain"));
738 assert_eq!(
739 validate_format(BackendKind::S3, &store, "", None)
740 .await
741 .unwrap(),
742 StorageEngine::Packchain,
743 );
744 }
745
746 #[tokio::test]
747 async fn validate_format_passes_stored_packchain_with_matching_url() {
748 let store = MockStore::new();
749 store.insert("FORMAT", Bytes::from_static(b"packchain"));
750 assert_eq!(
751 validate_format(BackendKind::S3, &store, "", Some(StorageEngine::Packchain))
752 .await
753 .unwrap(),
754 StorageEngine::Packchain,
755 );
756 }
757
758 #[tokio::test]
759 async fn validate_format_rejects_unknown_stored_engine() {
760 let store = MockStore::new();
761 store.insert("FORMAT", Bytes::from_static(b"pack"));
762 let err = validate_format(BackendKind::S3, &store, "", None)
763 .await
764 .unwrap_err();
765 assert!(
766 matches!(
767 err,
768 BackendError::UnknownStoredEngine { kind: BackendKind::S3, ref stored }
769 if stored == "pack"
770 ),
771 "expected UnknownStoredEngine(pack), got {err:?}",
772 );
773 }
774
775 #[tokio::test]
776 async fn validate_format_propagates_azure_kind_into_unknown_stored_engine() {
777 let store = MockStore::new();
781 store.insert("FORMAT", Bytes::from_static(b"pack"));
782 let err = validate_format(BackendKind::Azure, &store, "", None)
783 .await
784 .unwrap_err();
785 let BackendError::UnknownStoredEngine { kind, stored } = &err else {
786 panic!("expected UnknownStoredEngine, got {err:?}");
787 };
788 assert_eq!(*kind, BackendKind::Azure);
789 assert_eq!(stored, "pack");
790 let fatal = fatal_message(&err);
791 assert!(
792 fatal.contains("container uses unknown storage engine"),
793 "Azure UnknownStoredEngine must use 'container', got `{fatal}`",
794 );
795 assert!(
796 !fatal.contains("bucket"),
797 "Azure path leaked 'bucket': `{fatal}`",
798 );
799 }
800
801 #[tokio::test]
802 async fn validate_format_uses_prefix_for_key_lookup() {
803 let store = MockStore::new();
804 store.insert("my-repo/FORMAT", Bytes::from_static(b"bundle"));
806 store.insert(
813 "FORMAT",
814 Bytes::from_static(b"INVALID_SENTINEL_NEVER_AN_ENGINE"),
815 );
816 let err = validate_format(BackendKind::S3, &store, "", None)
821 .await
822 .unwrap_err();
823 assert!(
824 matches!(
825 err,
826 BackendError::UnknownStoredEngine { kind: BackendKind::S3, ref stored }
827 if stored == "INVALID_SENTINEL_NEVER_AN_ENGINE"
828 ),
829 "expected UnknownStoredEngine(INVALID_SENTINEL_NEVER_AN_ENGINE), got {err:?}",
830 );
831 validate_format(BackendKind::S3, &store, "my-repo", None)
833 .await
834 .unwrap();
835 }
836
837 #[tokio::test]
845 async fn validate_format_rejects_non_utf8_format_bytes() {
846 let store = MockStore::new();
847 store.insert("FORMAT", Bytes::from_static(b"\xff\xff\xff"));
848 let err = validate_format(BackendKind::S3, &store, "", None)
849 .await
850 .unwrap_err();
851 let BackendError::InvalidCredentials { source } = &err else {
852 panic!("expected InvalidCredentials, got {err:?}");
853 };
854 let ObjectStoreError::Other(inner) = source else {
855 panic!("expected Other inside InvalidCredentials, got {source:?}");
856 };
857 let msg = inner.to_string();
858 assert!(
864 msg.contains("non-UTF-8") && msg.contains("FORMAT"),
865 "expected message naming the FORMAT key and non-UTF-8 cause, got `{msg}`",
866 );
867 let fatal = fatal_message(&err);
873 assert!(
874 fatal.contains("invalid credentials") && fatal.contains("non-UTF-8"),
875 "fatal_message must surface variant + non-UTF-8 source, got `{fatal}`",
876 );
877 }
878
879 #[test]
880 fn unknown_stored_engine_error_message() {
881 let err = BackendError::UnknownStoredEngine {
882 kind: BackendKind::S3,
883 stored: "pack".into(),
884 };
885 let fatal = fatal_message(&err);
886 assert!(
887 fatal.starts_with("fatal: bucket uses unknown storage engine `pack`;"),
888 "missing prefix in {fatal}",
889 );
890 for engine in StorageEngine::ALL {
894 assert!(
895 fatal.contains(&format!("`{}`", engine.as_str())),
896 "fatal_message for UnknownStoredEngine must mention engine `{}`, got `{fatal}`",
897 engine.as_str(),
898 );
899 }
900 }
901
902 #[test]
903 fn unknown_stored_engine_error_message_azure_uses_container_word() {
904 let err = BackendError::UnknownStoredEngine {
908 kind: BackendKind::Azure,
909 stored: "pack".into(),
910 };
911 let fatal = fatal_message(&err);
912 assert!(
913 fatal.starts_with("fatal: container uses unknown storage engine `pack`;"),
914 "Azure UnknownStoredEngine must use 'container', got `{fatal}`",
915 );
916 assert!(
917 !fatal.contains("bucket"),
918 "Azure path leaked 'bucket': {fatal}"
919 );
920 }
921
922 #[tokio::test]
923 async fn validate_format_returns_network_error_on_transport_failure() {
924 use crate::object_store::mock::Fault;
925 let store = MockStore::new();
926 store.arm(Fault::NetworkOnGetBytes {
927 key: "FORMAT".into(),
928 });
929 let err = validate_format(BackendKind::S3, &store, "", None)
930 .await
931 .unwrap_err();
932 assert!(
933 matches!(err, BackendError::Network { .. }),
934 "expected Network, got {err:?}",
935 );
936 }
937}