Skip to main content

git_remote_object_store/
url.rs

1//! Parser for the `s3+https` / `s3+http` / `az+https` / `az+http` URL
2//! grammar.
3//!
4//! The parser strips the backend prefix (`s3+` or `az+`), parses the
5//! remainder as an RFC 3986 URL via the [`url`] crate, then layers
6//! cleartext-HTTP gating, backend-specific name validation,
7//! addressing-style detection, and query-flag extraction on top. The
8//! user-facing grammar reference is `docs/getting-started.md`.
9
10use std::env;
11use std::fmt;
12use std::num::NonZeroU64;
13use std::str::FromStr;
14
15use thiserror::Error;
16use url::Url;
17
18/// Environment override that allows cleartext `*+http://` URLs against
19/// non-loopback hosts. Accepted when set to any of the truthy values
20/// recognised by [`parse_bool_value`] (`1`, `true`, `yes`, `on`,
21/// case-insensitive). Any other value — including the empty string,
22/// `0`, `false`, `no`, `off`, or any unrecognised token — is treated
23/// as "not set" and the cleartext-HTTP gate stays closed.
24pub const ENV_ALLOW_HTTP: &str = "GIT_REMOTE_OBJECT_STORE_ALLOW_HTTP";
25
26/// Maximum accepted value for `?bundle_uri_presign_ttl=<seconds>`: 7
27/// days, in seconds. Pinned at the URL boundary so the value cannot
28/// reach the backend SDKs as a degenerate input.
29///
30/// AWS enforces a 7-day ceiling on presigned URLs as part of the
31/// `SigV4` specification; passing anything larger to
32/// `aws_sdk_s3::presigning::PresigningConfig::expires_in` fails with
33/// `expires_in must be less than or equal to 604800 seconds`. Azure
34/// service-SAS does not have a comparable spec-mandated cap, but a
35/// pathological caller-supplied TTL (e.g. `u64::MAX`) caused a panic
36/// in [`crate::object_store::azure::sas::build_blob_sas_url`] via
37/// `time::Duration::seconds_f64` overflow. Applying the same 7-day
38/// cap to both backends gives consistent behaviour and a clean error
39/// at URL-parse time rather than mid-protocol (issue #219).
40pub(crate) const MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS: u64 = 7 * 24 * 60 * 60;
41
42/// A parsed remote URL.
43///
44/// The `endpoint` field holds the canonical `https://` or `http://`
45/// URL that remains after stripping the backend prefix; bucket /
46/// account / container / prefix are projections of that URL.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RemoteUrl {
49    /// Amazon S3 (or any S3-compatible) endpoint.
50    S3 {
51        /// Canonical RFC 3986 endpoint URL (the input minus `s3+`).
52        endpoint: Url,
53        /// Bucket name.
54        bucket: String,
55        /// Optional repository prefix within the bucket (no trailing `/`).
56        prefix: Option<String>,
57        /// Auto-detected or explicitly overridden addressing style.
58        addressing: S3Addressing,
59        /// Query-string flags.
60        flags: RemoteFlags,
61    },
62    /// Azure Blob Storage endpoint.
63    Azure {
64        /// Canonical RFC 3986 endpoint URL (the input minus `az+`).
65        endpoint: Url,
66        /// Storage-account name.
67        account: String,
68        /// Container name.
69        container: String,
70        /// Optional repository prefix within the container (no trailing `/`).
71        prefix: Option<String>,
72        /// Auto-detected or explicitly overridden addressing style.
73        addressing: AzureAddressing,
74        /// Query-string flags.
75        flags: RemoteFlags,
76    },
77}
78
79/// S3 addressing style (§3.4).
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum S3Addressing {
82    /// `<bucket>.s3.<region>.amazonaws.com` — bucket is the leftmost
83    /// hostname label.
84    VirtualHosted,
85    /// `s3.<region>.amazonaws.com/<bucket>` — bucket is the first path
86    /// segment.
87    PathStyle,
88}
89
90/// Azure Blob addressing style (§3.4).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum AzureAddressing {
93    /// `<account>.blob.<endpoint-suffix>` — account is the leftmost
94    /// hostname label. Named `VirtualHosted` for symmetry with
95    /// [`S3Addressing::VirtualHosted`]; both describe the
96    /// "leftmost-hostname-label" pattern.
97    VirtualHosted,
98    /// `<host>/<account>/...` — account is the first path segment
99    /// (Azurite, custom endpoints).
100    PathStyle,
101}
102
103/// Identifies the on-bucket storage format / serialisation engine.
104///
105/// `engine` is a bucket-level property: once written to the `FORMAT` key on
106/// the first push, it is validated on every subsequent connect. The
107/// `?engine=` URL parameter is advisory — it is only meaningful when
108/// initialising a new repository. After the first push the stored value is
109/// authoritative and the URL parameter is checked for conflicts.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum StorageEngine {
112    /// Git bundle v2 — a text header followed by a PACK file.
113    ///
114    /// Key layout: `<prefix>/refs/heads/<branch>/<sha>.bundle`.
115    Bundle,
116    /// Incremental pack-chain engine (issue #52).
117    ///
118    /// On-bucket layout: `chain.json` (newest-first manifest) plus
119    /// `path-index.json` per ref, with content-addressed packs at
120    /// `<prefix>/packs/<sha>.{pack,idx}` and a baseline bundle for
121    /// first-push fan-out. Push, fetch, direct file access (`read_blob`
122    /// library API), compaction, and GC are all implemented; see
123    /// `src/packchain/{push,fetch,read,compact,gc}.rs`.
124    Packchain,
125}
126
127impl StorageEngine {
128    /// Every storage engine this client recognises.
129    ///
130    /// Single source of truth for diagnostics that need to enumerate
131    /// the supported set (see [`Self::supported_list_str`]). When a new
132    /// variant is added, append it here and every diagnostic that drives
133    /// its wording from this list updates automatically.
134    pub(crate) const ALL: &'static [Self] = &[Self::Bundle, Self::Packchain];
135
136    /// Parse an engine from its canonical string name. Returns `None` for
137    /// unrecognised names.
138    pub(crate) fn from_name(name: &str) -> Option<Self> {
139        Self::ALL
140            .iter()
141            .copied()
142            .find(|engine| engine.as_str() == name)
143    }
144
145    /// The canonical name for this engine, as stored in the `FORMAT` key and
146    /// accepted in the `?engine=` URL parameter.
147    #[must_use]
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Self::Bundle => "bundle",
151            Self::Packchain => "packchain",
152        }
153    }
154
155    /// Human-readable comma-separated list of every supported engine name,
156    /// each wrapped in backticks (e.g. `` "`bundle`, `packchain`" ``).
157    ///
158    /// Used by [`ParseError::UnknownEngine`] and
159    /// [`crate::protocol::backend::BackendError::UnknownStoredEngine`] so
160    /// that diagnostics stay in sync with [`Self::ALL`].
161    #[must_use]
162    pub(crate) fn supported_list_str() -> String {
163        Self::ALL
164            .iter()
165            .map(|engine| format!("`{}`", engine.as_str()))
166            .collect::<Vec<_>>()
167            .join(", ")
168    }
169}
170
171impl fmt::Display for StorageEngine {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.write_str(self.as_str())
174    }
175}
176
177/// Which backend a URL (or error) refers to.
178///
179/// Used as a discriminant in [`crate::protocol::backend::BackendError`] to select
180/// S3 vs Azure error wording, and internally in `url::parse` to route the
181/// URL to the right parsing path.
182///
183/// Marked `#[non_exhaustive]` so adding a new backend (e.g. GCS) is not
184/// a breaking change for downstream `match` arms — they will see a
185/// compiler error reminding them to handle the new variant via an
186/// explicit wildcard branch rather than silently picking up the wrong
187/// behaviour.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189#[non_exhaustive]
190pub enum BackendKind {
191    /// Amazon S3 (or any S3-compatible) backend.
192    S3,
193    /// Azure Blob Storage backend.
194    Azure,
195}
196
197impl BackendKind {
198    /// The URL scheme prefix for this backend (`"s3+"` or `"az+"`).
199    pub(crate) const fn scheme_prefix(self) -> &'static str {
200        match self {
201            Self::S3 => "s3+",
202            Self::Azure => "az+",
203        }
204    }
205
206    /// Human-readable backend name for diagnostics (`"S3"` / `"Azure"`).
207    pub(crate) const fn name(self) -> &'static str {
208        match self {
209            Self::S3 => "S3",
210            Self::Azure => "Azure",
211        }
212    }
213}
214
215impl fmt::Display for BackendKind {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.write_str(self.name())
218    }
219}
220
221/// Query-string flags described in §3.2 / §3.3.
222#[derive(Debug, Clone, Default, PartialEq, Eq)]
223pub struct RemoteFlags {
224    /// `?zip=1` — push uploads `repo.zip` alongside each bundle.
225    pub zip: bool,
226    /// `?profile=...` — selects a named AWS profile. S3 only; an Azure
227    /// URL carrying this flag is rejected with
228    /// [`ParseError::FlagNotApplicable`].
229    pub profile: Option<String>,
230    /// `?credential=...` — names an Azure credential alias. Azure only;
231    /// an S3 URL carrying this flag is rejected with
232    /// [`ParseError::FlagNotApplicable`].
233    pub credential: Option<String>,
234    /// `?region=...` — overrides the SDK-derived region (rare). S3 only;
235    /// an Azure URL carrying this flag is rejected with
236    /// [`ParseError::FlagNotApplicable`].
237    pub region: Option<String>,
238    /// `?engine=...` — declares the storage engine for a new repository.
239    ///
240    /// On the first push to an empty bucket this value is written to the
241    /// `FORMAT` key. On subsequent connects the stored `FORMAT` value is
242    /// authoritative; a conflicting `?engine=` aborts with an error.
243    pub engine: Option<StorageEngine>,
244    /// `?bundle_uri=1` — opt in to advertising the `bundle-uri` helper
245    /// capability so a `git clone` can fetch the packchain baseline
246    /// bundle directly (e.g. via a public bucket or CDN) before the
247    /// helper protocol negotiates the incremental tail. Only meaningful
248    /// for `?engine=packchain` remotes; bundle-engine remotes ignore
249    /// the flag because their bundle filenames rotate per push and a
250    /// stable URL would race the next push.
251    pub bundle_uri: bool,
252    /// `?bundle_uri_presign_ttl=<seconds>` — on a packchain remote with
253    /// `?bundle_uri=1`, the helper presigns each emitted
254    /// `bundle.<ref>.uri=<url>` line with an `<seconds>`-TTL signed
255    /// URL (S3 `SigV4` or Azure service-SAS). Operators with private
256    /// buckets need this; public-read buckets and CDN-fronted
257    /// endpoints can leave it unset (the canonical URL works
258    /// directly).
259    ///
260    /// Meaningful only when bundle-uri advertising is active — the TTL
261    /// solely governs presigning of the `bundle.<ref>.uri=` lines, which
262    /// are emitted nowhere else. Supplying it without `?bundle_uri=1` is
263    /// therefore rejected at the URL boundary with
264    /// [`ParseError::BundleUriPresignTtlWithoutBundleUri`] rather than
265    /// silently discarded (issue #246). The engine is **not** checked
266    /// here: it is resolved from the bucket `FORMAT` at connect time, so a
267    /// packchain bucket is validly reconnected with `?bundle_uri=1` and
268    /// the TTL but no `?engine=packchain` (a bundle-engine bucket simply
269    /// leaves the TTL inert, exactly as it does the `?bundle_uri=1` flag).
270    ///
271    /// `NonZeroU64` because a zero-second TTL is meaningless (the URL
272    /// would expire before any client could observe it). The URL
273    /// parser rejects `=0` at the boundary with [`ParseError::InvalidFlagValue`].
274    /// Issue #76.
275    pub bundle_uri_presign_ttl: Option<NonZeroU64>,
276}
277
278/// Errors produced by [`parse`].
279#[derive(Debug, Error, PartialEq, Eq)]
280pub enum ParseError {
281    /// Input was empty or whitespace-only.
282    #[error("empty URL")]
283    Empty,
284    /// Scheme is not one of the four accepted values.
285    #[error("unsupported scheme `{0}`; expected `s3+https`, `s3+http`, `az+https`, or `az+http`")]
286    UnsupportedScheme(String),
287    /// The body after the backend prefix could not be parsed as a URL.
288    #[error("malformed URL: {0}")]
289    InvalidUrl(#[from] url::ParseError),
290    /// URL is missing a host component.
291    #[error("URL is missing a host")]
292    MissingHost,
293    /// S3 path-style URL is missing the first path segment (the bucket).
294    #[error("URL is missing the bucket segment")]
295    MissingBucket,
296    /// Azure virtual-hosted URL is missing the first path segment (the
297    /// container) — or path-style is missing the second path segment.
298    #[error("URL is missing the container segment")]
299    MissingContainer,
300    /// Azure path-style URL is missing the first path segment (the
301    /// account).
302    #[error("URL is missing the account segment")]
303    MissingAccount,
304    /// Bucket name does not match the S3 charset rules in §3.5.
305    #[error("invalid bucket name `{0}`")]
306    InvalidBucket(String),
307    /// Storage-account name does not match the Azure rules in §3.5.
308    #[error("invalid storage-account name `{0}`")]
309    InvalidAccount(String),
310    /// Container name does not match the Azure rules in §3.5.
311    #[error("invalid container name `{0}`")]
312    InvalidContainer(String),
313    /// Cleartext `*+http://` against a non-loopback host without the
314    /// override env var.
315    #[error(
316        "cleartext http:// is forbidden against non-loopback host `{host}`; \
317         set {ENV_ALLOW_HTTP}=1 to override"
318    )]
319    CleartextHttpForbidden {
320        /// The non-loopback host that triggered the rejection.
321        host: String,
322    },
323    /// `?addressing=` value other than `path` or `virtual`.
324    #[error("unknown addressing override `{0}`; expected `path` or `virtual`")]
325    UnknownAddressing(String),
326    /// A known flag had a value outside its accepted set.
327    #[error("invalid value for flag `{name}`: `{value}`")]
328    InvalidFlagValue {
329        /// Flag name.
330        name: String,
331        /// Offending value.
332        value: String,
333    },
334    /// A query parameter is not part of the documented flag set.
335    #[error("unknown query flag `{0}`")]
336    UnknownFlag(String),
337    /// A flag is a documented flag but does not apply to the selected
338    /// backend (e.g. `?profile=` or `?region=` on an Azure URL,
339    /// `?credential=` on an S3 URL). Rejected with the same fail-fast
340    /// policy as [`UnknownFlag`][Self::UnknownFlag] so a misplaced flag
341    /// is never silently discarded.
342    #[error("query flag `{flag}` does not apply to the {backend} backend")]
343    FlagNotApplicable {
344        /// The flag name as it appeared in the query string.
345        flag: String,
346        /// The backend the URL selected, which does not consume `flag`.
347        backend: BackendKind,
348    },
349    /// `?engine=` value is not a recognised engine name.
350    #[error(
351        "unknown engine `{0}`; expected one of {supported}",
352        supported = StorageEngine::supported_list_str()
353    )]
354    UnknownEngine(String),
355    /// An `amazonaws.com` hostname that cannot be a valid S3 endpoint.
356    ///
357    /// Valid patterns are:
358    /// - virtual-hosted: `<bucket>.s3[.<region>].amazonaws.com`
359    /// - path-style: `s3[.<region>|-<region>].amazonaws.com`
360    #[error(
361        "hostname `{host}` is not a recognized AWS S3 endpoint; \
362         for virtual-hosted use `<bucket>.s3[.<region>].amazonaws.com`, \
363         for path-style use `s3[.<region>|-<region>].amazonaws.com`"
364    )]
365    InvalidAwsS3Endpoint {
366        /// The offending hostname.
367        host: String,
368    },
369    /// `?bundle_uri_presign_ttl=<seconds>` exceeded
370    /// [`MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS`] (7 days). Rejecting at
371    /// the URL boundary prevents a degenerate value from reaching the
372    /// AWS SDK (which rejects > 7 days anyway) or the Azure SAS
373    /// builder (which previously panicked on `u64::MAX`). Issue #219.
374    #[error(
375        "bundle_uri_presign_ttl=`{value}` exceeds the 7-day maximum \
376         ({max} seconds); presigned URLs cannot be valid for longer"
377    )]
378    BundleUriPresignTtlTooLarge {
379        /// The offending value.
380        value: u64,
381        /// The maximum accepted value
382        /// ([`MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS`]).
383        max: u64,
384    },
385    /// `?bundle_uri_presign_ttl=<seconds>` was supplied without
386    /// `?bundle_uri=1`, the flag that gives it meaning: the TTL only
387    /// governs presigning of the `bundle.<ref>.uri=` lines, which are
388    /// advertised solely when bundle-uri advertising is opted into, so
389    /// accepting it without that opt-in would silently discard caller
390    /// intent. The engine is intentionally not part of this check — it is
391    /// resolved from the bucket `FORMAT` at connect time, not knowable at
392    /// URL-parse time. Issue #246.
393    #[error("bundle_uri_presign_ttl requires `?bundle_uri=1`; it has no effect otherwise")]
394    BundleUriPresignTtlWithoutBundleUri,
395}
396
397/// Parse a remote URL.
398///
399/// # Errors
400///
401/// Returns [`ParseError`] if the input is empty, uses an unsupported
402/// scheme, contains a malformed URL, is missing required components
403/// (host, bucket, container, account), contains invalid component names,
404/// uses an `amazonaws.com` hostname that does not match a known S3
405/// endpoint pattern, or uses cleartext `http://` against a non-loopback
406/// host without the [`ENV_ALLOW_HTTP`] environment override.
407pub fn parse(input: &str) -> Result<RemoteUrl, ParseError> {
408    let trimmed = input.trim();
409    if trimmed.is_empty() {
410        return Err(ParseError::Empty);
411    }
412
413    let (backend, body) = detect_backend(trimmed)?;
414    let endpoint = Url::parse(body)?;
415
416    let host = endpoint
417        .host_str()
418        .ok_or(ParseError::MissingHost)?
419        .to_owned();
420    if endpoint.scheme() == "http" && !is_loopback(&endpoint) && !http_allowed_by_env() {
421        return Err(ParseError::CleartextHttpForbidden { host });
422    }
423
424    let (flags, addressing_override) = extract_flags(&endpoint)?;
425    reject_inapplicable_presign_ttl(&flags)?;
426
427    match backend {
428        BackendKind::S3 => finish_s3(endpoint, &host, flags, addressing_override),
429        BackendKind::Azure => finish_azure(endpoint, &host, flags, addressing_override),
430    }
431}
432
433impl FromStr for RemoteUrl {
434    type Err = ParseError;
435
436    fn from_str(s: &str) -> Result<Self, ParseError> {
437        parse(s)
438    }
439}
440
441impl fmt::Display for RemoteUrl {
442    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443        match self {
444            Self::S3 { endpoint, .. } => write!(f, "s3+{endpoint}"),
445            Self::Azure { endpoint, .. } => write!(f, "az+{endpoint}"),
446        }
447    }
448}
449
450impl RemoteUrl {
451    /// Returns the canonical endpoint URL (without the backend prefix).
452    #[must_use]
453    pub const fn endpoint(&self) -> &Url {
454        match self {
455            Self::S3 { endpoint, .. } | Self::Azure { endpoint, .. } => endpoint,
456        }
457    }
458
459    /// Returns the optional repository prefix.
460    #[must_use]
461    pub fn prefix(&self) -> Option<&str> {
462        match self {
463            Self::S3 { prefix, .. } | Self::Azure { prefix, .. } => prefix.as_deref(),
464        }
465    }
466
467    /// Returns the parsed query flags.
468    #[must_use]
469    pub const fn flags(&self) -> &RemoteFlags {
470        match self {
471            Self::S3 { flags, .. } | Self::Azure { flags, .. } => flags,
472        }
473    }
474
475    /// Returns the backend kind discriminant.
476    #[must_use]
477    pub const fn kind(&self) -> BackendKind {
478        match self {
479            Self::S3 { .. } => BackendKind::S3,
480            Self::Azure { .. } => BackendKind::Azure,
481        }
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Internals
487// ---------------------------------------------------------------------------
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490enum AddressingOverride {
491    Path,
492    Virtual,
493}
494
495/// Classify the URL by its backend scheme prefix and return both the
496/// detected [`BackendKind`] and the body of the URL with the `s3+` /
497/// `az+` tag stripped. Folding the classification and the strip into
498/// one step keeps `parse()` free of an unreachable fallback for a
499/// mismatched prefix.
500///
501/// Each branch also verifies that the body starts with `https://` or
502/// `http://` so the downstream `Url::parse` sees a recognised scheme.
503fn detect_backend(input: &str) -> Result<(BackendKind, &str), ParseError> {
504    for kind in [BackendKind::S3, BackendKind::Azure] {
505        if let Some(body) = input.strip_prefix(kind.scheme_prefix())
506            && (body.starts_with("https://") || body.starts_with("http://"))
507        {
508            return Ok((kind, body));
509        }
510    }
511    Err(ParseError::UnsupportedScheme(scheme_of(input)))
512}
513
514/// Extract the part of `input` before the first `:` for error messages.
515/// Falls back to the whole string when no `:` is present.
516fn scheme_of(input: &str) -> String {
517    input.split(':').next().unwrap_or(input).to_owned()
518}
519
520fn is_loopback(u: &Url) -> bool {
521    match u.host() {
522        Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
523        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
524        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
525        None => false,
526    }
527}
528
529fn http_allowed_by_env() -> bool {
530    // Reuse the same vocabulary the URL boolean flags accept so
531    // `ALLOW_HTTP=true` and `ALLOW_HTTP=1` behave identically. Anything
532    // we cannot parse as a boolean (unset, empty, junk) leaves the
533    // gate closed — fail-safe is "no cleartext".
534    env::var(ENV_ALLOW_HTTP)
535        .ok()
536        .as_deref()
537        .and_then(parse_bool_value)
538        .unwrap_or(false)
539}
540
541/// Pull known flags out of the query string. Unknown keys are an error
542/// (fail-fast on typos rather than silently discard configuration).
543fn extract_flags(u: &Url) -> Result<(RemoteFlags, Option<AddressingOverride>), ParseError> {
544    let mut flags = RemoteFlags::default();
545    let mut addressing = None;
546    for (key, value) in u.query_pairs() {
547        match key.as_ref() {
548            "zip" => flags.zip = parse_bool_flag("zip", value.as_ref())?,
549            "profile" => flags.profile = Some(value.into_owned()),
550            "credential" => flags.credential = Some(value.into_owned()),
551            "region" => flags.region = Some(value.into_owned()),
552            "addressing" => {
553                addressing = Some(match value.as_ref() {
554                    "path" => AddressingOverride::Path,
555                    "virtual" => AddressingOverride::Virtual,
556                    other => return Err(ParseError::UnknownAddressing(other.to_owned())),
557                });
558            }
559            "engine" => {
560                flags.engine = Some(
561                    StorageEngine::from_name(value.as_ref())
562                        .ok_or_else(|| ParseError::UnknownEngine(value.into_owned()))?,
563                );
564            }
565            "bundle_uri" => flags.bundle_uri = parse_bool_flag("bundle_uri", value.as_ref())?,
566            "bundle_uri_presign_ttl" => {
567                flags.bundle_uri_presign_ttl = Some(parse_bundle_uri_presign_ttl(value.as_ref())?);
568            }
569            other => return Err(ParseError::UnknownFlag(other.to_owned())),
570        }
571    }
572    Ok((flags, addressing))
573}
574
575/// Reject a flag that parsed globally in [`extract_flags`] but does not
576/// apply to the backend that the URL ultimately selected.
577///
578/// `extract_flags` runs before backend dispatch and therefore cannot
579/// know whether `?profile=` belongs to an S3 URL or an Azure one. The
580/// backend-specific `finish_*` functions own that context, so the
581/// cross-backend pairing check lives here and is shared by both. When
582/// `present` is true the flag is reported via
583/// [`ParseError::FlagNotApplicable`], matching the fail-fast policy used
584/// for unknown flags.
585fn reject_inapplicable_flag(
586    present: bool,
587    flag: &str,
588    backend: BackendKind,
589) -> Result<(), ParseError> {
590    if present {
591        return Err(ParseError::FlagNotApplicable {
592            flag: flag.to_owned(),
593            backend,
594        });
595    }
596    Ok(())
597}
598
599/// Reject `?bundle_uri_presign_ttl=` when `?bundle_uri=1` is absent — the
600/// TTL governs presigning of the emitted `bundle.<ref>.uri=` lines, which
601/// are only advertised when bundle-uri advertising is opted into, so a TTL
602/// without `?bundle_uri=1` silently discards the operator's intent.
603///
604/// The check is deliberately gated on `bundle_uri` alone, **not** the
605/// engine. The runtime engine is bucket-authoritative — resolved from the
606/// `FORMAT` key in `backend::build`, not the URL `?engine=` flag (see
607/// `protocol::run`) — so a packchain bucket is routinely reconnected with
608/// `?bundle_uri=1&bundle_uri_presign_ttl=<n>` and no `?engine=packchain`.
609/// The URL parser runs before `FORMAT` is read and therefore cannot know
610/// the engine; gating on the flag would reject that valid steady-state
611/// URL. `bundle_uri` is the only precondition the parser can evaluate
612/// soundly, and it mirrors how a `?bundle_uri=1` flag is itself merely
613/// inert (not rejected) on a non-packchain bucket.
614fn reject_inapplicable_presign_ttl(flags: &RemoteFlags) -> Result<(), ParseError> {
615    let has_ttl = flags.bundle_uri_presign_ttl.is_some();
616    if has_ttl && !flags.bundle_uri {
617        return Err(ParseError::BundleUriPresignTtlWithoutBundleUri);
618    }
619    Ok(())
620}
621
622fn parse_bool_flag(name: &str, value: &str) -> Result<bool, ParseError> {
623    parse_bool_value(value).ok_or_else(|| ParseError::InvalidFlagValue {
624        name: name.to_owned(),
625        value: value.to_owned(),
626    })
627}
628
629/// Single source of truth for boolean-string parsing across the URL
630/// query-flag parser and the helper-runtime env-var reads.
631///
632/// Accepts the conventional "truthy / falsy" vocabulary used by most
633/// shells and config files (`1|true|yes|on` for true; `0|false|no|off`
634/// for false), all case-insensitively. Returns `None` for any token
635/// outside the accepted set so callers can map the failure mode they
636/// need (URL flags surface [`ParseError::InvalidFlagValue`]; env-var
637/// reads fall back to "unset/false").
638///
639/// Centralising the vocabulary here (rather than open-coding `matches!`
640/// at each read site) prevents the divergence reported in issue #187,
641/// where `?zip=true` worked in the URL but `ALLOW_HTTP=true` did not.
642fn parse_bool_value(value: &str) -> Option<bool> {
643    // One `to_ascii_lowercase` allocation plus a single match,
644    // replacing the previous 8-way `eq_ignore_ascii_case` chain (#221).
645    // Not a hot path; clarity outweighs the per-call String alloc.
646    match value.to_ascii_lowercase().as_str() {
647        "1" | "true" | "yes" | "on" => Some(true),
648        "0" | "false" | "no" | "off" => Some(false),
649        _ => None,
650    }
651}
652
653/// Parse a positive integer flag value into [`NonZeroU64`]. Rejects
654/// `0`, negative values, non-numeric junk. Used for `bundle_uri_presign_ttl`
655/// (issue #76).
656fn parse_nonzero_u64_flag(name: &str, value: &str) -> Result<NonZeroU64, ParseError> {
657    let n: u64 = value.parse().map_err(|_| ParseError::InvalidFlagValue {
658        name: name.to_owned(),
659        value: value.to_owned(),
660    })?;
661    NonZeroU64::new(n).ok_or_else(|| ParseError::InvalidFlagValue {
662        name: name.to_owned(),
663        value: value.to_owned(),
664    })
665}
666
667/// Parse `?bundle_uri_presign_ttl=<seconds>`: positive integer in
668/// `1..=MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS`. The upper cap matches
669/// AWS's hard 7-day ceiling on presigned URLs and protects the Azure
670/// SAS builder from `u64`-overflow inputs (issue #219).
671fn parse_bundle_uri_presign_ttl(value: &str) -> Result<NonZeroU64, ParseError> {
672    let ttl = parse_nonzero_u64_flag("bundle_uri_presign_ttl", value)?;
673    if ttl.get() > MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS {
674        return Err(ParseError::BundleUriPresignTtlTooLarge {
675            value: ttl.get(),
676            max: MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS,
677        });
678    }
679    Ok(ttl)
680}
681
682/// Non-empty path segments. Segments are returned verbatim; bucket /
683/// account / container charsets cannot contain percent-encoded bytes,
684/// and the prefix is round-tripped as-stored.
685fn path_segments(u: &Url) -> Vec<String> {
686    u.path_segments()
687        .map(|iter| iter.filter(|s| !s.is_empty()).map(str::to_owned).collect())
688        .unwrap_or_default()
689}
690
691fn join_prefix(segments: &[String]) -> Option<String> {
692    if segments.is_empty() {
693        None
694    } else {
695        Some(segments.join("/"))
696    }
697}
698
699/// Set the URL's path so that [`fmt::Display`] reproduces the canonical
700/// form (with trailing `/` stripped).
701fn set_canonical_path(u: &mut Url, segments: &[&str]) {
702    u.set_path(&format!("/{}", segments.join("/")));
703}
704
705// ---------------------------------------------------------------------------
706// S3
707// ---------------------------------------------------------------------------
708
709/// AWS partition suffixes that are owned by AWS and therefore subject to
710/// `check_aws_s3_host` validation. Hosts ending in any of these must
711/// match a recognised S3 endpoint shape; hosts ending in anything else
712/// are treated as third-party S3-compatible endpoints (`MinIO`,
713/// Cloudflare R2, …) and skip the check entirely.
714///
715/// Order is irrelevant for correctness: a host that ends in
716/// `.amazonaws.com.cn` does not end in `.amazonaws.com` (the trailing
717/// `.cn` rules that out), so the two suffixes are mutually exclusive on
718/// any given host. The China entry is listed first by convention only.
719pub(crate) const AWS_HOST_SUFFIXES: &[&str] = &[".amazonaws.com.cn", ".amazonaws.com"];
720
721/// If `host` ends in one of [`AWS_HOST_SUFFIXES`], return the host with
722/// that suffix stripped; otherwise return `None`. Single source of truth
723/// for "is this an AWS partition host, and what is the leading portion?"
724pub(crate) fn strip_aws_host_suffix(host: &str) -> Option<&str> {
725    AWS_HOST_SUFFIXES
726        .iter()
727        .find_map(|suffix| host.strip_suffix(suffix))
728}
729
730/// Reject AWS hostnames (`.amazonaws.com` and `.amazonaws.com.cn`) that
731/// cannot be valid S3 endpoints.
732///
733/// Third-party S3-compatible endpoints (custom hosts, `MinIO`, R2, …)
734/// are passed through unconditionally — they do not end in an AWS
735/// partition suffix. For AWS hosts, after stripping the partition
736/// suffix the remainder must match one of:
737///
738/// - `s3` (legacy global path-style: `s3.amazonaws.com`)
739/// - `s3.<region>` (path-style with region: `s3.us-west-2.amazonaws.com`)
740/// - `s3-<region>` (legacy hyphenated path-style:
741///   `s3-us-east-1.amazonaws.com`)
742/// - end with `.s3` (no-region virtual-hosted:
743///   `<bucket>.s3.amazonaws.com`, where the trailing `.s3` label is
744///   the AWS service marker for the legacy global form)
745/// - contain `.s3.` or `.s3-` (virtual-hosted with region:
746///   `<bucket>.s3.<region>.amazonaws.com` /
747///   `<bucket>.s3-<region>.amazonaws.com`)
748///
749/// The common mistake `<bucket>.<region>.amazonaws.com` — missing the
750/// `.s3.` service marker — would otherwise silently fall through to
751/// path-style addressing with a non-existent endpoint hostname,
752/// producing an inscrutable DNS-resolution error at connect time.
753///
754/// **Policy on `?addressing=` override:** this check runs before the
755/// addressing override is applied, so `?addressing=path` (or
756/// `=virtual`) on an AWS hostname does not bypass it. AWS owns
757/// `.amazonaws.com[.cn]`; any host on those suffixes that is not a
758/// recognised S3 endpoint is a typo, and a fast-fail with the helpful
759/// `InvalidAwsS3Endpoint` error is preferable to letting the user pick
760/// any addressing style they want against a non-existent endpoint.
761fn check_aws_s3_host(host: &str) -> Result<(), ParseError> {
762    let Some(trimmed) = strip_aws_host_suffix(host) else {
763        // Not an AWS host — third-party S3-compatible endpoint, always OK.
764        return Ok(());
765    };
766
767    // `<bucket>.s3.amazonaws.com` → trimmed is `<bucket>.s3`; the last
768    // dot-separated label is "s3" (global virtual-hosted, no region).
769    // This is the only branch that catches the no-region virtual-hosted
770    // shape — it is NOT redundant with the `.s3.` / `.s3-` infix checks
771    // (which require a region segment after the marker).
772    let last_label_is_s3 = trimmed.split('.').next_back() == Some("s3");
773
774    let valid = trimmed == "s3"
775        || trimmed.starts_with("s3.")
776        // Legacy path-style hyphenated form: `s3-<region>.amazonaws.com`.
777        // Accepts any `s3-*` prefix without validating the region name, so
778        // `s3-mybucket.amazonaws.com` is a known false-negative (passes the
779        // check but is not a real S3 endpoint; user sees a DNS error rather
780        // than this helpful message). Tightening would require a region
781        // allowlist, which is fragile as AWS adds regions.
782        || trimmed.starts_with("s3-")
783        || last_label_is_s3
784        || trimmed.contains(".s3.")
785        || trimmed.contains(".s3-");
786
787    if !valid {
788        return Err(ParseError::InvalidAwsS3Endpoint {
789            host: host.to_owned(),
790        });
791    }
792    Ok(())
793}
794
795fn finish_s3(
796    mut endpoint: Url,
797    host: &str,
798    flags: RemoteFlags,
799    addressing_override: Option<AddressingOverride>,
800) -> Result<RemoteUrl, ParseError> {
801    // `credential` names an Azure credential alias and is consumed only
802    // by the Azure auth path; it is meaningless on S3.
803    reject_inapplicable_flag(flags.credential.is_some(), "credential", BackendKind::S3)?;
804
805    let segments = path_segments(&endpoint);
806
807    check_aws_s3_host(host)?;
808
809    let (addressing, bucket, prefix_segments) =
810        resolve_s3_components(host, &segments, addressing_override)?;
811
812    if !is_valid_bucket(&bucket) {
813        return Err(ParseError::InvalidBucket(bucket));
814    }
815    let prefix = join_prefix(prefix_segments);
816
817    // Re-emit a canonical path so Display round-trips cleanly.
818    let canonical: Vec<&str> = match addressing {
819        S3Addressing::VirtualHosted => prefix_segments.iter().map(String::as_str).collect(),
820        S3Addressing::PathStyle => std::iter::once(bucket.as_str())
821            .chain(prefix_segments.iter().map(String::as_str))
822            .collect(),
823    };
824    set_canonical_path(&mut endpoint, &canonical);
825
826    Ok(RemoteUrl::S3 {
827        endpoint,
828        bucket,
829        prefix,
830        addressing,
831        flags,
832    })
833}
834
835/// Determine S3 addressing style and extract the bucket name and prefix
836/// segments from the URL's host and path.
837///
838/// Path-style skips the `rfind` scan entirely; virtual-hosted (auto or
839/// explicit) runs it once and reuses the result for both detection and
840/// extraction.
841fn resolve_s3_components<'a>(
842    host: &str,
843    segments: &'a [String],
844    addressing_override: Option<AddressingOverride>,
845) -> Result<(S3Addressing, String, &'a [String]), ParseError> {
846    // Compute addressing and the AWS bucket prefix together.
847    let (addressing, aws_bucket) = match addressing_override {
848        Some(AddressingOverride::Path) => (S3Addressing::PathStyle, None),
849        Some(AddressingOverride::Virtual) => {
850            (S3Addressing::VirtualHosted, s3_virtual_hosted_bucket(host))
851        }
852        None => {
853            let b = s3_virtual_hosted_bucket(host);
854            let style = if b.is_some() {
855                S3Addressing::VirtualHosted
856            } else {
857                S3Addressing::PathStyle
858            };
859            (style, b)
860        }
861    };
862
863    let (bucket, prefix_segments) = match addressing {
864        S3Addressing::VirtualHosted => {
865            // `aws_bucket` covers both auto-detected and explicit
866            // `?addressing=virtual` for AWS hosts. Falls back to the
867            // leftmost label for non-AWS virtual-hosted endpoints, which
868            // by convention put the bucket as the leftmost label.
869            let bucket = aws_bucket
870                .or_else(|| leftmost_label(host))
871                .ok_or(ParseError::MissingBucket)?;
872            (bucket, segments)
873        }
874        S3Addressing::PathStyle => {
875            let (head, tail) = segments.split_first().ok_or(ParseError::MissingBucket)?;
876            (head.clone(), tail)
877        }
878    };
879
880    Ok((addressing, bucket, prefix_segments))
881}
882
883/// AWS virtual-hosted infixes anchored at the start of the
884/// `s3[.-]<region>.amazonaws.com` suffix. The scan picks the rightmost
885/// occurrence (see `s3_virtual_hosted_bucket`) so a bucket prefix
886/// containing dots — or even a literal `.s3.` segment — survives
887/// intact and only the AWS service marker before the region is
888/// consumed.
889pub(crate) const AWS_S3_INFIXES: &[&str] = &[".s3.", ".s3-"];
890
891/// Extract the bucket prefix that precedes the AWS `.s3.` or `.s3-`
892/// service infix in `host`. Returns `None` for hosts that don't carry
893/// the AWS virtual-hosted shape — callers fall back to `leftmost_label`
894/// for non-AWS endpoints reached via `?addressing=virtual`.
895///
896/// Uses `rfind` (rightmost occurrence) so a bucket name that itself
897/// contains `.s3.` or `.s3-` segments (no AWS rule forbids it) is
898/// extracted in full instead of being truncated at the first match.
899/// The returned string is the entire substring before the chosen
900/// infix, so dotted bucket names like `bucketname.com` survive intact.
901pub(crate) fn s3_virtual_hosted_bucket(host: &str) -> Option<String> {
902    // Both infixes are 4 bytes, so the one whose rfind position is
903    // numerically largest is the rightmost match in the string — no need
904    // to track which infix won after taking the max.
905    AWS_S3_INFIXES
906        .iter()
907        .filter_map(|infix| host.rfind(infix))
908        .max()
909        .map(|idx| host[..idx].to_owned())
910        .filter(|bucket| !bucket.is_empty())
911}
912
913fn leftmost_label(host: &str) -> Option<String> {
914    host.split('.')
915        .next()
916        .filter(|l| !l.is_empty())
917        .map(str::to_owned)
918}
919
920// ---------------------------------------------------------------------------
921// Azure
922// ---------------------------------------------------------------------------
923
924fn finish_azure(
925    mut endpoint: Url,
926    host: &str,
927    flags: RemoteFlags,
928    addressing_override: Option<AddressingOverride>,
929) -> Result<RemoteUrl, ParseError> {
930    // `profile` selects an AWS named profile and `region` overrides the
931    // AWS SDK region; both are consumed only on the S3 path and have no
932    // meaning for Azure.
933    reject_inapplicable_flag(flags.profile.is_some(), "profile", BackendKind::Azure)?;
934    reject_inapplicable_flag(flags.region.is_some(), "region", BackendKind::Azure)?;
935
936    let segments = path_segments(&endpoint);
937
938    let addressing = match addressing_override {
939        Some(AddressingOverride::Path) => AzureAddressing::PathStyle,
940        Some(AddressingOverride::Virtual) => AzureAddressing::VirtualHosted,
941        None => detect_azure_addressing(host),
942    };
943
944    let (account, container, prefix_segments) =
945        resolve_azure_components(addressing, host, &segments)?;
946
947    if !is_valid_account(&account) {
948        return Err(ParseError::InvalidAccount(account));
949    }
950    if !is_valid_container(&container) {
951        return Err(ParseError::InvalidContainer(container));
952    }
953    let prefix = join_prefix(prefix_segments);
954
955    let canonical: Vec<&str> = match addressing {
956        AzureAddressing::VirtualHosted => std::iter::once(container.as_str())
957            .chain(prefix_segments.iter().map(String::as_str))
958            .collect(),
959        AzureAddressing::PathStyle => std::iter::once(account.as_str())
960            .chain(std::iter::once(container.as_str()))
961            .chain(prefix_segments.iter().map(String::as_str))
962            .collect(),
963    };
964    set_canonical_path(&mut endpoint, &canonical);
965
966    Ok(RemoteUrl::Azure {
967        endpoint,
968        account,
969        container,
970        prefix,
971        addressing,
972        flags,
973    })
974}
975
976/// Extract the storage account, container, and prefix segments from the
977/// URL's host and path, according to the resolved addressing style.
978fn resolve_azure_components<'a>(
979    addressing: AzureAddressing,
980    host: &str,
981    segments: &'a [String],
982) -> Result<(String, String, &'a [String]), ParseError> {
983    match addressing {
984        AzureAddressing::VirtualHosted => {
985            let account = leftmost_label(host).ok_or(ParseError::MissingAccount)?;
986            match segments {
987                [] => Err(ParseError::MissingContainer),
988                [container, rest @ ..] => Ok((account, container.clone(), rest)),
989            }
990        }
991        AzureAddressing::PathStyle => match segments {
992            [] => Err(ParseError::MissingAccount),
993            [_] => Err(ParseError::MissingContainer),
994            [account, container, rest @ ..] => Ok((account.clone(), container.clone(), rest)),
995        },
996    }
997}
998
999fn detect_azure_addressing(host: &str) -> AzureAddressing {
1000    // §3.4: virtual-hosted iff the second hostname label is `blob`.
1001    // Hosts are already lowercased by the `url` crate (RFC 3986).
1002    if host.split('.').nth(1) == Some("blob") {
1003        AzureAddressing::VirtualHosted
1004    } else {
1005        AzureAddressing::PathStyle
1006    }
1007}
1008
1009// ---------------------------------------------------------------------------
1010// Validation (§3.5)
1011// ---------------------------------------------------------------------------
1012
1013/// AWS-reserved bucket-name prefixes. See
1014/// <https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html>.
1015const FORBIDDEN_BUCKET_PREFIXES: &[&str] = &["xn--", "sthree-", "amzn-s3-demo-"];
1016
1017/// AWS-reserved bucket-name suffixes. See the same AWS doc.
1018const FORBIDDEN_BUCKET_SUFFIXES: &[&str] =
1019    &["-s3alias", "--ol-s3", ".mrap", "--x-s3", "--table-s3"];
1020
1021/// AWS S3 General Purpose bucket-naming rules: 3–63 chars, lowercase
1022/// alphanumerics plus `.` and `-`, must begin and end with a letter or
1023/// digit, no consecutive periods, not formatted as an IPv4 address, and
1024/// none of the AWS reserved prefixes or suffixes.
1025fn is_valid_bucket(s: &str) -> bool {
1026    let bytes = s.as_bytes();
1027    let (Some(&first), Some(&last)) = (bytes.first(), bytes.last()) else {
1028        return false;
1029    };
1030    (3..=63).contains(&bytes.len())
1031        && is_ascii_alphanum_lower(first)
1032        && is_ascii_alphanum_lower(last)
1033        && bytes
1034            .iter()
1035            .all(|b| is_ascii_alphanum_lower(*b) || matches!(*b, b'.' | b'-'))
1036        && !s.contains("..")
1037        && !is_ipv4_formatted(s)
1038        && !FORBIDDEN_BUCKET_PREFIXES.iter().any(|p| s.starts_with(p))
1039        && !FORBIDDEN_BUCKET_SUFFIXES.iter().any(|p| s.ends_with(p))
1040}
1041
1042/// `[a-z0-9]{3,24}` — Azure storage-account naming rule.
1043fn is_valid_account(s: &str) -> bool {
1044    (3..=24).contains(&s.len()) && s.bytes().all(is_ascii_alphanum_lower)
1045}
1046
1047/// Azure container-naming rule: 3–63 chars, lowercase alphanumerics plus
1048/// `-`, must begin and end with a letter or digit, and no consecutive
1049/// hyphens. See
1050/// <https://learn.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata>.
1051fn is_valid_container(s: &str) -> bool {
1052    let bytes = s.as_bytes();
1053    let (Some(&first), Some(&last)) = (bytes.first(), bytes.last()) else {
1054        return false;
1055    };
1056    (3..=63).contains(&bytes.len())
1057        && is_ascii_alphanum_lower(first)
1058        && is_ascii_alphanum_lower(last)
1059        && bytes
1060            .iter()
1061            .all(|b| is_ascii_alphanum_lower(*b) || *b == b'-')
1062        && !s.contains("--")
1063}
1064
1065const fn is_ascii_alphanum_lower(b: u8) -> bool {
1066    b.is_ascii_lowercase() || b.is_ascii_digit()
1067}
1068
1069/// True iff `s` looks like a dotted-quad IPv4 address (four non-empty
1070/// digit-only segments separated by `.`). AWS rejects bucket names with
1071/// this shape regardless of whether the address is routable.
1072fn is_ipv4_formatted(s: &str) -> bool {
1073    let mut parts = 0usize;
1074    for part in s.split('.') {
1075        parts += 1;
1076        if parts > 4 {
1077            return false;
1078        }
1079        if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
1080            return false;
1081        }
1082    }
1083    parts == 4
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    #[test]
1091    fn rejects_empty() {
1092        assert_eq!(parse(""), Err(ParseError::Empty));
1093        assert_eq!(parse("   "), Err(ParseError::Empty));
1094    }
1095
1096    #[test]
1097    fn rejects_unknown_scheme() {
1098        let err = parse("https://example.com/bucket").unwrap_err();
1099        assert!(matches!(err, ParseError::UnsupportedScheme(s) if s == "https"));
1100    }
1101
1102    #[test]
1103    fn rejects_backend_tag_with_unsupported_inner_scheme() {
1104        // `detect_backend` must check both the `s3+`/`az+` tag and the
1105        // inner `http(s)://` scheme — otherwise an `s3+ftp://` URL would
1106        // sneak past classification and surface as a confusing downstream
1107        // `Url::parse` error.
1108        for input in [
1109            "s3+ftp://example.com/b",
1110            "az+ftp://acct.blob.core.windows.net/c",
1111        ] {
1112            let err = parse(input).unwrap_err();
1113            assert!(
1114                matches!(&err, ParseError::UnsupportedScheme(_)),
1115                "expected UnsupportedScheme for {input}, got {err:?}",
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn validates_bucket_charset() {
1122        assert!(is_valid_bucket("my-bucket"));
1123        assert!(is_valid_bucket("a23"));
1124        assert!(is_valid_bucket("a.b.c"));
1125        assert!(!is_valid_bucket("ab"));
1126        assert!(!is_valid_bucket("-leading-dash"));
1127        assert!(!is_valid_bucket("trailing-dash-"));
1128        assert!(!is_valid_bucket(".leading-dot"));
1129        assert!(!is_valid_bucket("trailing-dot."));
1130        assert!(!is_valid_bucket("UPPER"));
1131        assert!(!is_valid_bucket(&"a".repeat(64)));
1132    }
1133
1134    #[test]
1135    fn rejects_bucket_with_consecutive_dots() {
1136        assert!(!is_valid_bucket("ab..cd"));
1137        assert!(!is_valid_bucket("a..b"));
1138    }
1139
1140    #[test]
1141    fn rejects_bucket_formatted_like_ipv4() {
1142        assert!(!is_valid_bucket("192.168.1.1"));
1143        assert!(!is_valid_bucket("1.2.3.4"));
1144        assert!(!is_valid_bucket("999.999.999.999"));
1145        // Three or five segments are not IPv4-shaped.
1146        assert!(is_valid_bucket("1.2.3"));
1147        assert!(is_valid_bucket("1.2.3.4.5"));
1148    }
1149
1150    #[test]
1151    fn rejects_forbidden_bucket_prefixes() {
1152        assert!(!is_valid_bucket("xn--abc"));
1153        assert!(!is_valid_bucket("sthree-foo"));
1154        assert!(!is_valid_bucket("amzn-s3-demo-bucket"));
1155    }
1156
1157    #[test]
1158    fn rejects_forbidden_bucket_suffixes() {
1159        assert!(!is_valid_bucket("my-bucket-s3alias"));
1160        assert!(!is_valid_bucket("my-bucket--ol-s3"));
1161        assert!(!is_valid_bucket("my-bucket--x-s3"));
1162        assert!(!is_valid_bucket("my-bucket--table-s3"));
1163        assert!(!is_valid_bucket("ab.mrap"));
1164    }
1165
1166    #[test]
1167    fn ipv4_formatted_helper() {
1168        assert!(is_ipv4_formatted("0.0.0.0"));
1169        assert!(is_ipv4_formatted("10.20.30.40"));
1170        assert!(!is_ipv4_formatted("a.b.c.d"));
1171        assert!(!is_ipv4_formatted("1.2.3"));
1172        assert!(!is_ipv4_formatted("1.2.3.4.5"));
1173        assert!(!is_ipv4_formatted("1..2.3"));
1174        assert!(!is_ipv4_formatted(".1.2.3.4"));
1175    }
1176
1177    #[test]
1178    fn validates_account_charset() {
1179        assert!(is_valid_account("myacct1"));
1180        assert!(!is_valid_account("ab"));
1181        assert!(!is_valid_account("has-hyphen"));
1182        assert!(!is_valid_account(&"a".repeat(25)));
1183    }
1184
1185    #[test]
1186    fn validates_container_charset() {
1187        assert!(is_valid_container("my-container"));
1188        assert!(is_valid_container("a-b-c"));
1189        assert!(!is_valid_container("ab"));
1190        assert!(!is_valid_container("UPPER"));
1191        assert!(!is_valid_container(&"a".repeat(64)));
1192    }
1193
1194    #[test]
1195    fn rejects_container_with_dash_at_boundary() {
1196        assert!(!is_valid_container("-leading"));
1197        assert!(!is_valid_container("trailing-"));
1198    }
1199
1200    #[test]
1201    fn rejects_container_with_consecutive_dashes() {
1202        assert!(!is_valid_container("a--b"));
1203        assert!(!is_valid_container("foo--bar"));
1204    }
1205
1206    #[test]
1207    fn s3_addressing_heuristic() {
1208        // Auto-detection is now expressed as s3_virtual_hosted_bucket.is_some().
1209        assert!(s3_virtual_hosted_bucket("my-bucket.s3.us-west-2.amazonaws.com").is_some());
1210        assert!(s3_virtual_hosted_bucket("s3.us-west-2.amazonaws.com").is_none());
1211        assert!(s3_virtual_hosted_bucket("acc.r2.cloudflarestorage.com").is_none());
1212    }
1213
1214    #[test]
1215    fn s3_addressing_heuristic_dotted_bucket() {
1216        // Bucket names with embedded dots stretch the host across more
1217        // than two labels — auto-detection must still recognise the
1218        // virtual-hosted shape.
1219        assert!(s3_virtual_hosted_bucket("bucketname.com.s3.us-west-2.amazonaws.com").is_some());
1220        assert!(s3_virtual_hosted_bucket("my.dotted.s3.us-west-2.amazonaws.com").is_some());
1221        // Legacy `s3-<region>` hyphenated form.
1222        assert!(s3_virtual_hosted_bucket("bucketname.com.s3-us-west-2.amazonaws.com").is_some());
1223    }
1224
1225    #[test]
1226    fn s3_virtual_hosted_bucket_extracts_full_prefix() {
1227        assert_eq!(
1228            s3_virtual_hosted_bucket("my-bucket.s3.us-west-2.amazonaws.com"),
1229            Some("my-bucket".to_owned())
1230        );
1231        assert_eq!(
1232            s3_virtual_hosted_bucket("bucketname.com.s3.us-west-2.amazonaws.com"),
1233            Some("bucketname.com".to_owned())
1234        );
1235        assert_eq!(
1236            s3_virtual_hosted_bucket("my.dotted.s3.us-west-2.amazonaws.com"),
1237            Some("my.dotted".to_owned())
1238        );
1239        assert_eq!(
1240            s3_virtual_hosted_bucket("bucketname.com.s3-us-west-2.amazonaws.com"),
1241            Some("bucketname.com".to_owned())
1242        );
1243        // Path-style host has no `.s3.` infix preceded by anything —
1244        // returns None so the caller falls through.
1245        assert_eq!(s3_virtual_hosted_bucket("s3.us-west-2.amazonaws.com"), None);
1246        // Non-AWS host: no infix.
1247        assert_eq!(
1248            s3_virtual_hosted_bucket("acc.r2.cloudflarestorage.com"),
1249            None
1250        );
1251        // Pathological: bucket name itself contains `.s3.`. The
1252        // rightmost infix is the AWS service marker, so the full
1253        // bucket prefix is recovered.
1254        assert_eq!(
1255            s3_virtual_hosted_bucket("my.s3.bucket.s3.us-west-2.amazonaws.com"),
1256            Some("my.s3.bucket".to_owned())
1257        );
1258    }
1259
1260    #[test]
1261    fn azure_addressing_heuristic() {
1262        assert_eq!(
1263            detect_azure_addressing("my-account.blob.core.windows.net"),
1264            AzureAddressing::VirtualHosted
1265        );
1266        assert_eq!(
1267            detect_azure_addressing("127.0.0.1"),
1268            AzureAddressing::PathStyle
1269        );
1270    }
1271
1272    #[test]
1273    fn azure_path_style_with_account_only_rejects_missing_container() {
1274        // Path-style: host/account/container/prefix. Exactly one path
1275        // segment means the container is absent — must be a parse error.
1276        let err = parse("az+https://127.0.0.1/myaccount").unwrap_err();
1277        assert!(
1278            matches!(err, ParseError::MissingContainer),
1279            "expected MissingContainer, got {err:?}",
1280        );
1281    }
1282
1283    // --- StorageEngine and ?engine= flag ---------------------------------
1284
1285    #[test]
1286    fn engine_flag_absent_leaves_none() {
1287        let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
1288        assert_eq!(url.flags().engine, None);
1289    }
1290
1291    #[test]
1292    fn engine_flag_bundle_parses() {
1293        let url =
1294            parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=bundle").unwrap();
1295        assert_eq!(url.flags().engine, Some(StorageEngine::Bundle));
1296    }
1297
1298    #[test]
1299    fn engine_flag_rejects_unknown_value() {
1300        let err =
1301            parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=pack").unwrap_err();
1302        assert!(
1303            matches!(err, ParseError::UnknownEngine(ref s) if s == "pack"),
1304            "expected UnknownEngine(pack), got {err:?}",
1305        );
1306    }
1307
1308    #[test]
1309    fn engine_flag_rejects_empty_value() {
1310        let err =
1311            parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=").unwrap_err();
1312        assert!(
1313            matches!(err, ParseError::UnknownEngine(ref s) if s.is_empty()),
1314            "expected UnknownEngine(\"\"), got {err:?}",
1315        );
1316    }
1317
1318    #[test]
1319    fn unknown_engine_error_message_lists_every_supported_engine() {
1320        // Iterating over `StorageEngine::ALL` keeps this regression test
1321        // synchronised with the enum: a new variant whose name is missing
1322        // from the rendered diagnostic fails this assertion.
1323        let err =
1324            parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=pack").unwrap_err();
1325        let rendered = err.to_string();
1326        assert!(
1327            rendered.contains("unknown engine `pack`"),
1328            "missing rejected-value in `{rendered}`",
1329        );
1330        for engine in StorageEngine::ALL {
1331            assert!(
1332                rendered.contains(&format!("`{}`", engine.as_str())),
1333                "UnknownEngine message must mention engine `{}`, got `{rendered}`",
1334                engine.as_str(),
1335            );
1336        }
1337    }
1338
1339    #[test]
1340    fn engine_as_str_roundtrips() {
1341        assert_eq!(StorageEngine::Bundle.as_str(), "bundle");
1342        assert_eq!(StorageEngine::Bundle.to_string(), "bundle");
1343        assert_eq!(StorageEngine::Packchain.as_str(), "packchain");
1344        assert_eq!(StorageEngine::Packchain.to_string(), "packchain");
1345    }
1346
1347    #[test]
1348    fn engine_from_name_parses_known_and_rejects_unknown() {
1349        assert_eq!(
1350            StorageEngine::from_name("bundle"),
1351            Some(StorageEngine::Bundle)
1352        );
1353        assert_eq!(
1354            StorageEngine::from_name("packchain"),
1355            Some(StorageEngine::Packchain)
1356        );
1357        assert_eq!(StorageEngine::from_name("pack"), None);
1358        assert_eq!(StorageEngine::from_name(""), None);
1359        assert_eq!(StorageEngine::from_name("Bundle"), None); // case-sensitive
1360        assert_eq!(StorageEngine::from_name("Packchain"), None); // case-sensitive
1361    }
1362
1363    #[test]
1364    fn engine_flag_packchain_parses() {
1365        let url =
1366            parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain").unwrap();
1367        assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
1368    }
1369
1370    // --- bundle_uri flag (issue #71) -------------------------------------
1371
1372    #[test]
1373    fn bundle_uri_flag_absent_defaults_to_false() {
1374        let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
1375        assert!(!url.flags().bundle_uri);
1376    }
1377
1378    #[test]
1379    fn bundle_uri_flag_one_sets_true() {
1380        let url = parse(
1381            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
1382        )
1383        .unwrap();
1384        assert!(url.flags().bundle_uri);
1385    }
1386
1387    #[test]
1388    fn bundle_uri_flag_zero_sets_false() {
1389        let url = parse(
1390            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=0",
1391        )
1392        .unwrap();
1393        assert!(!url.flags().bundle_uri);
1394    }
1395
1396    // --- bundle_uri_presign_ttl flag (issue #76) -------------------------
1397
1398    #[test]
1399    fn bundle_uri_presign_ttl_absent_defaults_to_none() {
1400        let url = parse(
1401            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
1402        )
1403        .unwrap();
1404        assert_eq!(url.flags().bundle_uri_presign_ttl, None);
1405    }
1406
1407    #[test]
1408    fn bundle_uri_presign_ttl_positive_int_parses() {
1409        let url = parse(
1410            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1411             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
1412        )
1413        .unwrap();
1414        assert_eq!(
1415            url.flags().bundle_uri_presign_ttl,
1416            Some(NonZeroU64::new(3600).expect("3600 is non-zero")),
1417        );
1418    }
1419
1420    #[test]
1421    fn bundle_uri_presign_ttl_one_second_accepted() {
1422        // Useless in practice but the type-system contract is "any
1423        // positive value"; operator's prerogative to choose.
1424        let url = parse(
1425            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1426             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=1",
1427        )
1428        .unwrap();
1429        assert_eq!(
1430            url.flags().bundle_uri_presign_ttl,
1431            Some(NonZeroU64::new(1).expect("1 is non-zero")),
1432        );
1433    }
1434
1435    #[test]
1436    fn bundle_uri_presign_ttl_zero_rejected() {
1437        // Zero-second TTL is meaningless; reject at the boundary
1438        // rather than letting the bad value flow into the
1439        // (presigning) backend.
1440        let err = parse(
1441            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1442             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=0",
1443        )
1444        .unwrap_err();
1445        assert!(
1446            matches!(
1447                err,
1448                ParseError::InvalidFlagValue { ref name, ref value }
1449                    if name == "bundle_uri_presign_ttl" && value == "0"
1450            ),
1451            "expected InvalidFlagValue {{ name: bundle_uri_presign_ttl, value: 0 }}, got {err:?}",
1452        );
1453    }
1454
1455    #[test]
1456    fn bundle_uri_presign_ttl_non_numeric_rejected() {
1457        let err = parse(
1458            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1459             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=abc",
1460        )
1461        .unwrap_err();
1462        assert!(
1463            matches!(
1464                err,
1465                ParseError::InvalidFlagValue { ref name, ref value }
1466                    if name == "bundle_uri_presign_ttl" && value == "abc"
1467            ),
1468            "expected InvalidFlagValue, got {err:?}",
1469        );
1470    }
1471
1472    #[test]
1473    fn bundle_uri_presign_ttl_negative_rejected() {
1474        // u64 parser rejects negative input; surface as InvalidFlagValue.
1475        let err = parse(
1476            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1477             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=-1",
1478        )
1479        .unwrap_err();
1480        assert!(
1481            matches!(err, ParseError::InvalidFlagValue { ref name, .. } if name == "bundle_uri_presign_ttl"),
1482            "expected InvalidFlagValue, got {err:?}",
1483        );
1484    }
1485
1486    /// Issue #219: huge values panic the Azure SAS builder via
1487    /// `time::Duration::seconds_f64`. The URL boundary caps the flag
1488    /// at [`MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS`] (7 days) so the bad
1489    /// value never reaches the helper, matching the AWS SDK's hard
1490    /// ceiling.
1491    #[test]
1492    fn bundle_uri_presign_ttl_above_seven_days_rejected() {
1493        let err = parse(
1494            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1495             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=604801",
1496        )
1497        .unwrap_err();
1498        assert!(
1499            matches!(
1500                err,
1501                ParseError::BundleUriPresignTtlTooLarge { value, max }
1502                    if value == 604_801 && max == MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS
1503            ),
1504            "expected BundleUriPresignTtlTooLarge {{ value: 604801, max: {MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS} }}, got {err:?}",
1505        );
1506    }
1507
1508    /// Issue #219: the pathological `u64::MAX`-class value reported
1509    /// in the bug must be rejected at the URL boundary with a clean
1510    /// error rather than panicking the helper.
1511    #[test]
1512    fn bundle_uri_presign_ttl_huge_value_rejected_not_panic() {
1513        let err = parse(
1514            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1515             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=999999999999999999",
1516        )
1517        .unwrap_err();
1518        assert!(
1519            matches!(
1520                err,
1521                ParseError::BundleUriPresignTtlTooLarge { value, .. }
1522                    if value == 999_999_999_999_999_999
1523            ),
1524            "expected BundleUriPresignTtlTooLarge for huge value, got {err:?}",
1525        );
1526    }
1527
1528    /// Issue #219: the 7-day boundary value itself is accepted so
1529    /// operators can express AWS's spec-mandated maximum.
1530    #[test]
1531    fn bundle_uri_presign_ttl_exactly_seven_days_accepted() {
1532        let url = parse(
1533            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1534             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=604800",
1535        )
1536        .unwrap();
1537        assert_eq!(
1538            url.flags().bundle_uri_presign_ttl,
1539            Some(
1540                NonZeroU64::new(MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS).expect("7-day cap is non-zero")
1541            ),
1542        );
1543    }
1544
1545    #[test]
1546    fn engine_flag_packchain_on_azure_url() {
1547        let url =
1548            parse("az+https://myaccount.blob.core.windows.net/my-container/repo?engine=packchain")
1549                .unwrap();
1550        assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
1551    }
1552
1553    #[test]
1554    fn engine_flag_on_azure_url() {
1555        let url =
1556            parse("az+https://myaccount.blob.core.windows.net/my-container/repo?engine=bundle")
1557                .unwrap();
1558        assert_eq!(url.flags().engine, Some(StorageEngine::Bundle));
1559    }
1560
1561    // --- AWS S3 endpoint host validation ------------------------------------
1562
1563    #[test]
1564    fn rejects_amazonaws_host_missing_s3_service_marker() {
1565        // The common mistake: <bucket>.<region>.amazonaws.com — no `.s3.`.
1566        let err = parse("s3+https://git-test-2224.us-west-2.amazonaws.com/git-remote-object-store")
1567            .unwrap_err();
1568        assert!(
1569            matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "git-test-2224.us-west-2.amazonaws.com"),
1570            "expected InvalidAwsS3Endpoint, got {err:?}",
1571        );
1572    }
1573
1574    #[test]
1575    fn accepts_valid_aws_s3_hosts() {
1576        // Virtual-hosted with region.
1577        parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
1578        // Virtual-hosted without region (legacy global).
1579        parse("s3+https://my-bucket.s3.amazonaws.com/repo").unwrap();
1580        // Virtual-hosted legacy hyphenated region.
1581        parse("s3+https://my-bucket.s3-us-west-2.amazonaws.com/repo").unwrap();
1582        // Path-style with region.
1583        parse("s3+https://s3.us-west-2.amazonaws.com/my-bucket/repo").unwrap();
1584        // Path-style without region (legacy global).
1585        parse("s3+https://s3.amazonaws.com/my-bucket/repo").unwrap();
1586        // Path-style legacy hyphenated region (`s3-<region>.amazonaws.com`).
1587        parse("s3+https://s3-us-east-1.amazonaws.com/my-bucket/repo").unwrap();
1588        // China partition (`.amazonaws.com.cn`): both addressing styles.
1589        parse("s3+https://my-bucket.s3.cn-north-1.amazonaws.com.cn/repo").unwrap();
1590        parse("s3+https://s3.cn-north-1.amazonaws.com.cn/my-bucket/repo").unwrap();
1591    }
1592
1593    #[test]
1594    fn rejects_china_amazonaws_host_missing_s3_service_marker() {
1595        // Same typo class as `rejects_amazonaws_host_missing_s3_service_marker`
1596        // but on the China partition (`.amazonaws.com.cn`). The typo
1597        // `<bucket>.<region>.amazonaws.com.cn` (no `.s3.` marker) must
1598        // produce the helpful `InvalidAwsS3Endpoint`, not a silent fall-
1599        // through to PathStyle and a DNS error at connect time.
1600        let err = parse("s3+https://git-test.cn-north-1.amazonaws.com.cn/repo").unwrap_err();
1601        assert!(
1602            matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "git-test.cn-north-1.amazonaws.com.cn"),
1603            "expected InvalidAwsS3Endpoint, got {err:?}",
1604        );
1605    }
1606
1607    #[test]
1608    fn check_aws_s3_host_runs_before_addressing_override() {
1609        // Policy: `?addressing=path` (or `=virtual`) on an AWS hostname
1610        // does NOT bypass the validator. AWS owns `.amazonaws.com[.cn]`,
1611        // so any host on those suffixes that is not a recognised S3
1612        // endpoint is a typo. A user who needs path-style addressing on a
1613        // vanity host should use a domain they own, not `.amazonaws.com`.
1614        let err =
1615            parse("s3+https://corp.amazonaws.com/my-bucket/repo?addressing=path").unwrap_err();
1616        assert!(
1617            matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "corp.amazonaws.com"),
1618            "expected InvalidAwsS3Endpoint, got {err:?}",
1619        );
1620        let err =
1621            parse("s3+https://corp.amazonaws.com/my-bucket/repo?addressing=virtual").unwrap_err();
1622        assert!(
1623            matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "corp.amazonaws.com"),
1624            "expected InvalidAwsS3Endpoint, got {err:?}",
1625        );
1626    }
1627
1628    #[test]
1629    fn accepts_s3_prefix_known_false_negative() {
1630        // `s3-<non-region>.amazonaws.com` passes `check_aws_s3_host` because
1631        // the `starts_with("s3-")` guard does not validate the region name.
1632        // Pinned here to document the known false-negative: the parse
1633        // succeeds, but the user will see a DNS error at connect time rather
1634        // than the helpful `InvalidAwsS3Endpoint` message. The valid legacy
1635        // form (`s3-us-east-1`) and this false-negative are accepted by the
1636        // same branch; a tightening that rejects false-negatives must not
1637        // break valid legacy inputs.
1638        parse("s3+https://s3-mybucket.amazonaws.com/my-bucket/repo").unwrap();
1639    }
1640
1641    #[test]
1642    fn accepts_non_aws_s3_compatible_hosts() {
1643        // MinIO, Cloudflare R2, and other S3-compatible services that do
1644        // not use `.amazonaws.com` are not subject to the service-marker check.
1645        parse("s3+https://play.min.io/my-bucket/repo").unwrap();
1646        parse("s3+https://acc.r2.cloudflarestorage.com/my-bucket/repo").unwrap();
1647        parse("s3+https://localhost/my-bucket/repo?zip=0").unwrap();
1648    }
1649
1650    // --- Boolean-value vocabulary (issue #187) ----------------------------
1651    //
1652    // The same `parse_bool_value` helper governs both URL query flags
1653    // (`?zip=`, `?bundle_uri=`) and env-var booleans (`ALLOW_HTTP`).
1654    // The matrix below pins the accepted set so the two surfaces stay
1655    // in sync.
1656
1657    #[test]
1658    fn parse_bool_value_accepts_truthy_tokens() {
1659        for v in ["1", "true", "yes", "on"] {
1660            assert_eq!(parse_bool_value(v), Some(true), "expected true for `{v}`");
1661        }
1662    }
1663
1664    #[test]
1665    fn parse_bool_value_accepts_falsy_tokens() {
1666        for v in ["0", "false", "no", "off"] {
1667            assert_eq!(parse_bool_value(v), Some(false), "expected false for `{v}`");
1668        }
1669    }
1670
1671    #[test]
1672    fn parse_bool_value_is_case_insensitive() {
1673        // Per-value matrix covering common mixed-case spellings users
1674        // type ad-hoc. The helper must accept every casing for every
1675        // accepted token; this loop checks the full cross product.
1676        for (input, expected) in [
1677            ("TRUE", true),
1678            ("True", true),
1679            ("tRuE", true),
1680            ("YES", true),
1681            ("Yes", true),
1682            ("ON", true),
1683            ("On", true),
1684            ("FALSE", false),
1685            ("False", false),
1686            ("NO", false),
1687            ("No", false),
1688            ("OFF", false),
1689            ("Off", false),
1690        ] {
1691            assert_eq!(
1692                parse_bool_value(input),
1693                Some(expected),
1694                "expected {expected} for `{input}`",
1695            );
1696        }
1697    }
1698
1699    #[test]
1700    fn parse_bool_value_rejects_unknown_tokens() {
1701        // Empty string, near-misses, common typos, and arbitrary
1702        // junk must all fall through to `None` so the URL-flag path
1703        // can surface `InvalidFlagValue` and the env-var path can
1704        // fall back to "unset". Picking "y"/"n" as rejected pins the
1705        // policy: short forms are NOT accepted (issue #187 left this
1706        // explicit to avoid surprising aliases).
1707        for v in [
1708            "", " ", "yep", "nope", "2", "-1", "truee", "y", "n", "enabled",
1709        ] {
1710            assert_eq!(parse_bool_value(v), None, "expected None for `{v}`");
1711        }
1712    }
1713
1714    #[test]
1715    fn parse_bool_flag_propagates_invalid_flag_value_error() {
1716        // Names propagated into the error must match the flag the
1717        // user typed so the diagnostic stays useful.
1718        let err = parse_bool_flag("zip", "maybe").unwrap_err();
1719        assert!(
1720            matches!(&err, ParseError::InvalidFlagValue { name, value }
1721                if name == "zip" && value == "maybe"),
1722            "expected InvalidFlagValue(zip, maybe), got {err:?}",
1723        );
1724    }
1725
1726    #[test]
1727    fn url_bool_flags_accept_mixed_case_and_extended_vocabulary() {
1728        // Per-value coverage at the URL surface: `?zip=` and
1729        // `?bundle_uri=` must accept every truthy / falsy token the
1730        // helper recognises. Loopback host keeps this independent of
1731        // the AWS-endpoint validator.
1732        for v in ["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"] {
1733            let url = parse(&format!("s3+https://localhost/my-bucket/repo?zip={v}")).unwrap();
1734            assert!(url.flags().zip, "expected zip=true for `{v}`");
1735        }
1736        for v in ["0", "false", "False", "FALSE", "no", "No", "off", "OFF"] {
1737            let url = parse(&format!("s3+https://localhost/my-bucket/repo?zip={v}")).unwrap();
1738            assert!(!url.flags().zip, "expected zip=false for `{v}`");
1739        }
1740    }
1741
1742    #[test]
1743    fn url_bool_flags_reject_unknown_value_with_flag_name() {
1744        let err = parse("s3+https://localhost/my-bucket/repo?zip=maybe").unwrap_err();
1745        assert!(
1746            matches!(&err, ParseError::InvalidFlagValue { name, value }
1747                if name == "zip" && value == "maybe"),
1748            "expected InvalidFlagValue(zip, maybe), got {err:?}",
1749        );
1750
1751        let err = parse("s3+https://localhost/my-bucket/repo?bundle_uri=2").unwrap_err();
1752        assert!(
1753            matches!(&err, ParseError::InvalidFlagValue { name, value }
1754                if name == "bundle_uri" && value == "2"),
1755            "expected InvalidFlagValue(bundle_uri, 2), got {err:?}",
1756        );
1757    }
1758
1759    // --- backend-specific flag pairing (issue #245) ----------------------
1760
1761    #[test]
1762    fn azure_url_rejects_s3_only_profile_flag() {
1763        let err =
1764            parse("az+https://myaccount.blob.core.windows.net/my-container/repo?profile=prod")
1765                .unwrap_err();
1766        assert!(
1767            matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1768                if flag == "profile" && *backend == BackendKind::Azure),
1769            "expected FlagNotApplicable(profile, Azure), got {err:?}",
1770        );
1771    }
1772
1773    #[test]
1774    fn azure_url_rejects_s3_only_region_flag() {
1775        let err =
1776            parse("az+https://myaccount.blob.core.windows.net/my-container/repo?region=us-east-1")
1777                .unwrap_err();
1778        assert!(
1779            matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1780                if flag == "region" && *backend == BackendKind::Azure),
1781            "expected FlagNotApplicable(region, Azure), got {err:?}",
1782        );
1783    }
1784
1785    #[test]
1786    fn s3_url_rejects_azure_only_credential_flag() {
1787        let err = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?credential=ci-cd")
1788            .unwrap_err();
1789        assert!(
1790            matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1791                if flag == "credential" && *backend == BackendKind::S3),
1792            "expected FlagNotApplicable(credential, S3), got {err:?}",
1793        );
1794    }
1795
1796    #[test]
1797    fn inapplicable_flag_rejected_even_with_empty_value() {
1798        // An empty value still records `Some("")`, so the pairing check
1799        // must fire — fail-fast does not depend on the value being
1800        // non-empty.
1801        let err = parse("az+https://myaccount.blob.core.windows.net/my-container/repo?profile=")
1802            .unwrap_err();
1803        assert!(
1804            matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1805                if flag == "profile" && *backend == BackendKind::Azure),
1806            "expected FlagNotApplicable(profile, Azure), got {err:?}",
1807        );
1808    }
1809
1810    #[test]
1811    fn flag_not_applicable_message_names_flag_and_backend() {
1812        let err =
1813            parse("az+https://myaccount.blob.core.windows.net/my-container/repo?region=us-east-1")
1814                .unwrap_err();
1815        let rendered = err.to_string();
1816        assert!(
1817            rendered.contains("`region`") && rendered.contains("Azure"),
1818            "message must name the flag and backend, got `{rendered}`",
1819        );
1820    }
1821
1822    #[test]
1823    fn valid_backend_flag_pairings_still_parse() {
1824        // S3 consumes `profile` and `region`; Azure consumes
1825        // `credential`. The pairing check must leave these untouched.
1826        let s3 = parse(
1827            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1828             ?profile=prod&region=us-east-1",
1829        )
1830        .unwrap();
1831        assert_eq!(s3.flags().profile.as_deref(), Some("prod"));
1832        assert_eq!(s3.flags().region.as_deref(), Some("us-east-1"));
1833        assert_eq!(s3.flags().credential, None);
1834
1835        let azure =
1836            parse("az+https://myaccount.blob.core.windows.net/my-container/repo?credential=ci-cd")
1837                .unwrap();
1838        assert_eq!(azure.flags().credential.as_deref(), Some("ci-cd"));
1839        assert_eq!(azure.flags().profile, None);
1840        assert_eq!(azure.flags().region, None);
1841    }
1842
1843    // --- bundle_uri_presign_ttl cross-flag validation (issue #246) -------
1844    //
1845    // The TTL only governs presigning of the `bundle.<ref>.uri=` lines,
1846    // which are advertised only when `?bundle_uri=1` is set. Supplying the
1847    // TTL without `?bundle_uri=1` is a no-op and must be rejected at the
1848    // URL boundary rather than stored and silently ignored. The engine is
1849    // NOT gated here: it is resolved from the bucket `FORMAT` at connect
1850    // time, so a URL carrying `?bundle_uri=1` and the TTL must parse
1851    // regardless of the `?engine=` flag (or its absence).
1852
1853    #[test]
1854    fn presign_ttl_without_bundle_uri_rejected() {
1855        // packchain engine, but bundle_uri is unset (defaults to false).
1856        let err = parse(
1857            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1858             ?engine=packchain&bundle_uri_presign_ttl=3600",
1859        )
1860        .unwrap_err();
1861        assert_eq!(err, ParseError::BundleUriPresignTtlWithoutBundleUri);
1862    }
1863
1864    #[test]
1865    fn presign_ttl_with_bundle_uri_disabled_rejected() {
1866        // packchain engine and the TTL, but bundle_uri is explicitly off.
1867        let err = parse(
1868            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1869             ?engine=packchain&bundle_uri=0&bundle_uri_presign_ttl=3600",
1870        )
1871        .unwrap_err();
1872        assert_eq!(err, ParseError::BundleUriPresignTtlWithoutBundleUri);
1873    }
1874
1875    #[test]
1876    fn presign_ttl_with_explicit_bundle_engine_still_parses() {
1877        // `?engine=bundle` is the URL flag, but the runtime engine is
1878        // resolved from the bucket `FORMAT`, not the flag — the same
1879        // bucket may be packchain. The parser cannot know, so as long as
1880        // `?bundle_uri=1` is present it must accept the TTL (a bundle
1881        // bucket simply leaves it inert downstream, exactly as it does
1882        // the `?bundle_uri=1` flag itself). Rejecting here would break a
1883        // valid packchain reconnect that happens to carry `?engine=bundle`.
1884        let url = parse(
1885            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1886             ?engine=bundle&bundle_uri=1&bundle_uri_presign_ttl=3600",
1887        )
1888        .unwrap();
1889        assert_eq!(
1890            url.flags().bundle_uri_presign_ttl,
1891            Some(NonZeroU64::new(3600).unwrap())
1892        );
1893    }
1894
1895    #[test]
1896    fn presign_ttl_without_engine_flag_still_parses() {
1897        // Regression for the steady-state packchain reconnect: a packchain
1898        // bucket is routinely connected with `?bundle_uri=1` and the TTL
1899        // but no `?engine=packchain` (the engine comes from `FORMAT`). The
1900        // URL parser must not reject this — gating the TTL on the URL
1901        // engine flag would abort every fetch/push on such a bucket.
1902        let url = parse(
1903            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1904             ?bundle_uri=1&bundle_uri_presign_ttl=3600",
1905        )
1906        .unwrap();
1907        assert_eq!(url.flags().engine, None);
1908        assert!(url.flags().bundle_uri);
1909        assert_eq!(
1910            url.flags().bundle_uri_presign_ttl,
1911            Some(NonZeroU64::new(3600).unwrap())
1912        );
1913    }
1914
1915    #[test]
1916    fn presign_ttl_with_packchain_and_bundle_uri_parses() {
1917        // The one valid configuration: packchain + bundle_uri=1 + TTL.
1918        let url = parse(
1919            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1920             ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
1921        )
1922        .unwrap();
1923        assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
1924        assert!(url.flags().bundle_uri);
1925        assert_eq!(
1926            url.flags().bundle_uri_presign_ttl,
1927            Some(NonZeroU64::new(3600).unwrap())
1928        );
1929    }
1930
1931    #[test]
1932    fn packchain_and_bundle_uri_without_ttl_parses() {
1933        // The TTL is optional: the valid pairing must still parse when it
1934        // is absent, leaving the field None.
1935        let url = parse(
1936            "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1937             ?engine=packchain&bundle_uri=1",
1938        )
1939        .unwrap();
1940        assert_eq!(url.flags().bundle_uri_presign_ttl, None);
1941    }
1942}