Skip to main content

git_remote_object_store/protocol/
backend.rs

1//! Backend factory: turns a parsed [`RemoteUrl`] into an
2//! [`Arc<dyn ObjectStore>`] for the protocol REPL to drive.
3//!
4//! Both S3 and Azure Blob are wired here.
5//!
6//! # Eager probe and categorical error mapping
7//!
8//! After constructing the SDK client, [`build`] runs a single low-cost
9//! listing call (`max_keys=1` for S3, `maxresults=1` for Azure) and folds
10//! well-known failures into categorical [`BackendError`] variants. Helper
11//! binaries pattern-match on these variants via [`fatal_message`] to emit
12//! single-line `fatal:` diagnostics.
13//!
14//! The probe runs **once** at backend construction. Per-call errors during
15//! `fetch` / `push` continue to flow through their existing typed paths.
16
17use 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/// Errors surfaced by [`build`].
28///
29/// The `Display` strings (no colons, "user" prefix on `NotAuthorized`)
30/// are the single source of truth for the operator-facing wording
31/// rendered by [`fatal_message`].
32///
33/// # Invariant for backend-specific wording
34///
35/// Every variant whose `Display` string mentions the storage container
36/// ("bucket" / "container") must carry a `kind: BackendKind` field and
37/// route the noun through [`container_word`]. Hardcoded "bucket" /
38/// "container" literals in the format string are a bug — they leak S3
39/// vocabulary into Azure diagnostics (and vice versa). New variants
40/// must follow the same pattern as [`BackendError::BucketNotFound`].
41///
42/// # Invariant for `fatal_message`
43///
44/// [`fatal_message`] walks the error source chain starting one level
45/// past `err.source()`, because any variant with a `#[source]` field
46/// already embeds `{source}` in its `Display` format string (making
47/// the first level visible without chain-walking). **Every future
48/// variant that adds a `#[source]` field must also include `{source}`
49/// in its format string.** Omitting `{source}` while keeping
50/// `#[source]` causes `fatal_message` to silently drop the first
51/// source level from the rendered message.
52///
53/// # Invariant for `Network` classification
54///
55/// When [`classify`] or [`validate_format`] encounter
56/// [`ObjectStoreError::Network`], they extract the inner [`BoxError`]
57/// and store it directly in [`BackendError::Network::source`]. They
58/// must **never** wrap the whole `ObjectStoreError::Network` (whose
59/// own `Display` is `"network error: <inner>"`) into another
60/// `BackendError` variant whose `Display` also includes the source —
61/// that produces the redundant `"network error: network error: ..."`
62/// rendering [`fatal_message`] is documented to avoid. New
63/// `ObjectStoreError` variants that carry transport semantics must
64/// either add a dedicated `BackendError::Network`-style classification
65/// arm or store the inner cause directly.
66#[derive(Debug, thiserror::Error)]
67pub enum BackendError {
68    /// Bucket (S3) or container (Azure) does not exist. Maps from a
69    /// 404 / `NoSuchBucket` on the construction-time probe.
70    #[error("{} not found {name}", container_word(*kind))]
71    BucketNotFound {
72        /// Which backend reported the failure.
73        kind: BackendKind,
74        /// Bucket or container name.
75        name: String,
76    },
77
78    /// Authentication succeeded but the principal lacks the listed
79    /// `action` on the named bucket/container. Maps from a 403 /
80    /// `AccessDenied` on the probe.
81    #[error("user not authorized to perform {action} on {} {name}", container_word(*kind))]
82    NotAuthorized {
83        /// Which backend reported the failure.
84        kind: BackendKind,
85        /// SDK call name the principal was denied (e.g. `ListObjectsV2`).
86        action: String,
87        /// Bucket or container name.
88        name: String,
89    },
90
91    /// Transport-level failure during backend construction (probe or FORMAT
92    /// key read): DNS resolution failed, connection refused, TLS handshake
93    /// error, or request timeout. This indicates a URL or network
94    /// configuration problem — not a credentials problem. The inner error is
95    /// extracted from [`ObjectStoreError::Network`] and stored directly to
96    /// avoid the redundant "network error: network error" display that would
97    /// result from wrapping it whole.
98    #[error("connection error: {source}")]
99    Network {
100        /// The underlying transport error preserved for chain-walking.
101        #[source]
102        source: BoxError,
103    },
104
105    /// Catch-all for credential acquisition failures (missing AWS
106    /// profile, expired creds, missing Azure credential alias, ...).
107    #[error("invalid credentials {source}")]
108    InvalidCredentials {
109        /// The underlying [`ObjectStoreError`] preserved as `#[source]`.
110        #[source]
111        source: ObjectStoreError,
112    },
113
114    /// The `FORMAT` key records an engine name this binary does not support.
115    ///
116    /// The supported-engine list is rendered from
117    /// [`StorageEngine::supported_list_str`] so adding a new variant
118    /// updates this message automatically.
119    #[error(
120        "{} uses unknown storage engine `{stored}`; \
121         this client supports {}",
122        container_word(*kind),
123        StorageEngine::supported_list_str()
124    )]
125    UnknownStoredEngine {
126        /// Which backend reported the failure.
127        kind: BackendKind,
128        /// The engine name as written in the `FORMAT` key.
129        stored: String,
130    },
131
132    /// The `?engine=` URL parameter conflicts with the engine stored in the
133    /// `FORMAT` key.
134    #[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        /// Which backend reported the failure.
141        kind: BackendKind,
142        /// Engine requested via the `?engine=` URL parameter.
143        url_engine: StorageEngine,
144        /// Engine stored in the `FORMAT` key.
145        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/// Render `err` as a single-line `fatal:` diagnostic helper binaries
157/// write to stderr.
158///
159/// The Azure wording substitutes "container" for "bucket". The wording
160/// lives in [`BackendError`]'s `Display` derive — see the type-level
161/// doc comment.
162///
163/// Variants like [`BackendError::InvalidCredentials`] and
164/// [`BackendError::Network`] inline their immediate source via
165/// `{source}`/`{0}` in the format string, sometimes transitively when
166/// the source itself wraps another typed error. The chain-walk is done
167/// by [`super::append_source_chain`], which dedups any level whose
168/// `Display` text is already at the tail of `msg` — so the `fatal:`
169/// line surfaces deeper root causes (e.g. the io / DNS error nested
170/// inside the SDK dispatch failure) without producing the duplicated
171/// "network error: network error: …" rendering that a naive walk
172/// would.
173#[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
180/// Fold an [`ObjectStoreError`] from backend construction or the eager
181/// probe into the categorical [`BackendError`] surface used by helper
182/// binaries.
183fn 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
204/// Read the `FORMAT` key at `<prefix>/FORMAT` and validate it against the
205/// engine declared in the URL. `kind` selects S3 vs Azure vocabulary in
206/// the error variants this function can surface
207/// ([`BackendError::UnknownStoredEngine`], [`BackendError::EngineMismatch`]).
208/// Returns `Ok(())` when:
209///
210/// - The key does not exist (new bucket — engine will be written on first push).
211/// - The stored engine matches the URL engine (or no engine was declared).
212///
213/// Returns the resolved engine in priority order:
214///
215/// 1. the engine stored in `FORMAT` when present and recognised,
216/// 2. the URL engine when `FORMAT` is absent and the URL declared one,
217/// 3. [`StorageEngine::Bundle`] otherwise (the default for new buckets).
218///
219/// One FORMAT read per call. [`build`] surfaces the resolved engine to
220/// its caller so [`crate::protocol::run`] can dispatch without a second
221/// network round trip.
222///
223/// # Errors
224///
225/// - [`BackendError::UnknownStoredEngine`] when the `FORMAT` content is not a
226///   recognised engine name.
227/// - [`BackendError::EngineMismatch`] when the URL engine conflicts with the
228///   stored engine.
229/// - [`BackendError::Network`] for transport failures (DNS, TLS, timeout)
230///   reading the key.
231/// - [`BackendError::InvalidCredentials`] for auth / credential failures
232///   reading the key, or non-UTF-8 bytes in the FORMAT body.
233pub 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        // No FORMAT key — this is a new or legacy bucket. The engine
243        // will be written on the first push; until then, the URL value
244        // (or the Bundle default) is authoritative.
245        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    // Trim ASCII whitespace so a trailing newline in the stored value does
255    // not cause a spurious parse failure. Use `from_utf8` (not lossy) so
256    // non-UTF-8 bytes in the FORMAT key surface as an error rather than
257    // silently producing a replacement-character engine name that would
258    // never match a valid StorageEngine variant.
259    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
286/// Construct the right [`ObjectStore`] for `remote`, verify it is
287/// reachable with a single low-cost list call, and resolve the storage
288/// engine from the `FORMAT` key in one pass.
289///
290/// Returns the connected store paired with the resolved
291/// [`StorageEngine`]. The engine is computed from `FORMAT` (when
292/// present) plus the URL's `?engine=` flag, with [`StorageEngine::Bundle`]
293/// as the default for buckets that have no `FORMAT` key yet.
294///
295/// # Errors
296///
297/// Returns [`BackendError`] if the backend cannot be constructed (e.g.
298/// invalid credentials or endpoint), the probe list call fails (e.g.
299/// bucket/container not found or permission denied), or the `FORMAT` key
300/// conflicts with `?engine=`.
301pub 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        // The BoxError is extracted from ObjectStoreError::Network directly,
404        // so its Display is the inner message, not "network error".
405        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        // 412 / 409 are impossible from a list call but should not panic
425        // — they fall through to the catch-all arm.
426        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        // Regression guard for #193: Azure operators must not see "bucket"
472        // in `NotAuthorized` diagnostics. The variant's `kind` field is
473        // load-bearing — a regression that drops it from the `Display`
474        // format string would silently revert to S3-only vocabulary.
475        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        // BackendError::Network stores the BoxError directly (not wrapped in
505        // ObjectStoreError::Network), so the Display is "connection error: <source>"
506        // and fatal_message walks one level deeper from source.
507        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        // A two-level chain: Network { source: mid } where mid itself has a
522        // source. Verifies the `while` loop in fatal_message appends every
523        // level, not just the first one it reaches.
524        #[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        // Pin the wording with two distinct engines so the assertion is
555        // not structurally circular (Lesson #6 — expected values derived
556        // from the spec). Picking `Packchain` URL against a `Bundle`
557        // bucket exercises the realistic operator-error path: someone
558        // adds `?engine=packchain` to a remote that was first pushed as
559        // `bundle`.
560        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        // Regression guard for #193: Azure operators must not see "bucket"
576        // in `EngineMismatch` diagnostics. The variant's `kind` field
577        // selects "container" via `container_word`.
578        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    // --- validate_format --------------------------------------------------
595
596    #[tokio::test]
597    async fn validate_format_passes_when_key_absent() {
598        let store = MockStore::new();
599        // No FORMAT key in the store — should resolve to Bundle (the
600        // default for new buckets) when the URL also omits the engine.
601        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        // Empty bucket + URL declares an engine → resolve to URL value.
614        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        // No URL engine — stored value is authoritative; no conflict.
639        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        // Operator typo: bucket was first pushed as `bundle`, then
662        // `?engine=packchain` was added to the remote URL. Stored value
663        // is authoritative, so we must reject with a clear mismatch.
664        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        // Symmetric direction: bucket was first pushed as `packchain`,
685        // then a stale `?engine=bundle` URL is reused. Same rejection.
686        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        // Regression guard for #193: when `validate_format` is invoked
707        // with `BackendKind::Azure`, the resulting `EngineMismatch` must
708        // carry `Azure` so its `Display` renders "container", not
709        // "bucket". A regression that hardcoded `BackendKind::S3` in
710        // construction would still pass the prior tests.
711        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        // `FORMAT` already locked to `packchain`; URL omits `?engine=`.
735        // Stored value is authoritative; resolution returns it.
736        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        // Regression guard for #193: an Azure container whose FORMAT key
778        // contains an unrecognised engine must render "container uses
779        // unknown storage engine ...", not "bucket uses ...".
780        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        // Valid key at the prefixed path.
805        store.insert("my-repo/FORMAT", Bytes::from_static(b"bundle"));
806        // Conflicting/invalid content at the root path — if the prefix is
807        // ignored, the "with prefix" call below would read this and fail.
808        // The sentinel value ("INVALID_SENTINEL_NEVER_AN_ENGINE") is
809        // structurally impossible to be a valid `StorageEngine` name now
810        // or in the future (uppercase, contains underscores), so the
811        // assertion holds even if a future engine variant is added.
812        store.insert(
813            "FORMAT",
814            Bytes::from_static(b"INVALID_SENTINEL_NEVER_AN_ENGINE"),
815        );
816        // Without prefix: reads root FORMAT → must be specifically the
817        // `UnknownStoredEngine` variant. A regression that mapped this
818        // through `Network` or `InvalidCredentials` would still produce
819        // an error but for the wrong reason.
820        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        // With prefix "my-repo": reads "my-repo/FORMAT" = "bundle" → Ok.
832        validate_format(BackendKind::S3, &store, "my-repo", None)
833            .await
834            .unwrap();
835    }
836
837    /// T1 tripwire: the `from_utf8` hardening in `validate_format` (vs
838    /// the prior `from_utf8_lossy`) must surface non-UTF-8 bytes as
839    /// `BackendError::InvalidCredentials` carrying an `io::Error` whose
840    /// message names the FORMAT key. A regression that revives
841    /// `from_utf8_lossy()` would silently produce a replacement-character
842    /// engine name and fail later at `from_name`'s lookup with the wrong
843    /// error variant.
844    #[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        // Both substrings together pin the wording the docstring claims:
859        // the message must surface the encoding category ("non-UTF-8")
860        // AND identify which key carried the bytes ("FORMAT"). Either
861        // assertion alone could false-pass on a generic "invalid utf-8"
862        // wording or a different-key error.
863        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        // The fatal message must surface BOTH the variant prefix
868        // ("invalid credentials") AND the inner non-UTF-8 cause through
869        // the chain-walk in `fatal_message`. This catches a regression
870        // that drops the source level (e.g. by removing `{source}` from
871        // the `InvalidCredentials` `#[error(...)]` format).
872        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        // The supported-engine list is driven by `StorageEngine::ALL`.
891        // Asserting against every variant means a new engine that fails
892        // to update the diagnostic wording will surface here automatically.
893        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        // Regression guard for #193: Azure path must render "container
905        // uses unknown storage engine ...". A regression that hardcoded
906        // "bucket" would still pass the S3 test above.
907        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}