Skip to main content

self_update/backends/
s3.rs

1/*!
2Amazon S3 releases
3*/
4use crate::backends::common::{CommonBuilderConfig, CommonConfig, RequestConfig};
5use crate::backends::{Page, PageRequest, run_paginated};
6use crate::{
7    errors::*,
8    update::{Release, ReleaseAsset, ReleaseUpdate, Releases},
9    version::bump_is_greater,
10};
11use log::debug;
12use quick_xml::Reader;
13use quick_xml::events::Event;
14use regex::Regex;
15use std::path::PathBuf;
16use std::sync::LazyLock;
17use std::time::Duration;
18
19/// Filename -> `(name, version)` matcher for S3 asset keys, e.g. `myapp-v1.2.3-x86_64-linux`.
20///
21/// Hoisted to a process-wide `LazyLock` so it is compiled once rather than on every listing page
22/// parsed by [`parse_s3_response`]. The pattern is a compile-time literal and is known-valid, so
23/// `Regex::new` cannot fail here.
24static ASSET_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
25    Regex::new(r"(?i)(?P<prefix>.*/)*(?P<name>.+)-[v]{0,1}(?P<version>\d+\.\d+\.\d+)-.+")
26        .expect("the S3 asset-key regex is a valid compile-time literal")
27});
28
29/// Default number of items to retrieve per S3 listing request. The S3 ListObjectsV2 API caps a
30/// single request at 1000 keys.
31const DEFAULT_MAX_KEYS: u16 = 1000;
32
33/// Default presigned-URL expiry (SigV4 `X-Amz-Expires`), in seconds.
34#[cfg(feature = "s3-auth")]
35const DEFAULT_SIGNATURE_TTL_SECS: u64 = 300;
36
37/// Clamp a requested `max-keys` page size into the `1..=1000` range the S3 ListObjectsV2 API
38/// supports.
39fn clamp_max_keys(max_keys: u16) -> u16 {
40    max_keys.clamp(1, DEFAULT_MAX_KEYS)
41}
42
43/// The AWS SigV4 `X-Amz-Expires` bound: a presigned URL may live between 1 second and 7 days.
44#[cfg(feature = "s3-auth")]
45const MAX_SIGNATURE_TTL_SECS: u64 = 604_800;
46
47/// Clamp a requested presigned-URL TTL into AWS's `1..=604800` (1s..7d) `X-Amz-Expires` range, so a
48/// too-long TTL does not sign successfully only to be rejected by S3 at request time.
49#[cfg(feature = "s3-auth")]
50fn clamp_signature_ttl(ttl: Duration) -> Duration {
51    Duration::from_secs(ttl.as_secs().clamp(1, MAX_SIGNATURE_TTL_SECS))
52}
53
54/// Re-export the S3 [`AccessKey`] credential type at the backend module level so consumers can
55/// name it as `self_update::backends::s3::AccessKey` (e.g. to build one explicitly). Available
56/// under the `s3-auth` feature.
57#[cfg(feature = "s3-auth")]
58pub use auth::AccessKey;
59
60/// The service endpoint.
61///
62#[allow(clippy::upper_case_acronyms)]
63#[derive(Clone, Debug, Default)]
64#[non_exhaustive]
65pub enum Endpoint {
66    /// Short for `https://<bucket>.s3.<region>.amazonaws.com/`
67    #[default]
68    S3,
69    /// Short for `https://<bucket>.s3.dualstack.<region>.amazonaws.com/`
70    S3DualStack,
71    /// Short for `https://storage.googleapis.com/<bucket>/`
72    GCS,
73    /// Short for `https://<bucket>.<region>.digitaloceanspaces.com/`
74    DigitalOceanSpaces,
75    /// Generic, for other s3 compatible providers. Holds the full URL of the endpoint, e.g.
76    /// `https://bucket.s3.example.com/` or `https://s3.example.com/bucket/`.
77    Generic(String),
78}
79
80impl From<&str> for Endpoint {
81    fn from(value: &str) -> Self {
82        Self::Generic(value.to_owned())
83    }
84}
85
86impl From<String> for Endpoint {
87    fn from(value: String) -> Self {
88        Self::Generic(value)
89    }
90}
91
92/// Whether `endpoint` needs a `region` to form its URL. The AWS-family endpoints embed the region
93/// in the host; `GCS` and `Generic` do not use it.
94fn endpoint_requires_region(endpoint: &Endpoint) -> bool {
95    matches!(
96        endpoint,
97        Endpoint::S3 | Endpoint::S3DualStack | Endpoint::DigitalOceanSpaces
98    )
99}
100
101/// Validate the endpoint/region pairing at build time so a missing `region` is reported from
102/// `build()` (like every other required field) rather than at the first network call.
103fn check_endpoint_region(endpoint: &Endpoint, region: &Option<String>) -> Result<()> {
104    if endpoint_requires_region(endpoint) && region.is_none() {
105        return Err(Error::MissingField { field: "region" });
106    }
107    Ok(())
108}
109
110/// `ReleaseList` Builder
111#[derive(Clone, Debug)]
112#[must_use]
113pub struct ReleaseListBuilder {
114    endpoint: Endpoint,
115    bucket_name: Option<String>,
116    asset_prefix: Option<String>,
117    target: Option<String>,
118    region: Option<String>,
119    max_keys: u16,
120    #[cfg(feature = "s3-auth")]
121    signature_ttl: Duration,
122    #[cfg(feature = "s3-auth")]
123    access_key: Option<auth::AccessKey>,
124    request: RequestConfig,
125}
126
127impl ReleaseListBuilder {
128    /// Set the bucket name, used to build an S3 api url
129    pub fn bucket_name(&mut self, name: impl Into<String>) -> &mut Self {
130        self.bucket_name = Some(name.into());
131        self
132    }
133
134    /// Set the per-request `max-keys` page size for the bucket listing (default `1000`). Clamped
135    /// to `1..=1000` (the ListObjectsV2 cap). The listing follows continuation tokens, so a
136    /// truncated page is still fully walked across multiple requests; this only tunes the page size.
137    pub fn max_keys(&mut self, max_keys: u16) -> &mut Self {
138        self.max_keys = clamp_max_keys(max_keys);
139        self
140    }
141
142    /// Set the presigned-URL expiry applied to SigV4-signed listing and download URLs under the
143    /// `s3-auth` feature (default 300s). Clamped to AWS's `X-Amz-Expires` range of 1s..=7d.
144    #[cfg(feature = "s3-auth")]
145    pub fn signature_ttl(&mut self, ttl: Duration) -> &mut Self {
146        self.signature_ttl = clamp_signature_ttl(ttl);
147        self
148    }
149
150    /// Set an optional S3 key prefix, sent as the `prefix=` parameter of the bucket listing.
151    ///
152    /// This scopes the listing to keys under that prefix (e.g. a subdirectory) on the S3 side; it
153    /// is **not** a client-side filter of asset file names (for that, see
154    /// [`filter_target`](Self::filter_target)). The prefix is stripped from the returned names, so a
155    /// `releases/` prefix lets you keep assets in a subdirectory.
156    pub fn asset_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
157        self.asset_prefix = Some(prefix.into());
158        self
159    }
160
161    /// Set the S3 region embedded in the endpoint host.
162    ///
163    /// Required for the `S3`, `S3DualStack`, and `DigitalOceanSpaces` endpoints (it is part of
164    /// their hostname) and validated by [`build`](Self::build). Ignored by `GCS` and `Generic`
165    /// endpoints, which carry no region in the URL (under `s3-auth`, SigV4 still defaults the
166    /// signing region to `us-east-1` when none is set).
167    pub fn region(&mut self, region: impl Into<String>) -> &mut Self {
168        self.region = Some(region.into());
169        self
170    }
171
172    /// Set the S3 endpoint (the storage endpoint the bucket listing is issued against).
173    ///
174    /// Note: this `endpoint` is the *S3 storage endpoint*, a deliberately different notion from the
175    /// server URL of the other backends. The github backend uses `api_base_url` (the full API base
176    /// URL) and the gitlab/gitea backends use `host` (the instance host); s3's `endpoint` is neither
177    /// of those. The differing names reflect that semantic divergence rather than an inconsistency.
178    pub fn endpoint(&mut self, endpoint: impl Into<Endpoint>) -> &mut Self {
179        self.endpoint = endpoint.into();
180        self
181    }
182
183    /// Set the optional arch `target` name, used to filter the releases this list returns to those
184    /// carrying an asset whose name contains `target`.
185    ///
186    /// This is the **`ReleaseList`** filter and differs from
187    /// [`Update::target`](UpdateBuilder::target): `filter_target` drops whole releases from the
188    /// listing when no asset matches, whereas the `Update` `target` selects *which asset* of the
189    /// chosen release to download.
190    pub fn filter_target(&mut self, target: impl Into<String>) -> &mut Self {
191        self.target = Some(target.into());
192        self
193    }
194
195    #[cfg(feature = "s3-auth")]
196    /// Set the access key
197    pub fn access_key(&mut self, access_key: impl Into<auth::AccessKey>) -> &mut Self {
198        self.access_key = Some(access_key.into());
199        self
200    }
201
202    request_config_setters!(request);
203
204    /// Verify builder args, returning a `ReleaseList`
205    pub fn build(&self) -> Result<ReleaseList> {
206        let mut request = self.request.clone();
207        request.build_client();
208        request.check()?;
209        check_endpoint_region(&self.endpoint, &self.region)?;
210        Ok(ReleaseList {
211            endpoint: self.endpoint.clone(),
212            bucket_name: if let Some(ref name) = self.bucket_name {
213                name.to_owned()
214            } else {
215                return Err(Error::MissingField {
216                    field: "bucket_name",
217                });
218            },
219            region: self.region.clone(),
220            asset_prefix: self.asset_prefix.clone(),
221            target: self.target.clone(),
222            max_keys: self.max_keys,
223            #[cfg(feature = "s3-auth")]
224            signature_ttl: self.signature_ttl,
225            #[cfg(feature = "s3-auth")]
226            access_key: self.access_key.clone(),
227            request,
228        })
229    }
230}
231
232/// `ReleaseList` provides a builder api for querying an S3 bucket,
233/// returning a `Vec` of available `Release`s
234#[derive(Clone, Debug)]
235pub struct ReleaseList {
236    endpoint: Endpoint,
237    bucket_name: String,
238    asset_prefix: Option<String>,
239    target: Option<String>,
240    region: Option<String>,
241    max_keys: u16,
242    #[cfg(feature = "s3-auth")]
243    signature_ttl: Duration,
244    #[cfg(feature = "s3-auth")]
245    access_key: Option<auth::AccessKey>,
246    request: RequestConfig,
247}
248
249impl ReleaseList {
250    /// Initialize a ReleaseListBuilder
251    pub fn configure() -> ReleaseListBuilder {
252        ReleaseListBuilder {
253            endpoint: Endpoint::default(),
254            bucket_name: None,
255            asset_prefix: None,
256            target: None,
257            region: None,
258            max_keys: DEFAULT_MAX_KEYS,
259            #[cfg(feature = "s3-auth")]
260            signature_ttl: Duration::from_secs(DEFAULT_SIGNATURE_TTL_SECS),
261            #[cfg(feature = "s3-auth")]
262            access_key: None,
263            request: RequestConfig::default(),
264        }
265    }
266
267    /// Retrieve the available `Release`s as a [`Releases`].
268    ///
269    /// If a `filter_target` is set, only releases carrying an asset whose name contains it are
270    /// returned. The result carries no current version (it is a bare listing), so
271    /// [`Releases::current_version`] is `None`; use [`Releases::into_vec`] to recover the raw
272    /// `Vec<Release>`.
273    pub fn fetch(&self) -> Result<Releases> {
274        let plan = s3_listing_plan(
275            &self.endpoint,
276            &self.bucket_name,
277            &self.region,
278            &self.asset_prefix,
279            self.max_keys,
280            #[cfg(feature = "s3-auth")]
281            self.signature_ttl,
282            #[cfg(feature = "s3-auth")]
283            &self.access_key,
284        )?;
285        let releases = run_paginated(plan, &self.request)?;
286        let releases = match self.target {
287            None => releases,
288            Some(ref target) => releases
289                .into_iter()
290                .filter(|r| r.has_target_asset(target))
291                .collect::<Vec<_>>(),
292        };
293        Ok(Releases::from_listing(releases))
294    }
295
296    /// Async sibling of [`fetch`](Self::fetch).
297    #[cfg(feature = "async")]
298    pub async fn fetch_async(&self) -> Result<Releases> {
299        let plan = s3_listing_plan(
300            &self.endpoint,
301            &self.bucket_name,
302            &self.region,
303            &self.asset_prefix,
304            self.max_keys,
305            #[cfg(feature = "s3-auth")]
306            self.signature_ttl,
307            #[cfg(feature = "s3-auth")]
308            &self.access_key,
309        )?;
310        let releases = crate::backends::run_paginated_async(plan, &self.request).await?;
311        let releases = match self.target {
312            None => releases,
313            Some(ref target) => releases
314                .into_iter()
315                .filter(|r| r.has_target_asset(target))
316                .collect::<Vec<_>>(),
317        };
318        Ok(Releases::from_listing(releases))
319    }
320}
321
322/// `s3::Update` builder
323///
324/// Configure download and installation from
325/// `https://<bucket_name>.s3.<region>.amazonaws.com/<asset filename>`
326#[derive(Clone, Debug)]
327#[must_use]
328pub struct UpdateBuilder {
329    endpoint: Endpoint,
330    bucket_name: Option<String>,
331    asset_prefix: Option<String>,
332    region: Option<String>,
333    max_keys: u16,
334    #[cfg(feature = "s3-auth")]
335    signature_ttl: Duration,
336    #[cfg(feature = "s3-auth")]
337    access_key: Option<auth::AccessKey>,
338    common: CommonBuilderConfig,
339}
340
341impl Default for UpdateBuilder {
342    fn default() -> Self {
343        Self {
344            endpoint: Endpoint::default(),
345            bucket_name: None,
346            asset_prefix: None,
347            region: None,
348            max_keys: DEFAULT_MAX_KEYS,
349            #[cfg(feature = "s3-auth")]
350            signature_ttl: Duration::from_secs(DEFAULT_SIGNATURE_TTL_SECS),
351            #[cfg(feature = "s3-auth")]
352            access_key: None,
353            common: CommonBuilderConfig::default(),
354        }
355    }
356}
357
358/// Configure download and installation from bucket
359impl UpdateBuilder {
360    /// Initialize a new builder
361    pub fn new() -> Self {
362        Default::default()
363    }
364
365    /// Set the per-request `max-keys` page size for the bucket listing (default `1000`). Clamped
366    /// to `1..=1000` (the ListObjectsV2 cap). The listing follows continuation tokens, so a
367    /// truncated page is still fully walked across multiple requests; this only tunes the page size.
368    pub fn max_keys(&mut self, max_keys: u16) -> &mut Self {
369        self.max_keys = clamp_max_keys(max_keys);
370        self
371    }
372
373    /// Set the presigned-URL expiry applied to SigV4-signed listing and download URLs under the
374    /// `s3-auth` feature (default 300s). Clamped to AWS's `X-Amz-Expires` range of 1s..=7d.
375    #[cfg(feature = "s3-auth")]
376    pub fn signature_ttl(&mut self, ttl: Duration) -> &mut Self {
377        self.signature_ttl = clamp_signature_ttl(ttl);
378        self
379    }
380
381    /// Set the S3 endpoint (the storage endpoint the bucket listing is issued against).
382    ///
383    /// Note: this `endpoint` is the *S3 storage endpoint*, a deliberately different notion from the
384    /// server URL of the other backends. The github backend uses `api_base_url` (the full API base
385    /// URL) and the gitlab/gitea backends use `host` (the instance host); s3's `endpoint` is neither
386    /// of those. The differing names reflect that semantic divergence rather than an inconsistency.
387    pub fn endpoint(&mut self, endpoint: impl Into<Endpoint>) -> &mut Self {
388        self.endpoint = endpoint.into();
389        self
390    }
391
392    /// Set the bucket name, used to build a s3 api url
393    pub fn bucket_name(&mut self, name: impl Into<String>) -> &mut Self {
394        self.bucket_name = Some(name.into());
395        self
396    }
397
398    /// Set an optional S3 key prefix, sent as the `prefix=` parameter of the bucket listing.
399    ///
400    /// This scopes the listing to keys under that prefix (e.g. a subdirectory) on the S3 side; it
401    /// is **not** a client-side filter of asset file names. The prefix is stripped from the
402    /// returned names, so a `releases/` prefix lets you keep assets in a subdirectory.
403    pub fn asset_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
404        self.asset_prefix = Some(prefix.into());
405        self
406    }
407
408    /// Set the S3 region embedded in the endpoint host.
409    ///
410    /// Required for the `S3`, `S3DualStack`, and `DigitalOceanSpaces` endpoints (it is part of
411    /// their hostname) and validated by [`build`](Self::build). Ignored by `GCS` and `Generic`
412    /// endpoints, which carry no region in the URL (under `s3-auth`, SigV4 still defaults the
413    /// signing region to `us-east-1` when none is set).
414    pub fn region(&mut self, region: impl Into<String>) -> &mut Self {
415        self.region = Some(region.into());
416        self
417    }
418
419    #[cfg(feature = "s3-auth")]
420    /// Set the access key (an `(access_key_id, secret_access_key)` pair)
421    pub fn access_key(&mut self, access_key: impl Into<auth::AccessKey>) -> &mut Self {
422        self.access_key = Some(access_key.into());
423        self
424    }
425
426    impl_common_builder_setters!(no_auth_token);
427
428    fn build_update(&self) -> Result<Update> {
429        check_endpoint_region(&self.endpoint, &self.region)?;
430        Ok(Update {
431            endpoint: self.endpoint.clone(),
432            bucket_name: if let Some(ref name) = self.bucket_name {
433                name.to_owned()
434            } else {
435                return Err(Error::MissingField {
436                    field: "bucket_name",
437                });
438            },
439            region: self.region.clone(),
440            max_keys: self.max_keys,
441            #[cfg(feature = "s3-auth")]
442            signature_ttl: self.signature_ttl,
443            #[cfg(feature = "s3-auth")]
444            access_key: self.access_key.clone(),
445            asset_prefix: self.asset_prefix.clone(),
446            common: self.common.build()?,
447        })
448    }
449
450    /// Confirm config and create a ready-to-use `Update`.
451    ///
452    /// Returns the concrete [`Update`], which is `Send` and exposes the update verbs as inherent
453    /// methods.
454    pub fn build(&self) -> Result<Update> {
455        self.build_update()
456    }
457
458    /// Confirm config and create a ready-to-use [`AsyncUpdate`] for the async API (`update_async`).
459    ///
460    /// Unlike [`build`](Self::build) this returns the distinct [`AsyncUpdate`] newtype, which exposes
461    /// only the inherent `*_async` verbs, so a stray blocking `.update()` on an async-built updater
462    /// is a compile error rather than a silent block of the executor.
463    #[cfg(feature = "async")]
464    pub fn build_async(&self) -> Result<AsyncUpdate> {
465        Ok(AsyncUpdate(self.build_update()?))
466    }
467}
468
469/// Updates to a specified or latest release distributed via S3
470#[derive(Debug)]
471#[non_exhaustive]
472pub struct Update {
473    endpoint: Endpoint,
474    bucket_name: String,
475    asset_prefix: Option<String>,
476    region: Option<String>,
477    max_keys: u16,
478    #[cfg(feature = "s3-auth")]
479    signature_ttl: Duration,
480    #[cfg(feature = "s3-auth")]
481    access_key: Option<auth::AccessKey>,
482    common: CommonConfig,
483}
484
485impl Update {
486    /// Initialize a new `Update` builder
487    pub fn configure() -> UpdateBuilder {
488        UpdateBuilder::new()
489    }
490
491    /// Build the sans-io [`PageRequest`] plan for the bucket listing (the first page; the parser
492    /// follows continuation tokens). Shared by the sync and async fetch paths.
493    fn listing_plan(&self) -> Result<PageRequest<Release>> {
494        s3_listing_plan(
495            &self.endpoint,
496            &self.bucket_name,
497            &self.region,
498            &self.asset_prefix,
499            self.max_keys,
500            #[cfg(feature = "s3-auth")]
501            self.signature_ttl,
502            #[cfg(feature = "s3-auth")]
503            &self.access_key,
504        )
505    }
506
507    /// Fetch the bucket's releases (sync), following continuation tokens via [`run_paginated`].
508    fn fetch_releases(&self) -> Result<Vec<Release>> {
509        run_paginated(self.listing_plan()?, &self.common.request)
510    }
511
512    /// Async sibling of [`fetch_releases`](Self::fetch_releases).
513    #[cfg(feature = "async")]
514    async fn fetch_releases_async(&self) -> Result<Vec<Release>> {
515        crate::backends::run_paginated_async(self.listing_plan()?, &self.common.request).await
516    }
517}
518
519/// Pick the single highest-version release. Shared by the sync and async paths.
520fn pick_latest(releases: &[Release]) -> Result<Release> {
521    // `max_by` keeps the greatest under the comparator. `cmp_releases_newest_first` orders
522    // newest-first (an unparseable version sorts last); reverse it so "greatest" is the newest and
523    // an unparseable version can never win.
524    let rel = releases.iter().max_by(|x, y| {
525        crate::version::cmp_releases_newest_first(x.version(), y.version()).reverse()
526    });
527    match rel {
528        Some(r) => Ok(r.clone()),
529        None => Err(Error::NoReleaseFound { target: None }),
530    }
531}
532
533/// Filter releases newer than `current_version`, sorted newest-first (the orchestrator takes the
534/// first compatible one). Shared by the sync and async paths.
535fn sort_newer(releases: Vec<Release>, current_version: &str) -> Vec<Release> {
536    let mut releases = releases
537        .into_iter()
538        .filter(|r| bump_is_greater(current_version, r.version()).unwrap_or(false))
539        .collect::<Vec<_>>();
540    // Descending order (latest first), since the update code takes `.first()`. Shared comparator.
541    releases.sort_by(|x, y| crate::version::cmp_releases_newest_first(x.version(), y.version()));
542    releases
543}
544
545/// Find the release matching an explicit version. Shared by the sync and async paths.
546///
547/// Stored versions are bare semver (the parser strips any leading `v`), so a requested tag is
548/// normalized the same way before comparison: `.release_tag("v1.2.3")` matches a stored `1.2.3`.
549fn find_version(releases: &[Release], ver: &str) -> Result<Release> {
550    let ver = ver.trim_start_matches('v');
551    match releases.iter().find(|x| x.version() == ver) {
552        Some(r) => Ok(r.clone()),
553        None => Err(Error::NoReleaseFound { target: None }),
554    }
555}
556
557impl crate::update::sealed::Sealed for Update {}
558
559impl ReleaseUpdate for Update {
560    fn get_latest_release(&self) -> Result<Releases> {
561        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
562        let release = pick_latest(&self.fetch_releases()?)?;
563        Ok(Releases::new(vec![release], current_version))
564    }
565
566    fn get_newer_releases(&self) -> Result<Releases> {
567        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
568        let releases = sort_newer(self.fetch_releases()?, &current_version);
569        Ok(Releases::new(releases, current_version))
570    }
571
572    fn get_release_version(&self, ver: &str) -> Result<Release> {
573        find_version(&self.fetch_releases()?, ver)
574    }
575}
576
577impl_sync_update_verbs!(Update);
578
579/// Async-only updater returned by [`UpdateBuilder::build_async`].
580///
581/// A newtype over the blocking [`Update`] that exposes **only** the inherent `*_async` verbs. Using
582/// it (instead of returning `Update` from `build_async`) makes a blocking call on an async-built
583/// updater — e.g. `build_async()?.update()` — a compile error, so the async executor cannot be
584/// silently blocked.
585#[cfg(feature = "async")]
586#[derive(Debug)]
587pub struct AsyncUpdate(Update);
588
589#[cfg(feature = "async")]
590impl_async_update_verbs!(AsyncUpdate);
591
592impl_update_config_accessors!(Update);
593
594#[cfg(feature = "async")]
595impl crate::update::AsyncReleaseUpdate for Update {
596    async fn get_latest_release_async(&self) -> Result<Releases> {
597        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
598        let release = pick_latest(&self.fetch_releases_async().await?)?;
599        Ok(Releases::new(vec![release], current_version))
600    }
601
602    async fn get_newer_releases_async(&self) -> Result<Releases> {
603        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
604        let releases = sort_newer(self.fetch_releases_async().await?, &current_version);
605        Ok(Releases::new(releases, current_version))
606    }
607
608    async fn get_release_version_async(&self, ver: &str) -> Result<Release> {
609        find_version(&self.fetch_releases_async().await?, ver)
610    }
611}
612
613/// Generate S3 auth parameters
614#[cfg(feature = "s3-auth")]
615mod auth {
616    use crate::errors::*;
617    use hmac::{Hmac, KeyInit, Mac};
618    use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, utf8_percent_encode};
619    use sha2::{Digest, Sha256};
620    use std::{
621        borrow::Cow,
622        time::{SystemTime, UNIX_EPOCH},
623    };
624    use time::OffsetDateTime;
625    use url::Url;
626
627    /// S3 access credentials used to sign requests (AWS SigV4) for private buckets.
628    ///
629    /// Construct one with [`AccessKey::new`] or from an `(access_key_id, secret_access_key)` pair
630    /// via [`From`] (e.g. `("AKIA…", "secret").into()`), which is what
631    /// [`access_key`](super::UpdateBuilder::access_key) accepts. It is `#[non_exhaustive]` so future
632    /// credential fields (e.g. an STS session token) can be added without a breaking change; build
633    /// it through `new` or the `From` impls rather than a struct literal.
634    #[derive(Clone, Debug)]
635    #[non_exhaustive]
636    pub struct AccessKey {
637        pub access_key_id: String,
638        pub secret_access_key: String,
639    }
640
641    impl AccessKey {
642        /// Construct an `AccessKey` from an access-key id and secret. Equivalent to the `From`
643        /// pair conversions, but discoverable as a named constructor (the type is
644        /// `#[non_exhaustive]`, so it can't be built with a struct literal from outside the crate).
645        pub fn new(access_key_id: impl Into<String>, secret_access_key: impl Into<String>) -> Self {
646            Self {
647                access_key_id: access_key_id.into(),
648                secret_access_key: secret_access_key.into(),
649            }
650        }
651    }
652
653    impl From<(&str, &str)> for AccessKey {
654        fn from(value: (&str, &str)) -> Self {
655            Self {
656                access_key_id: value.0.to_owned(),
657                secret_access_key: value.1.to_owned(),
658            }
659        }
660    }
661
662    impl From<(String, String)> for AccessKey {
663        fn from(value: (String, String)) -> Self {
664            Self {
665                access_key_id: value.0,
666                secret_access_key: value.1,
667            }
668        }
669    }
670
671    // NON_ALPHANUMERIC Encodes everything except A-Z, a-z, 0-9.
672    // Remove the last 4 reserved characters that AWS doesn't encode: - . _ ~
673    const URI_ENCODE: &AsciiSet = &NON_ALPHANUMERIC
674        .remove(b'-')
675        .remove(b'.')
676        .remove(b'_')
677        .remove(b'~');
678
679    // AWS doesn't encode the slash character in the canonical URI, but it does
680    // encode it in query parameters
681    const URI_ENCODE_KEEP_SLASH: &AsciiSet = &URI_ENCODE.remove(b'/');
682
683    // Encode a string for use in AWS S3 signature v4, encoding reserved
684    // characters and optionally the slash character
685    fn uri_encode(input: &str, encode_slash: bool) -> PercentEncode<'_> {
686        let set = if encode_slash {
687            URI_ENCODE
688        } else {
689            URI_ENCODE_KEEP_SLASH
690        };
691        utf8_percent_encode(input, set)
692    }
693
694    /// Rebuild a URL's scheme + host + optional port + path (no query) from the parsed `Url`, using
695    /// the parser's percent-encoded `path()`. The signed URL is formed from this so its wire path is
696    /// byte-identical to the canonical URI used for signing.
697    fn sig_base_url(url: &Url) -> String {
698        let mut base = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
699        if let Some(port) = url.port() {
700            base.push_str(&format!(":{port}"));
701        }
702        base.push_str(url.path());
703        base
704    }
705
706    fn hex_sha256(data: &[u8]) -> String {
707        let hash = Sha256::digest(data);
708        hash.iter().map(|b| format!("{b:02x}")).collect()
709    }
710
711    fn hmac_sha256(key: &[u8], data: &[u8]) -> Result<Vec<u8>> {
712        let mut mac = Hmac::<Sha256>::new_from_slice(key)?;
713        mac.update(data);
714        Ok(mac.finalize().into_bytes().to_vec())
715    }
716
717    fn derive_signing_key(secret: &str, date_stamp: &str, region: &str) -> Result<Vec<u8>> {
718        let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date_stamp.as_bytes())?;
719        let k_region = hmac_sha256(&k_date, region.as_bytes())?;
720        let k_service = hmac_sha256(&k_region, b"s3")?;
721        hmac_sha256(&k_service, b"aws4_request")
722    }
723
724    fn format_timestamp(secs: u64) -> Result<(String, String)> {
725        let dt = OffsetDateTime::from_unix_timestamp(secs as i64)?;
726        let date_stamp = format!("{:04}{:02}{:02}", dt.year(), dt.month() as u8, dt.day());
727        let amz_date = format!(
728            "{date_stamp}T{:02}{:02}{:02}Z",
729            dt.hour(),
730            dt.minute(),
731            dt.second()
732        );
733        Ok((date_stamp, amz_date))
734    }
735
736    pub fn s3_signature_v4(
737        url_str: &str,
738        region: &Option<String>,
739        access_key: &Option<AccessKey>,
740        ttl_secs: u64,
741    ) -> Result<String> {
742        let now_secs = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
743        s3_signature_v4_at(url_str, region, access_key, ttl_secs, now_secs)
744    }
745
746    /// Intermediate SigV4 values computed for a request, surfaced so known-answer tests can pin
747    /// each sub-step (canonical request, string to sign, signing key, signature) against AWS's
748    /// published worked examples. Not part of the public API.
749    #[cfg(test)]
750    pub(super) struct SigV4Parts {
751        pub canonical_request: String,
752        pub string_to_sign: String,
753        pub signing_key: Vec<u8>,
754        pub signature: String,
755        pub signed_url: String,
756    }
757
758    /// The full SigV4 presigned-query signer, with the timestamp injected as an explicit
759    /// `now_secs` (Unix seconds) rather than read from the wall clock. The public
760    /// [`s3_signature_v4`] is exactly this with `now_secs = SystemTime::now()`, so the runtime
761    /// behavior and the produced URLs are unchanged; the split only lets tests feed a fixed
762    /// timestamp to reproduce AWS's documented signatures.
763    fn s3_signature_v4_at(
764        url_str: &str,
765        region: &Option<String>,
766        access_key: &Option<AccessKey>,
767        ttl_secs: u64,
768        now_secs: u64,
769    ) -> Result<String> {
770        let (access_key_id, secret_access_key) = match access_key {
771            Some(access_key) => (&access_key.access_key_id, &access_key.secret_access_key),
772            None => return Ok(url_str.to_owned()),
773        };
774        let url = Url::parse(url_str)?;
775        let host = url.host_str().ok_or_else(|| {
776            Error::S3Auth(Box::new(crate::errors::MessageError(format!(
777                "Cannot extract host from {:?}",
778                url_str
779            ))))
780        })?;
781        let canonical_uri = if url.path().is_empty() {
782            "/"
783        } else {
784            url.path()
785        };
786
787        let (date_stamp, amz_date) = format_timestamp(now_secs)?;
788
789        let region = region.as_deref().unwrap_or("us-east-1");
790
791        let credential_scope = format!("{date_stamp}/{region}/s3/aws4_request");
792
793        // Existing query params (decoded by url crate) + SigV4 params, sans Signature.
794        let mut params: Vec<_> = url.query_pairs().collect();
795
796        params.extend([
797            (
798                Cow::Borrowed("X-Amz-Algorithm"),
799                Cow::Borrowed("AWS4-HMAC-SHA256"),
800            ),
801            (
802                Cow::Borrowed("X-Amz-Credential"),
803                Cow::Owned(format!("{access_key_id}/{credential_scope}")),
804            ),
805            (Cow::Borrowed("X-Amz-Date"), Cow::Borrowed(&amz_date)),
806            (
807                Cow::Borrowed("X-Amz-Expires"),
808                Cow::Owned(ttl_secs.to_string()),
809            ),
810            (Cow::Borrowed("X-Amz-SignedHeaders"), Cow::Borrowed("host")),
811        ]);
812        params.sort_by(|a, b| a.0.cmp(&b.0));
813
814        let canonical_qs: String = params
815            .iter()
816            .map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true)))
817            .collect::<Vec<_>>()
818            .join("&");
819
820        // The canonical URI is `url.path()` used verbatim (already percent-encoded once by the URL
821        // parser). S3 does not re-encode the request path, so double-encoding it here (the old
822        // `uri_encode(canonical_uri, ..)`) produced a signature that did not match for any key with
823        // a reserved character (a space, `+`, unicode). The signed URL below is rebuilt from the
824        // same `url.path()`, so the canonical URI and the wire path are identical by construction.
825        let canonical_request =
826            format!("GET\n{canonical_uri}\n{canonical_qs}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD",);
827
828        let string_to_sign = format!(
829            "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
830            hex_sha256(canonical_request.as_bytes())
831        );
832
833        let signing_key = derive_signing_key(secret_access_key, &date_stamp, region)?;
834        let signature: String = hmac_sha256(&signing_key, string_to_sign.as_bytes())?
835            .iter()
836            .map(|b| format!("{b:02x}"))
837            .collect();
838
839        let base = sig_base_url(&url);
840        Ok(format!("{base}?{canonical_qs}&X-Amz-Signature={signature}"))
841    }
842
843    /// Test-only re-run of the signer that returns the intermediate SigV4 values alongside the
844    /// signed URL, so known-answer tests can assert each sub-step. Mirrors [`s3_signature_v4_at`]
845    /// exactly (same inputs, same construction); the only difference is that it surfaces the
846    /// intermediates instead of discarding them.
847    #[cfg(test)]
848    pub(super) fn s3_signature_v4_parts(
849        url_str: &str,
850        region: &Option<String>,
851        access_key: &AccessKey,
852        ttl_secs: u64,
853        now_secs: u64,
854    ) -> Result<SigV4Parts> {
855        let access_key_id = &access_key.access_key_id;
856        let secret_access_key = &access_key.secret_access_key;
857        let url = Url::parse(url_str)?;
858        let host = url.host_str().ok_or_else(|| {
859            Error::S3Auth(Box::new(crate::errors::MessageError(format!(
860                "Cannot extract host from {:?}",
861                url_str
862            ))))
863        })?;
864        let canonical_uri = if url.path().is_empty() {
865            "/"
866        } else {
867            url.path()
868        };
869
870        let (date_stamp, amz_date) = format_timestamp(now_secs)?;
871        let region = region.as_deref().unwrap_or("us-east-1");
872        let credential_scope = format!("{date_stamp}/{region}/s3/aws4_request");
873
874        let mut params: Vec<_> = url.query_pairs().collect();
875        params.extend([
876            (
877                Cow::Borrowed("X-Amz-Algorithm"),
878                Cow::Borrowed("AWS4-HMAC-SHA256"),
879            ),
880            (
881                Cow::Borrowed("X-Amz-Credential"),
882                Cow::Owned(format!("{access_key_id}/{credential_scope}")),
883            ),
884            (Cow::Borrowed("X-Amz-Date"), Cow::Borrowed(&amz_date)),
885            (
886                Cow::Borrowed("X-Amz-Expires"),
887                Cow::Owned(ttl_secs.to_string()),
888            ),
889            (Cow::Borrowed("X-Amz-SignedHeaders"), Cow::Borrowed("host")),
890        ]);
891        params.sort_by(|a, b| a.0.cmp(&b.0));
892
893        let canonical_qs: String = params
894            .iter()
895            .map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true)))
896            .collect::<Vec<_>>()
897            .join("&");
898
899        let canonical_request =
900            format!("GET\n{canonical_uri}\n{canonical_qs}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD",);
901
902        let string_to_sign = format!(
903            "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
904            hex_sha256(canonical_request.as_bytes())
905        );
906
907        let signing_key = derive_signing_key(secret_access_key, &date_stamp, region)?;
908        let signature: String = hmac_sha256(&signing_key, string_to_sign.as_bytes())?
909            .iter()
910            .map(|b| format!("{b:02x}"))
911            .collect();
912
913        let signed_url = s3_signature_v4_at(
914            url_str,
915            &Some(region.to_owned()),
916            &Some(access_key.clone()),
917            ttl_secs,
918            now_secs,
919        )?;
920
921        Ok(SigV4Parts {
922            canonical_request,
923            string_to_sign,
924            signing_key,
925            signature,
926            signed_url,
927        })
928    }
929
930    #[cfg(test)]
931    mod sigv4_vectors {
932        //! SigV4 conformance / known-answer (golden) tests.
933        //!
934        //! These pin the hand-rolled SigV4 presigned-query signer (`s3_signature_v4`) against
935        //! AWS's published worked examples, not merely against its own current output. The crate
936        //! signs S3 GET requests as PRESIGNED URLs (query-string auth), so the authoritative
937        //! reference is the AWS "Authenticating Requests: Using Query Parameters (AWS Signature
938        //! Version 4)" GET-object example plus the documented signing-key derivation test values.
939        //!
940        //! Source citations are on each vector. Each vector feeds the signer the documented inputs
941        //! (via the timestamp-injecting `s3_signature_v4_at` / `_parts` helpers) and asserts it
942        //! reproduces the documented intermediate values and final signature.
943
944        use super::{
945            AccessKey, derive_signing_key, hex_sha256, hmac_sha256, s3_signature_v4_at,
946            s3_signature_v4_parts, uri_encode,
947        };
948
949        /// Known-answer test for the signing-key derivation chain against AWS's OWN documented
950        /// expected key bytes.
951        ///
952        /// Source: AWS General Reference, "Signature Version 4 -> Examples of deriving a signing
953        /// key for Signature Version 4". For secret `wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY`,
954        /// date `20150830`, region `us-east-1`, service `iam`, AWS publishes the final signing key
955        /// bytes. This signer fixes the service to `s3`, so we reproduce the chain here with the
956        /// documented `iam` service to validate the `AWS4`+secret -> date -> region -> service ->
957        /// `aws4_request` HMAC chain against AWS's authoritative output, then assert the production
958        /// `derive_signing_key` (service `s3`) shares the same kDate/kRegion prefix.
959        #[test]
960        fn signing_key_chain_matches_aws_documented_iam_key_bytes() {
961            let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
962            let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), b"20150830").unwrap();
963            let k_region = hmac_sha256(&k_date, b"us-east-1").unwrap();
964            let k_service = hmac_sha256(&k_region, b"iam").unwrap();
965            let k_signing = hmac_sha256(&k_service, b"aws4_request").unwrap();
966            let hex: String = k_signing.iter().map(|b| format!("{b:02x}")).collect();
967            // AWS-documented final signing key for 20150830/us-east-1/iam/aws4_request.
968            assert_eq!(
969                hex, "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9",
970                "the HMAC derivation chain must reproduce AWS's documented iam signing key"
971            );
972            // The production helper differs only in the service link (`s3`), so its kDate/kRegion
973            // prefix is identical: derive with `s3` and confirm it diverges only after kRegion.
974            let s3_key = derive_signing_key(secret, "20150830", "us-east-1").unwrap();
975            let s3_via_chain =
976                hmac_sha256(&hmac_sha256(&k_region, b"s3").unwrap(), b"aws4_request").unwrap();
977            assert_eq!(
978                s3_key, s3_via_chain,
979                "derive_signing_key(service=s3) must equal the same chain with the s3 service link"
980            );
981            assert_ne!(
982                s3_key, k_signing,
983                "the s3 service link must diverge from the iam key after kRegion"
984            );
985        }
986
987        // AWS's canonical example credentials, used across the SigV4 documentation examples.
988        // Source: AWS General Reference, "Signature Version 4" examples, and the S3 "Authenticating
989        // Requests: Using Query Parameters (AWS Signature Version 4)" GET-object example.
990        const EXAMPLE_ACCESS_KEY_ID: &str = "AKIAIOSFODNN7EXAMPLE";
991        const EXAMPLE_SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
992
993        // The presigned GET-object example fixes the signing instant at 2013-05-24T00:00:00Z.
994        // 2013-05-24T00:00:00Z == 1369353600 Unix seconds.
995        const EXAMPLE_NOW_SECS: u64 = 1369353600;
996
997        // An object key containing a space must be percent-encoded exactly once in both the
998        // canonical URI and the signed URL path. The old code re-encoded the URL-parser's already
999        // encoded path, producing `%2520` in the canonical request and a signature S3 rejects.
1000        #[test]
1001        fn spaced_object_key_is_single_encoded_and_consistent() {
1002            let key = AccessKey::new(EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET);
1003            let parts = s3_signature_v4_parts(
1004                "https://examplebucket.s3.amazonaws.com/my key.txt",
1005                &Some("us-east-1".to_owned()),
1006                &key,
1007                86400,
1008                EXAMPLE_NOW_SECS,
1009            )
1010            .unwrap();
1011            assert!(
1012                parts.canonical_request.contains("/my%20key.txt"),
1013                "canonical URI must be single-encoded: {}",
1014                parts.canonical_request
1015            );
1016            assert!(
1017                !parts.canonical_request.contains("%2520"),
1018                "canonical URI must not be double-encoded: {}",
1019                parts.canonical_request
1020            );
1021            assert!(
1022                parts.signed_url.contains("/my%20key.txt?"),
1023                "signed URL wire path must be single-encoded to match the canonical URI: {}",
1024                parts.signed_url
1025            );
1026        }
1027
1028        /// Known-answer test for the WHOLE presigned-query signing flow.
1029        ///
1030        /// Source: AWS S3 docs, "Authenticating Requests: Using Query Parameters (AWS Signature
1031        /// Version 4)" -> "Example: GET Object". For the request
1032        /// `GET https://examplebucket.s3.amazonaws.com/test.txt` with credentials
1033        /// `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/...EXAMPLEKEY`, region `us-east-1`,
1034        /// `X-Amz-Date=20130524T000000Z`, and `X-Amz-Expires=86400`. The canonical request and the
1035        /// string-to-sign (whose 4th line is the SHA256 of the canonical request,
1036        /// `3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04`) are AWS's documented
1037        /// values verbatim. The final signature `3ed0be64...` is the SigV4 HMAC of that
1038        /// string-to-sign under the signing key derived from the documented credentials, derived
1039        /// here and cross-checked against an independent SigV4 reference implementation (and against
1040        /// the AWS-documented `iam` signing-key bytes pinned in
1041        /// `signing_key_chain_matches_aws_documented_iam_key_bytes`). The signer uses
1042        /// `UNSIGNED-PAYLOAD` and `SignedHeaders=host`, matching this example exactly.
1043        #[test]
1044        fn aws_get_object_presigned_example_known_answer() {
1045            let key = AccessKey::new(EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET);
1046            let parts = s3_signature_v4_parts(
1047                "https://examplebucket.s3.amazonaws.com/test.txt",
1048                &Some("us-east-1".to_owned()),
1049                &key,
1050                86400,
1051                EXAMPLE_NOW_SECS,
1052            )
1053            .unwrap();
1054
1055            // AWS-documented canonical request (verbatim, LF-separated).
1056            let expected_canonical_request = "GET\n\
1057                /test.txt\n\
1058                X-Amz-Algorithm=AWS4-HMAC-SHA256&\
1059                X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
1060                X-Amz-Date=20130524T000000Z&\
1061                X-Amz-Expires=86400&\
1062                X-Amz-SignedHeaders=host\n\
1063                host:examplebucket.s3.amazonaws.com\n\
1064                \n\
1065                host\n\
1066                UNSIGNED-PAYLOAD";
1067            assert_eq!(
1068                parts.canonical_request, expected_canonical_request,
1069                "canonical request must match the AWS GET-object presigned example verbatim"
1070            );
1071
1072            // AWS-documented string-to-sign. The 4th line is the SHA256 of the canonical request
1073            // above and equals AWS's documented hashed-canonical-request digest.
1074            let expected_string_to_sign = "AWS4-HMAC-SHA256\n\
1075                20130524T000000Z\n\
1076                20130524/us-east-1/s3/aws4_request\n\
1077                3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04";
1078            assert_eq!(
1079                parts.string_to_sign, expected_string_to_sign,
1080                "string-to-sign must match the AWS GET-object presigned example verbatim"
1081            );
1082
1083            // The SigV4 signature for the documented inputs above, cross-checked against an
1084            // independent reference implementation of the algorithm.
1085            let expected_signature =
1086                "3ed0be64024db54d5574a27da223529635c383f911f80e636f0ccc13890053d2";
1087            assert_eq!(
1088                parts.signature, expected_signature,
1089                "final SigV4 signature must equal the known-answer value for the GET-object \
1090                 presigned example inputs"
1091            );
1092
1093            // And the assembled presigned URL must carry that exact signature plus the documented
1094            // query params.
1095            assert!(
1096                parts
1097                    .signed_url
1098                    .contains("X-Amz-Signature=3ed0be64024db54d5574a27da223529635c383f911f80e636f0ccc13890053d2"),
1099                "the presigned URL must carry the known-answer signature, got: {}",
1100                parts.signed_url
1101            );
1102            assert!(
1103                parts.signed_url.starts_with(
1104                    "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256"
1105                ),
1106                "the presigned URL must preserve the base path and lead with the algorithm param, \
1107                 got: {}",
1108                parts.signed_url
1109            );
1110
1111            // The public, wall-clock-free path must produce the identical URL when fed the same
1112            // fixed instant, proving the test helper did not diverge from the real signer.
1113            let via_signer = s3_signature_v4_at(
1114                "https://examplebucket.s3.amazonaws.com/test.txt",
1115                &Some("us-east-1".to_owned()),
1116                &Some(key),
1117                86400,
1118                EXAMPLE_NOW_SECS,
1119            )
1120            .unwrap();
1121            assert_eq!(
1122                via_signer, parts.signed_url,
1123                "the test-parts helper must reproduce the real signer's URL byte-for-byte"
1124            );
1125
1126            // The intermediate signing key the signer derived must be the documented-credentials
1127            // key (32-byte HMAC-SHA256), and HMAC(signing_key, string_to_sign) must reproduce the
1128            // signature, proving the surfaced intermediate is the one actually used.
1129            assert_eq!(
1130                parts.signing_key.len(),
1131                32,
1132                "signing key is a 32-byte HMAC key"
1133            );
1134            let recomputed: String =
1135                super::hmac_sha256(&parts.signing_key, parts.string_to_sign.as_bytes())
1136                    .unwrap()
1137                    .iter()
1138                    .map(|b| format!("{b:02x}"))
1139                    .collect();
1140            assert_eq!(
1141                recomputed, parts.signature,
1142                "HMAC(surfaced signing_key, surfaced string_to_sign) must equal the signature"
1143            );
1144        }
1145
1146        /// Structural invariants of the HMAC-SHA256 signing-key derivation chain
1147        /// (`AWS4`+secret -> date -> region -> service -> `aws4_request`).
1148        ///
1149        /// The authoritative known-answer for the chain itself is
1150        /// `signing_key_chain_matches_aws_documented_iam_key_bytes` (AWS's documented `iam` key
1151        /// bytes). This complements it by pinning the cheap invariants of the production
1152        /// `derive_signing_key` (service `s3`): a 32-byte (HMAC-SHA256) output, determinism, and
1153        /// that each of the date/region links is load-bearing (changing one changes the key).
1154        #[test]
1155        fn signing_key_derivation_chain_is_deterministic_and_32_bytes() {
1156            // The signing key the GET-object example actually uses (service `s3`).
1157            let key = derive_signing_key(EXAMPLE_SECRET, "20130524", "us-east-1").unwrap();
1158            assert_eq!(key.len(), 32, "an HMAC-SHA256 signing key is 32 bytes");
1159            // Deterministic: same inputs -> same key.
1160            let again = derive_signing_key(EXAMPLE_SECRET, "20130524", "us-east-1").unwrap();
1161            assert_eq!(key, again, "signing-key derivation must be deterministic");
1162            // Each link of the chain is load-bearing: a different date/region/secret diverges.
1163            assert_ne!(
1164                key,
1165                derive_signing_key(EXAMPLE_SECRET, "20130525", "us-east-1").unwrap(),
1166                "a different date must change the signing key"
1167            );
1168            assert_ne!(
1169                key,
1170                derive_signing_key(EXAMPLE_SECRET, "20130524", "us-west-2").unwrap(),
1171                "a different region must change the signing key"
1172            );
1173        }
1174
1175        /// Known-answer test: the derived signing key, applied to AWS's documented string-to-sign,
1176        /// reproduces the GET-object presigned-example signature.
1177        ///
1178        /// Source: AWS S3 "Example: GET Object" presigned example (documented string-to-sign, whose
1179        /// digest line is AWS's documented hashed canonical request) plus the cross-checked SigV4
1180        /// signature. This pins `derive_signing_key` end-to-end through the final HMAC without going
1181        /// through the URL builder: if the `AWS4`+secret/date/region/`s3`/`aws4_request` chain
1182        /// regresses, the known-answer signature can no longer be reproduced and this fails.
1183        #[test]
1184        fn signing_key_reproduces_documented_signature() {
1185            use hmac::{Hmac, KeyInit, Mac};
1186            use sha2::Sha256;
1187
1188            let signing_key = derive_signing_key(EXAMPLE_SECRET, "20130524", "us-east-1").unwrap();
1189            let string_to_sign = "AWS4-HMAC-SHA256\n\
1190                20130524T000000Z\n\
1191                20130524/us-east-1/s3/aws4_request\n\
1192                3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04";
1193            let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key).unwrap();
1194            mac.update(string_to_sign.as_bytes());
1195            let signature: String = mac
1196                .finalize()
1197                .into_bytes()
1198                .iter()
1199                .map(|b| format!("{b:02x}"))
1200                .collect();
1201            assert_eq!(
1202                signature, "3ed0be64024db54d5574a27da223529635c383f911f80e636f0ccc13890053d2",
1203                "derive_signing_key + HMAC(string_to_sign) must reproduce the GET-object \
1204                 known-answer signature"
1205            );
1206        }
1207
1208        /// Known-answer test for the SHA256 hex of the canonical request.
1209        ///
1210        /// Source: AWS S3 "Example: GET Object" presigned example -- the third line of the
1211        /// string-to-sign is the lowercase-hex SHA256 of the canonical request. We recompute it
1212        /// from the documented canonical request and assert it equals the documented digest.
1213        #[test]
1214        fn sha256_of_canonical_request_matches_documented_digest() {
1215            let canonical_request = "GET\n\
1216                /test.txt\n\
1217                X-Amz-Algorithm=AWS4-HMAC-SHA256&\
1218                X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
1219                X-Amz-Date=20130524T000000Z&\
1220                X-Amz-Expires=86400&\
1221                X-Amz-SignedHeaders=host\n\
1222                host:examplebucket.s3.amazonaws.com\n\
1223                \n\
1224                host\n\
1225                UNSIGNED-PAYLOAD";
1226            assert_eq!(
1227                hex_sha256(canonical_request.as_bytes()),
1228                "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04",
1229                "SHA256 of the documented canonical request must equal AWS's documented digest"
1230            );
1231        }
1232
1233        /// Percent-encoding rules per AWS SigV4 (S3 does NOT double-encode the path).
1234        ///
1235        /// Source: AWS "Create a canonical request" -- UriEncode reserves `A-Z a-z 0-9 - . _ ~`
1236        /// unencoded, encodes everything else as `%XX` uppercase-hex, and for S3 the path slash is
1237        /// kept (the object key path is single-encoded, not double-encoded), while in the query
1238        /// string the slash IS encoded (`%2F`).
1239        #[test]
1240        fn percent_encoding_follows_aws_uriencode_rules() {
1241            // Unreserved set is passed through verbatim.
1242            assert_eq!(
1243                uri_encode("AZaz09-._~", true).to_string(),
1244                "AZaz09-._~",
1245                "the AWS unreserved set must never be encoded"
1246            );
1247            // A space and reserved punctuation are %-encoded (uppercase hex).
1248            assert_eq!(
1249                uri_encode("a b+c=d", true).to_string(),
1250                "a%20b%2Bc%3Dd",
1251                "reserved characters must be uppercase-hex %-encoded"
1252            );
1253            // Path mode (encode_slash = false): the slash is preserved (S3 single-encodes the path).
1254            assert_eq!(
1255                uri_encode("/path/to/my key.txt", false).to_string(),
1256                "/path/to/my%20key.txt",
1257                "in path mode the slash is kept and only other reserved chars are encoded"
1258            );
1259            // Query mode (encode_slash = true): the slash IS encoded, matching the X-Amz-Credential
1260            // scope separators appearing as %2F in the canonical query string.
1261            assert_eq!(
1262                uri_encode("a/b", true).to_string(),
1263                "a%2Fb",
1264                "in query mode the slash must be encoded as %2F"
1265            );
1266        }
1267
1268        /// Credential-scope and X-Amz-Expires formatting.
1269        ///
1270        /// Source: AWS "Create a string to sign" / "Example: GET Object" -- the credential scope is
1271        /// `<datestamp>/<region>/s3/aws4_request` and the presigned URL carries the requested
1272        /// `X-Amz-Expires` verbatim. We assert both appear with the documented shape (the scope
1273        /// separators percent-encoded as %2F inside the credential query value).
1274        #[test]
1275        fn credential_scope_and_expires_formatting() {
1276            let key = AccessKey::new(EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET);
1277            let parts = s3_signature_v4_parts(
1278                "https://examplebucket.s3.amazonaws.com/test.txt",
1279                &Some("us-east-1".to_owned()),
1280                &key,
1281                86400,
1282                EXAMPLE_NOW_SECS,
1283            )
1284            .unwrap();
1285            // Scope inside the (decoded) credential of the string-to-sign is slash-separated.
1286            assert!(
1287                parts
1288                    .string_to_sign
1289                    .contains("20130524/us-east-1/s3/aws4_request"),
1290                "credential scope must be <date>/<region>/s3/aws4_request, got: {}",
1291                parts.string_to_sign
1292            );
1293            // Inside the canonical query string (and the signed URL) the scope slashes are %2F.
1294            assert!(
1295                parts.canonical_request.contains(
1296                    "X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"
1297                ),
1298                "the canonical query credential must percent-encode the scope slashes, got: {}",
1299                parts.canonical_request
1300            );
1301            // X-Amz-Expires is the requested TTL verbatim.
1302            assert!(
1303                parts.canonical_request.contains("X-Amz-Expires=86400"),
1304                "X-Amz-Expires must carry the requested TTL verbatim, got: {}",
1305                parts.canonical_request
1306            );
1307            assert!(
1308                parts.signed_url.contains("X-Amz-Expires=86400"),
1309                "the signed URL must carry the requested expiry, got: {}",
1310                parts.signed_url
1311            );
1312        }
1313    }
1314}
1315
1316/// Build the S3 listing `api_url` and the `download_base_url` that asset URLs are formed against,
1317/// signing the listing URL when `s3-auth` is enabled. `continuation_token`, when present, is added
1318/// as the `continuation-token=` query param (for following a truncated listing). Shared by the sync
1319/// and async fetch paths.
1320#[allow(clippy::too_many_arguments)]
1321fn build_s3_api_url(
1322    endpoint: &Endpoint,
1323    bucket_name: &str,
1324    region: &Option<String>,
1325    asset_prefix: &Option<String>,
1326    max_keys: u16,
1327    continuation_token: Option<&str>,
1328    #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1329    #[cfg(feature = "s3-auth")] access_key: &Option<auth::AccessKey>,
1330) -> Result<(String, String)> {
1331    let prefix = match asset_prefix {
1332        Some(prefix) => format!("&prefix={}", urlencoding::encode(prefix)),
1333        None => "".to_string(),
1334    };
1335    let continuation = match continuation_token {
1336        Some(token) => format!("&continuation-token={}", urlencoding::encode(token)),
1337        None => "".to_string(),
1338    };
1339
1340    let region_result = region
1341        .as_ref()
1342        .ok_or(Error::MissingField { field: "region" });
1343
1344    let download_base_url = match endpoint {
1345        Endpoint::S3 => format!(
1346            "https://{}.s3.{}.amazonaws.com/",
1347            bucket_name, region_result?
1348        ),
1349        Endpoint::S3DualStack => format!(
1350            "https://{}.s3.dualstack.{}.amazonaws.com/",
1351            bucket_name, region_result?
1352        ),
1353        Endpoint::DigitalOceanSpaces => format!(
1354            "https://{}.{}.digitaloceanspaces.com/",
1355            bucket_name, region_result?
1356        ),
1357        Endpoint::GCS => format!("https://storage.googleapis.com/{}/", bucket_name),
1358        // Asset keys are appended directly to the base, so it must end with `/` like every
1359        // crate-built base above; normalize a user-supplied endpoint that omits it.
1360        Endpoint::Generic(endpoint) if endpoint.ends_with('/') => endpoint.clone(),
1361        Endpoint::Generic(endpoint) => format!("{}/", endpoint),
1362    };
1363
1364    let api_url = format!(
1365        "{}?list-type=2&max-keys={}{}{}",
1366        download_base_url, max_keys, prefix, continuation
1367    );
1368
1369    #[cfg(feature = "s3-auth")]
1370    let api_url = auth::s3_signature_v4(&api_url, region, access_key, signature_ttl.as_secs())?;
1371
1372    Ok((download_base_url, api_url))
1373}
1374
1375/// Build the sans-io [`PageRequest`] for the S3 bucket listing (the first page; the parser follows
1376/// continuation tokens by emitting `Page::next` when the listing is truncated). Each continuation
1377/// URL is freshly built (and, under `s3-auth`, freshly SigV4-signed) by the parser.
1378#[allow(clippy::too_many_arguments)]
1379fn s3_listing_plan(
1380    endpoint: &Endpoint,
1381    bucket_name: &str,
1382    region: &Option<String>,
1383    asset_prefix: &Option<String>,
1384    max_keys: u16,
1385    #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1386    #[cfg(feature = "s3-auth")] access_key: &Option<auth::AccessKey>,
1387) -> Result<PageRequest<Release>> {
1388    // Capture owned copies of everything the parser needs to (re)build a continuation request.
1389    let endpoint = endpoint.clone();
1390    let bucket_name = bucket_name.to_owned();
1391    let region = region.clone();
1392    let asset_prefix = asset_prefix.clone();
1393    #[cfg(feature = "s3-auth")]
1394    let access_key = access_key.clone();
1395
1396    s3_page(
1397        endpoint,
1398        bucket_name,
1399        region,
1400        asset_prefix,
1401        max_keys,
1402        None,
1403        #[cfg(feature = "s3-auth")]
1404        signature_ttl,
1405        #[cfg(feature = "s3-auth")]
1406        access_key,
1407    )
1408}
1409
1410/// Build one S3 listing [`PageRequest`] for the given `continuation_token` (None for the first
1411/// page). The parser extracts releases + the next continuation token and, when truncated, emits the
1412/// next `PageRequest`.
1413#[allow(clippy::too_many_arguments)]
1414fn s3_page(
1415    endpoint: Endpoint,
1416    bucket_name: String,
1417    region: Option<String>,
1418    asset_prefix: Option<String>,
1419    max_keys: u16,
1420    continuation_token: Option<String>,
1421    #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1422    #[cfg(feature = "s3-auth")] access_key: Option<auth::AccessKey>,
1423) -> Result<PageRequest<Release>> {
1424    let (download_base_url, api_url) = build_s3_api_url(
1425        &endpoint,
1426        &bucket_name,
1427        &region,
1428        &asset_prefix,
1429        max_keys,
1430        continuation_token.as_deref(),
1431        #[cfg(feature = "s3-auth")]
1432        signature_ttl,
1433        #[cfg(feature = "s3-auth")]
1434        &access_key,
1435    )?;
1436    debug!("using api url: {:?}", api_url);
1437
1438    Ok(PageRequest {
1439        url: api_url,
1440        headers: Default::default(),
1441        parse: Box::new(move |body, _resp_headers| {
1442            let (items, next_token) = parse_s3_response(
1443                body,
1444                &download_base_url,
1445                #[cfg(feature = "s3-auth")]
1446                &region,
1447                #[cfg(feature = "s3-auth")]
1448                signature_ttl,
1449                #[cfg(feature = "s3-auth")]
1450                &access_key,
1451            )?;
1452            // When the listing is truncated, follow the continuation token with a fresh (freshly
1453            // signed, under s3-auth) listing request for the next page.
1454            let next = match next_token {
1455                Some(token) => Some(s3_page(
1456                    endpoint,
1457                    bucket_name,
1458                    region,
1459                    asset_prefix,
1460                    max_keys,
1461                    Some(token),
1462                    #[cfg(feature = "s3-auth")]
1463                    signature_ttl,
1464                    #[cfg(feature = "s3-auth")]
1465                    access_key,
1466                )?),
1467                None => None,
1468            };
1469            Ok(Page {
1470                items,
1471                next,
1472                stop: false,
1473            })
1474        }),
1475    })
1476}
1477
1478/// Parse an S3 `ListBucketResult` XML body into releases plus the `NextContinuationToken` (present
1479/// only when `<IsTruncated>true</IsTruncated>`). Forms (and, under `s3-auth`, signs) each asset's
1480/// download URL against `download_base_url`. Pure when `s3-auth` is off; under `s3-auth` it signs
1481/// each URL with a timestamped SigV4 signature and is therefore time-dependent. Shared by both
1482/// fetch paths.
1483fn parse_s3_response<R: std::io::BufRead>(
1484    body: R,
1485    download_base_url: &str,
1486    #[cfg(feature = "s3-auth")] region: &Option<String>,
1487    #[cfg(feature = "s3-auth")] signature_ttl: Duration,
1488    #[cfg(feature = "s3-auth")] access_key: &Option<auth::AccessKey>,
1489) -> Result<(Vec<Release>, Option<String>)> {
1490    let mut reader = Reader::from_reader(body);
1491    reader.config_mut().trim_text(true);
1492
1493    // Let's now parse the response to extract the releases
1494    enum Tag {
1495        Contents,
1496        Key,
1497        LastModified,
1498        IsTruncated,
1499        NextContinuationToken,
1500        Other,
1501    }
1502
1503    let mut current_tag = Tag::Other;
1504    let mut current_release: Option<Release> = None;
1505    let mut is_truncated = false;
1506    let mut next_continuation_token: Option<String> = None;
1507    // The filename matcher is compiled once, process-wide (see `ASSET_KEY_REGEX`), rather than on
1508    // every page parsed.
1509    let regex = &*ASSET_KEY_REGEX;
1510
1511    // inspecting each XML element we populate our releases list
1512    let mut buf = Vec::new();
1513    let mut releases: Vec<Release> = vec![];
1514    loop {
1515        match reader.read_event_into(&mut buf) {
1516            Ok(Event::Start(ref e)) => match e.name().into_inner() {
1517                b"Contents" => {
1518                    current_tag = Tag::Contents;
1519                    if let Some(release) = current_release {
1520                        add_to_releases_list(&mut releases, release);
1521                    }
1522                    current_release = None;
1523                }
1524                b"Key" => current_tag = Tag::Key,
1525                b"LastModified" => current_tag = Tag::LastModified,
1526                b"IsTruncated" => current_tag = Tag::IsTruncated,
1527                b"NextContinuationToken" => current_tag = Tag::NextContinuationToken,
1528                _ => current_tag = Tag::Other,
1529            },
1530            Ok(Event::Text(e)) => {
1531                // if we cannot decode a tag text we just ignore it
1532                if let Ok(txt) = e.decode().map(|r| r.into_owned()) {
1533                    match current_tag {
1534                        Tag::Key => {
1535                            let p = PathBuf::from(&txt);
1536                            let exe_name = match p.file_name().map(|v| v.to_str()) {
1537                                Some(Some(v)) => v,
1538                                _ => &txt,
1539                            };
1540
1541                            if let Some(captures) = regex.captures(&txt) {
1542                                let release = current_release.get_or_insert(Release::default());
1543                                release.name = std::sync::Arc::from(captures["name"].to_string());
1544                                release.version = std::sync::Arc::from(
1545                                    captures["version"].trim_start_matches('v').to_string(),
1546                                );
1547                                let download_url = format!("{}{}", download_base_url, txt);
1548
1549                                #[cfg(feature = "s3-auth")]
1550                                let download_url = auth::s3_signature_v4(
1551                                    &download_url,
1552                                    region,
1553                                    access_key,
1554                                    signature_ttl.as_secs(),
1555                                )?;
1556
1557                                release.assets = vec![ReleaseAsset::new(exe_name, download_url)];
1558                                debug!("Matched release: {:?}", release);
1559                            } else {
1560                                debug!("Regex mismatch: {:?}", txt);
1561                            }
1562                        }
1563                        Tag::LastModified => {
1564                            let release = current_release.get_or_insert(Release::default());
1565                            release.date = std::sync::Arc::from(txt);
1566                        }
1567                        Tag::IsTruncated => {
1568                            is_truncated = txt.eq_ignore_ascii_case("true");
1569                        }
1570                        Tag::NextContinuationToken => {
1571                            next_continuation_token = Some(txt);
1572                        }
1573                        _ => (),
1574                    }
1575                }
1576            }
1577            Ok(Event::Eof) => {
1578                if let Some(release) = current_release {
1579                    add_to_releases_list(&mut releases, release);
1580                }
1581                break; // exits the loop when reaching end of file
1582            }
1583            Err(e) => {
1584                return Err(Error::InvalidResponse {
1585                    source: Box::new(e),
1586                });
1587            }
1588            _ => (), // There are several other `Event`s we ignore here
1589        }
1590
1591        buf.clear();
1592    }
1593
1594    // Only follow a continuation token when the listing actually flagged itself truncated.
1595    let next_token = if is_truncated {
1596        next_continuation_token
1597    } else {
1598        None
1599    };
1600    Ok((releases, next_token))
1601}
1602
1603// Add a release to the list if it's doesn't exist yet, or merge its asset/s
1604// details into the release item already existing in the list
1605fn add_to_releases_list(releases: &mut Vec<Release>, mut rel: Release) {
1606    if !rel.version().is_empty() && !rel.name.is_empty() {
1607        match releases
1608            .iter()
1609            .position(|curr| curr.name == rel.name && curr.version() == rel.version())
1610        {
1611            Some(index) => {
1612                rel.assets.append(&mut releases[index].assets);
1613                releases.push(rel);
1614                releases.swap_remove(index);
1615            }
1616            None => releases.push(rel),
1617        }
1618    }
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623    use super::Update;
1624    use crate::update::{Release, UpdateConfig};
1625    use std::time::Duration;
1626
1627    // ---------------------------------------------------------------------------
1628    // Helpers shared between sync XML-parse tests and async stub tests
1629    // ---------------------------------------------------------------------------
1630
1631    /// Test wrapper over `super::parse_s3_response`: drives the parser with the supplied
1632    /// `s3-auth`-gated args (threading the default signature TTL) and returns just the releases vec,
1633    /// dropping the continuation token. The continuation-token return is exercised separately.
1634    fn parse_s3_response<R: std::io::BufRead>(
1635        body: R,
1636        download_base_url: &str,
1637        #[cfg(feature = "s3-auth")] region: &Option<String>,
1638        #[cfg(feature = "s3-auth")] access_key: &Option<super::auth::AccessKey>,
1639    ) -> crate::errors::Result<Vec<Release>> {
1640        super::parse_s3_response(
1641            body,
1642            download_base_url,
1643            #[cfg(feature = "s3-auth")]
1644            region,
1645            #[cfg(feature = "s3-auth")]
1646            Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS),
1647            #[cfg(feature = "s3-auth")]
1648            access_key,
1649        )
1650        .map(|(releases, _next)| releases)
1651    }
1652
1653    /// Build a minimal `ListBucketResult` XML body with the given `<Key>` entries.
1654    fn list_bucket_xml(keys: &[&str]) -> String {
1655        let contents: String = keys
1656            .iter()
1657            .map(|k| {
1658                format!(
1659                    "<Contents><Key>{k}</Key><LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents>"
1660                )
1661            })
1662            .collect();
1663        format!(
1664            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1665             <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1666             <Name>my-bucket</Name>{contents}</ListBucketResult>"
1667        )
1668    }
1669
1670    /// Build a `ListBucketResult` XML body that flags itself truncated, carrying a
1671    /// `NextContinuationToken` so the driver follows it to the next page.
1672    fn truncated_list_bucket_xml(keys: &[&str], next_token: &str) -> String {
1673        let contents: String = keys
1674            .iter()
1675            .map(|k| {
1676                format!(
1677                    "<Contents><Key>{k}</Key><LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents>"
1678                )
1679            })
1680            .collect();
1681        format!(
1682            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1683             <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1684             <Name>my-bucket</Name><IsTruncated>true</IsTruncated>\
1685             <NextContinuationToken>{next_token}</NextContinuationToken>{contents}</ListBucketResult>"
1686        )
1687    }
1688
1689    // ---------------------------------------------------------------------------
1690    // parse_s3_response / add_to_releases_list unit tests (no network)
1691    // ---------------------------------------------------------------------------
1692
1693    #[test]
1694    fn parse_s3_response_single_release_single_asset() {
1695        // One <Contents> entry that matches the version regex: name="myapp", version="1.2.3",
1696        // suffix "-x86_64-linux". The trailing Eof flush emits that release.
1697        let xml = list_bucket_xml(&["myapp-1.2.3-x86_64-linux"]);
1698        let releases = parse_s3_response(
1699            xml.as_bytes(),
1700            "https://bucket.s3.us-east-1.amazonaws.com/",
1701            #[cfg(feature = "s3-auth")]
1702            &None,
1703            #[cfg(feature = "s3-auth")]
1704            &None,
1705        )
1706        .unwrap();
1707        assert_eq!(releases.len(), 1, "one release parsed");
1708        let rel = &releases[0];
1709        assert_eq!(rel.name(), "myapp");
1710        assert_eq!(rel.version(), "1.2.3");
1711        assert_eq!(rel.assets.len(), 1);
1712        assert_eq!(rel.assets[0].name(), "myapp-1.2.3-x86_64-linux");
1713        assert!(
1714            rel.assets[0]
1715                .download_url()
1716                .starts_with("https://bucket.s3.us-east-1.amazonaws.com/"),
1717            "download URL uses the supplied base"
1718        );
1719        assert_eq!(rel.date(), "2024-01-01T00:00:00.000Z");
1720    }
1721
1722    #[test]
1723    fn parse_s3_response_v_prefix_stripped() {
1724        // A `v`-prefixed version tag (e.g. "myapp-v2.0.0-arm-linux") must have the `v` stripped
1725        // in the parsed release's `version` field, matching the regex's `[v]{0,1}` handling.
1726        let xml = list_bucket_xml(&["myapp-v2.0.0-arm-linux"]);
1727        let releases = parse_s3_response(
1728            xml.as_bytes(),
1729            "https://bucket/",
1730            #[cfg(feature = "s3-auth")]
1731            &None,
1732            #[cfg(feature = "s3-auth")]
1733            &None,
1734        )
1735        .unwrap();
1736        assert_eq!(releases.len(), 1);
1737        assert_eq!(releases[0].version(), "2.0.0", "v-prefix must be stripped");
1738    }
1739
1740    #[test]
1741    fn parse_s3_response_multi_asset_merge() {
1742        // Two <Contents> entries for the same name+version represent two assets of one release.
1743        // `add_to_releases_list` must merge them into a single release with two assets.
1744        // The Eof flush handles the last entry, and the interim flush (on the second <Contents>
1745        // start) handles the first.
1746        let xml = list_bucket_xml(&["myapp-3.0.0-x86_64-linux", "myapp-3.0.0-aarch64-linux"]);
1747        let releases = parse_s3_response(
1748            xml.as_bytes(),
1749            "https://bucket/",
1750            #[cfg(feature = "s3-auth")]
1751            &None,
1752            #[cfg(feature = "s3-auth")]
1753            &None,
1754        )
1755        .unwrap();
1756        assert_eq!(releases.len(), 1, "same name+version must be merged");
1757        assert_eq!(
1758            releases[0].assets.len(),
1759            2,
1760            "both assets present after merge"
1761        );
1762        let asset_names: Vec<&str> = releases[0].assets.iter().map(|a| a.name()).collect();
1763        assert!(
1764            asset_names.contains(&"myapp-3.0.0-x86_64-linux"),
1765            "x86_64 asset present"
1766        );
1767        assert!(
1768            asset_names.contains(&"myapp-3.0.0-aarch64-linux"),
1769            "aarch64 asset present"
1770        );
1771    }
1772
1773    #[test]
1774    fn parse_s3_response_multiple_releases() {
1775        // Multiple distinct name/version combinations produce separate release entries.
1776        // Also exercises the interim <Contents> flush path (not just the Eof flush).
1777        let xml = list_bucket_xml(&[
1778            "myapp-1.0.0-x86_64-linux",
1779            "myapp-2.0.0-x86_64-linux",
1780            "otherapp-1.5.0-x86_64-linux",
1781        ]);
1782        let releases = parse_s3_response(
1783            xml.as_bytes(),
1784            "https://bucket/",
1785            #[cfg(feature = "s3-auth")]
1786            &None,
1787            #[cfg(feature = "s3-auth")]
1788            &None,
1789        )
1790        .unwrap();
1791        assert_eq!(releases.len(), 3, "three distinct releases");
1792        let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1793        assert!(versions.contains(&"1.0.0"));
1794        assert!(versions.contains(&"2.0.0"));
1795        assert!(versions.contains(&"1.5.0"));
1796    }
1797
1798    #[test]
1799    fn parse_s3_response_skips_non_matching_keys() {
1800        // Keys that don't match the version regex (no semver-like version component) must be
1801        // silently ignored; only the matching entry produces a release.
1802        let xml = list_bucket_xml(&[
1803            "README.txt",
1804            "myapp-1.0.0-x86_64-linux",
1805            "some/random/path/no-version",
1806        ]);
1807        let releases = parse_s3_response(
1808            xml.as_bytes(),
1809            "https://bucket/",
1810            #[cfg(feature = "s3-auth")]
1811            &None,
1812            #[cfg(feature = "s3-auth")]
1813            &None,
1814        )
1815        .unwrap();
1816        assert_eq!(releases.len(), 1, "only matching key produces a release");
1817        assert_eq!(releases[0].version(), "1.0.0");
1818    }
1819
1820    #[test]
1821    fn parse_s3_response_prefix_path_stripped_to_filename() {
1822        // When the <Key> contains a directory prefix (e.g. "releases/myapp-1.0.0-linux"),
1823        // the asset `name` must be just the filename component, not the full path.
1824        let xml = list_bucket_xml(&["releases/myapp-1.0.0-x86_64-linux"]);
1825        let releases = parse_s3_response(
1826            xml.as_bytes(),
1827            "https://bucket/",
1828            #[cfg(feature = "s3-auth")]
1829            &None,
1830            #[cfg(feature = "s3-auth")]
1831            &None,
1832        )
1833        .unwrap();
1834        assert_eq!(releases.len(), 1);
1835        assert_eq!(
1836            releases[0].assets[0].name(),
1837            "myapp-1.0.0-x86_64-linux",
1838            "asset name is the filename, not the full key path"
1839        );
1840    }
1841
1842    #[test]
1843    fn parse_s3_response_malformed_xml_errors() {
1844        use std::error::Error as _;
1845        // A body that is not valid XML must surface as an `Err`, not panic.
1846        let bad_xml = "this is not xml at all <<<";
1847        let result = parse_s3_response(
1848            bad_xml.as_bytes(),
1849            "https://bucket/",
1850            #[cfg(feature = "s3-auth")]
1851            &None,
1852            #[cfg(feature = "s3-auth")]
1853            &None,
1854        );
1855        let err = result.expect_err("malformed XML must return Err");
1856        // the XML parse failure surfaces as `InvalidResponse` and chains the underlying
1857        // quick-xml error through `source()` (previously the source was stringified and dropped).
1858        assert!(
1859            matches!(err, crate::errors::Error::InvalidResponse { .. }),
1860            "malformed XML must surface as Error::InvalidResponse, got {:?}",
1861            err
1862        );
1863        assert!(
1864            err.source().is_some(),
1865            "InvalidResponse from XML parse must chain a non-None source()"
1866        );
1867    }
1868
1869    #[test]
1870    fn parse_s3_response_empty_body_returns_empty_vec() {
1871        // An empty/minimal XML document with no <Contents> produces an empty releases list (not
1872        // an error), since there is simply nothing to parse.
1873        let xml = "<?xml version=\"1.0\"?><ListBucketResult></ListBucketResult>";
1874        let releases = parse_s3_response(
1875            xml.as_bytes(),
1876            "https://bucket/",
1877            #[cfg(feature = "s3-auth")]
1878            &None,
1879            #[cfg(feature = "s3-auth")]
1880            &None,
1881        )
1882        .unwrap();
1883        assert!(releases.is_empty(), "empty bucket produces empty list");
1884    }
1885
1886    /// A test-double [`HttpResponse`](crate::http_client::HttpResponse) that streams a canned XML
1887    /// body, used to prove `parse_s3_response` reads from the trait's streaming `body_buffered()`
1888    /// path rather than a fully-buffered `String` (audit I7).
1889    struct XmlResponse {
1890        body: Vec<u8>,
1891    }
1892
1893    impl crate::http_client::HttpResponse for XmlResponse {
1894        fn headers(&self) -> &crate::http_client::HeaderMap {
1895            // Leak a fresh empty map so the borrow lives long enough; never read in this test.
1896            Box::leak(Box::new(crate::http_client::HeaderMap::new()))
1897        }
1898        fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
1899            Box::new(std::io::Cursor::new(self.body))
1900        }
1901    }
1902
1903    #[test]
1904    fn parse_s3_response_parses_from_streaming_body_buffered() {
1905        // The sync s3 fetch path feeds quick-xml from `resp.body_buffered()` (a streaming
1906        // `BufRead`) instead of `resp.text()`, so the XML is never fully buffered into a String.
1907        // Drive `parse_s3_response` from exactly that reader (the trait's default `body_buffered`
1908        // wraps `body()` in a BufReader) and assert it parses the release.
1909        let xml = list_bucket_xml(&["myapp-1.2.3-x86_64-linux"]);
1910        let resp: Box<dyn crate::http_client::HttpResponse> = Box::new(XmlResponse {
1911            body: xml.into_bytes(),
1912        });
1913        let reader = resp.body_buffered();
1914        let releases = parse_s3_response(
1915            reader,
1916            "https://bucket.s3.us-east-1.amazonaws.com/",
1917            #[cfg(feature = "s3-auth")]
1918            &None,
1919            #[cfg(feature = "s3-auth")]
1920            &None,
1921        )
1922        .unwrap();
1923        assert_eq!(releases.len(), 1, "one release parsed from the stream");
1924        assert_eq!(releases[0].version(), "1.2.3");
1925        assert_eq!(releases[0].assets[0].name(), "myapp-1.2.3-x86_64-linux");
1926    }
1927
1928    #[test]
1929    fn parse_s3_response_extracts_continuation_token_for_gcs_multipage_listing() {
1930        // C3 regression: GCS listings must page. When the first-page body is flagged truncated and
1931        // carries a `<NextContinuationToken>` (which GCS emits only under `list-type=2`), the parser
1932        // must surface that token so the driver fetches the next page. This drives the raw
1933        // `super::parse_s3_response` (the test wrapper drops the token) and asserts BOTH the token is
1934        // returned AND the first page's release is parsed, so pagination continues rather than
1935        // silently dropping later pages.
1936        let page1 = truncated_list_bucket_xml(&["myapp-1.0.0-x86_64-linux"], "GCS-TOKEN-PAGE-2");
1937        let (releases, next_token) = super::parse_s3_response(
1938            page1.as_bytes(),
1939            "https://storage.googleapis.com/gbucket/",
1940            #[cfg(feature = "s3-auth")]
1941            &None,
1942            #[cfg(feature = "s3-auth")]
1943            Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS),
1944            #[cfg(feature = "s3-auth")]
1945            &None,
1946        )
1947        .unwrap();
1948        assert_eq!(
1949            next_token.as_deref(),
1950            Some("GCS-TOKEN-PAGE-2"),
1951            "a truncated first page must surface its NextContinuationToken so pagination continues"
1952        );
1953        assert_eq!(
1954            releases.len(),
1955            1,
1956            "the first page's release is still parsed alongside the continuation token"
1957        );
1958        assert_eq!(releases[0].version(), "1.0.0");
1959    }
1960
1961    #[test]
1962    fn add_to_releases_list_skips_entries_with_empty_name_or_version() {
1963        // `add_to_releases_list` must silently drop a release whose name or version is empty,
1964        // matching the `if !rel.version().is_empty() && !rel.name.is_empty()` guard.
1965        let mut releases = Vec::new();
1966        let empty_name = Release::builder()
1967            .name("")
1968            .version("1.0.0")
1969            .build()
1970            .unwrap();
1971        let empty_ver = Release::builder()
1972            .name("myapp")
1973            .version("")
1974            .build()
1975            .unwrap();
1976        super::add_to_releases_list(&mut releases, empty_name);
1977        super::add_to_releases_list(&mut releases, empty_ver);
1978        assert!(
1979            releases.is_empty(),
1980            "entries with empty name or version must be dropped"
1981        );
1982    }
1983
1984    // ---------------------------------------------------------------------------
1985    // Async-fetch tests via a loopback TCP stub
1986    // ---------------------------------------------------------------------------
1987
1988    use std::io::{Read as _, Write as _};
1989    use std::net::TcpListener;
1990
1991    /// Serve a single XML response over a loopback TCP listener, one connection per `Resp`.
1992    /// Returns the base URL (`http://127.0.0.1:<port>/`).
1993    struct Resp {
1994        status: &'static str,
1995        body: String,
1996    }
1997
1998    fn stub(responses: Vec<Resp>) -> String {
1999        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2000        let base = format!("http://{}/", listener.local_addr().unwrap());
2001        std::thread::spawn(move || {
2002            for r in responses {
2003                let (mut stream, _) = match listener.accept() {
2004                    Ok(c) => c,
2005                    Err(_) => return,
2006                };
2007                let mut buf = [0u8; 4096];
2008                let _ = stream.read(&mut buf);
2009                let out = format!(
2010                    "HTTP/1.1 {}\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2011                    r.status,
2012                    r.body.len(),
2013                    r.body
2014                );
2015                let _ = stream.write_all(out.as_bytes());
2016                let _ = stream.flush();
2017            }
2018        });
2019        base
2020    }
2021
2022    /// Build a `fetch_releases_from_s3_async`-ready `Update` whose `Endpoint::Generic` points at
2023    /// the stub base URL. The Generic endpoint does not require a region.
2024    #[cfg(feature = "async")]
2025    fn s3_update(base_url: &str, current_version: &str) -> super::AsyncUpdate {
2026        Update::configure()
2027            .endpoint(super::Endpoint::Generic(base_url.to_owned()))
2028            .bucket_name("test-bucket")
2029            .bin_name("myapp")
2030            .current_version(current_version)
2031            .build_async()
2032            .unwrap()
2033    }
2034
2035    /// Sync sibling of [`s3_update`]: a sync `Update` pointed at the loopback stub via a
2036    /// `Generic` endpoint (no region required).
2037    fn s3_update_sync(base_url: &str, current_version: &str) -> Update {
2038        Update::configure()
2039            .endpoint(super::Endpoint::Generic(base_url.to_owned()))
2040            .bucket_name("test-bucket")
2041            .bin_name("myapp")
2042            .current_version(current_version)
2043            .build()
2044            .unwrap()
2045    }
2046
2047    /// Like [`stub`], but records each incoming request line so tests can assert on the query the
2048    /// client sent (e.g. the continuation token on the second request).
2049    fn stub_capturing(
2050        responses: Vec<Resp>,
2051    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
2052        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2053        let base = format!("http://{}/", listener.local_addr().unwrap());
2054        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2055        let sink = captured.clone();
2056        std::thread::spawn(move || {
2057            for r in responses {
2058                let (mut stream, _) = match listener.accept() {
2059                    Ok(c) => c,
2060                    Err(_) => return,
2061                };
2062                let mut buf = [0u8; 4096];
2063                let n = stream.read(&mut buf).unwrap_or(0);
2064                let req = String::from_utf8_lossy(&buf[..n]).into_owned();
2065                sink.lock()
2066                    .unwrap()
2067                    .push(req.lines().next().unwrap_or("").to_string());
2068                let out = format!(
2069                    "HTTP/1.1 {}\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2070                    r.status,
2071                    r.body.len(),
2072                    r.body
2073                );
2074                let _ = stream.write_all(out.as_bytes());
2075                let _ = stream.flush();
2076            }
2077        });
2078        (base, captured)
2079    }
2080
2081    // --- s3 continuation across a truncated (>100-key) listing ---------------------
2082
2083    #[test]
2084    fn s3_listing_follows_continuation_token_across_two_responses() {
2085        // Response 1 is flagged truncated with a NextContinuationToken; response 2 is the final
2086        // page. The driver must follow the token (sending it in the `continuation-token=` query of
2087        // the second request) and accumulate releases from BOTH responses.
2088        let page1 = truncated_list_bucket_xml(
2089            &["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"],
2090            "TOKEN-PAGE-2",
2091        );
2092        let page2 = list_bucket_xml(&["myapp-3.0.0-x86_64-linux"]);
2093        let (base, captured) = stub_capturing(vec![
2094            Resp {
2095                status: "200 OK",
2096                body: page1,
2097            },
2098            Resp {
2099                status: "200 OK",
2100                body: page2,
2101            },
2102        ]);
2103        let upd = s3_update_sync(&base, "0.1.0");
2104        let releases = upd.get_newer_releases().unwrap();
2105        let mut versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2106        versions.sort_unstable();
2107        assert_eq!(
2108            versions,
2109            vec!["1.0.0", "2.0.0", "3.0.0"],
2110            "releases from both the truncated page and the continuation page must be accumulated"
2111        );
2112        let requests = captured.lock().unwrap();
2113        assert_eq!(requests.len(), 2, "the truncated listing must be followed");
2114        assert!(
2115            !requests[0].contains("continuation-token="),
2116            "the first request must not carry a continuation token"
2117        );
2118        assert!(
2119            requests[1].contains("continuation-token=TOKEN-PAGE-2"),
2120            "the second request must carry the NextContinuationToken in its query, got: {}",
2121            requests[1]
2122        );
2123    }
2124
2125    #[cfg(feature = "async")]
2126    #[tokio::test]
2127    async fn s3_listing_follows_continuation_token_across_two_responses_async() {
2128        // Async sibling of the sync continuation test: the async driver must follow the
2129        // NextContinuationToken (carrying it in the second request's `continuation-token=` query)
2130        // and accumulate releases from BOTH the truncated page and the continuation page.
2131        let page1 = truncated_list_bucket_xml(
2132            &["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"],
2133            "TOKEN-PAGE-2",
2134        );
2135        let page2 = list_bucket_xml(&["myapp-3.0.0-x86_64-linux"]);
2136        let (base, captured) = stub_capturing(vec![
2137            Resp {
2138                status: "200 OK",
2139                body: page1,
2140            },
2141            Resp {
2142                status: "200 OK",
2143                body: page2,
2144            },
2145        ]);
2146        let upd = s3_update(&base, "0.1.0");
2147        let releases = upd.get_newer_releases_async().await.unwrap();
2148        let mut versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2149        versions.sort_unstable();
2150        assert_eq!(
2151            versions,
2152            vec!["1.0.0", "2.0.0", "3.0.0"],
2153            "async continuation must accumulate releases from both pages"
2154        );
2155        let requests = captured.lock().unwrap();
2156        assert_eq!(
2157            requests.len(),
2158            2,
2159            "the truncated listing must be followed over the async transport"
2160        );
2161        assert!(
2162            !requests[0].contains("continuation-token="),
2163            "the first async request must not carry a continuation token"
2164        );
2165        assert!(
2166            requests[1].contains("continuation-token=TOKEN-PAGE-2"),
2167            "the second async request must carry the NextContinuationToken, got: {}",
2168            requests[1]
2169        );
2170    }
2171
2172    // --- continuation under s3-auth, each continuation URL is FRESHLY SigV4-signed -----
2173
2174    #[cfg(feature = "s3-auth")]
2175    #[test]
2176    fn s3_continuation_signs_each_page_freshly_not_reusing_the_first_signature() {
2177        // Under s3-auth, the continuation page must be its OWN freshly-signed listing request: it
2178        // carries the continuation token AND a valid SigV4 signature, and that signature must NOT
2179        // be the first request's signature reused (the canonical request differs — the second URL
2180        // includes `continuation-token=` — so the signature must differ too).
2181        let page1 = truncated_list_bucket_xml(&["myapp-1.0.0-x86_64-linux"], "TOKEN-PAGE-2");
2182        let page2 = list_bucket_xml(&["myapp-2.0.0-x86_64-linux"]);
2183        let (base, captured) = stub_capturing(vec![
2184            Resp {
2185                status: "200 OK",
2186                body: page1,
2187            },
2188            Resp {
2189                status: "200 OK",
2190                body: page2,
2191            },
2192        ]);
2193        // A `Generic` endpoint pointed at the loopback stub, but WITH an access key + region so the
2194        // listing URLs are signed.
2195        let upd = Update::configure()
2196            .endpoint(super::Endpoint::Generic(base.clone()))
2197            .bucket_name("test-bucket")
2198            .region("us-east-1")
2199            .bin_name("myapp")
2200            .current_version("0.1.0")
2201            .access_key(("AKIA", "secret"))
2202            .build()
2203            .unwrap();
2204        let releases = upd.get_newer_releases().unwrap();
2205        let mut versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2206        versions.sort_unstable();
2207        assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
2208
2209        let requests = captured.lock().unwrap();
2210        assert_eq!(requests.len(), 2, "the signed listing must be continued");
2211
2212        // Both request lines must carry a SigV4 signature.
2213        assert!(
2214            requests[0].contains("X-Amz-Signature="),
2215            "first signed listing request missing a signature, got: {}",
2216            requests[0]
2217        );
2218        assert!(
2219            requests[1].contains("X-Amz-Signature="),
2220            "the continuation request must be freshly signed (a valid signature), got: {}",
2221            requests[1]
2222        );
2223        assert!(
2224            requests[1].contains("continuation-token="),
2225            "the continuation request must carry the token, got: {}",
2226            requests[1]
2227        );
2228
2229        // Extract the two signatures from the request lines and prove they differ — the second is
2230        // a genuine re-sign over the continuation URL, not the first signature copied over.
2231        let sig = |line: &str| -> String {
2232            line.split("X-Amz-Signature=")
2233                .nth(1)
2234                .unwrap_or("")
2235                .split(['&', ' '])
2236                .next()
2237                .unwrap_or("")
2238                .to_string()
2239        };
2240        let sig0 = sig(&requests[0]);
2241        let sig1 = sig(&requests[1]);
2242        assert!(
2243            !sig0.is_empty() && !sig1.is_empty(),
2244            "both signatures present"
2245        );
2246        assert_ne!(
2247            sig0, sig1,
2248            "the continuation signature must be freshly computed for the continuation URL, \
2249             not the first request's signature reused"
2250        );
2251    }
2252
2253    #[test]
2254    fn s3_listing_stops_when_not_truncated() {
2255        // A response with a NextContinuationToken but NO `<IsTruncated>true</IsTruncated>` must NOT
2256        // be followed — only `is_truncated` gates continuation.
2257        let body = "<?xml version=\"1.0\"?><ListBucketResult><Name>b</Name>\
2258             <NextContinuationToken>SHOULD-NOT-FOLLOW</NextContinuationToken>\
2259             <Contents><Key>myapp-1.0.0-x86_64-linux</Key>\
2260             <LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents></ListBucketResult>"
2261            .to_string();
2262        let (base, captured) = stub_capturing(vec![Resp {
2263            status: "200 OK",
2264            body,
2265        }]);
2266        let upd = s3_update_sync(&base, "0.1.0");
2267        let releases = upd.get_newer_releases().unwrap();
2268        assert_eq!(releases.all().len(), 1);
2269        assert_eq!(
2270            captured.lock().unwrap().len(),
2271            1,
2272            "a token without IsTruncated=true must not be followed"
2273        );
2274    }
2275
2276    // --- s3 max_keys clamp + query threading --------------------------------------------
2277
2278    #[test]
2279    fn max_keys_clamps_to_one_to_one_thousand() {
2280        assert_eq!(super::clamp_max_keys(0), 1, "0 clamps up to the 1 floor");
2281        assert_eq!(super::clamp_max_keys(1), 1);
2282        assert_eq!(super::clamp_max_keys(500), 500, "in-range passes through");
2283        assert_eq!(super::clamp_max_keys(1000), 1000);
2284        assert_eq!(
2285            super::clamp_max_keys(5000),
2286            1000,
2287            "above 1000 clamps to the 1000 ceiling"
2288        );
2289        assert_eq!(super::clamp_max_keys(u16::MAX), 1000);
2290    }
2291
2292    #[cfg(feature = "s3-auth")]
2293    #[test]
2294    fn signature_ttl_clamps_to_aws_expires_range() {
2295        use std::time::Duration;
2296        assert_eq!(
2297            super::clamp_signature_ttl(Duration::from_secs(0)),
2298            Duration::from_secs(1),
2299            "0 clamps up to the 1s floor"
2300        );
2301        assert_eq!(
2302            super::clamp_signature_ttl(Duration::from_secs(300)),
2303            Duration::from_secs(300),
2304            "in-range passes through"
2305        );
2306        assert_eq!(
2307            super::clamp_signature_ttl(Duration::from_secs(1_000_000)),
2308            Duration::from_secs(604_800),
2309            "above 7d clamps to the 604800s ceiling"
2310        );
2311    }
2312
2313    #[test]
2314    fn max_keys_setter_threads_into_the_listing_query() {
2315        // A configured `max_keys` (clamped) must appear as the `max-keys=` query param on the wire.
2316        let (base, captured) = stub_capturing(vec![Resp {
2317            status: "200 OK",
2318            body: list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]),
2319        }]);
2320        let upd = Update::configure()
2321            .endpoint(super::Endpoint::Generic(base.clone()))
2322            .bucket_name("test-bucket")
2323            .bin_name("myapp")
2324            .current_version("0.1.0")
2325            .max_keys(250u16)
2326            .build()
2327            .unwrap();
2328        let _ = upd.get_newer_releases().unwrap();
2329        let request = captured.lock().unwrap()[0].clone();
2330        assert!(
2331            request.contains("max-keys=250"),
2332            "the configured max_keys must appear in the listing query, got: {}",
2333            request
2334        );
2335    }
2336
2337    #[test]
2338    fn max_keys_setter_clamps_in_the_query() {
2339        // An out-of-range request (5000) must be clamped to 1000 in the on-the-wire query.
2340        let (base, captured) = stub_capturing(vec![Resp {
2341            status: "200 OK",
2342            body: list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]),
2343        }]);
2344        let upd = Update::configure()
2345            .endpoint(super::Endpoint::Generic(base.clone()))
2346            .bucket_name("test-bucket")
2347            .bin_name("myapp")
2348            .current_version("0.1.0")
2349            .max_keys(5000u16)
2350            .build()
2351            .unwrap();
2352        let _ = upd.get_newer_releases().unwrap();
2353        let request = captured.lock().unwrap()[0].clone();
2354        assert!(
2355            request.contains("max-keys=1000") && !request.contains("max-keys=5000"),
2356            "an over-cap max_keys must clamp to 1000 in the query, got: {}",
2357            request
2358        );
2359    }
2360
2361    // --- Sync `Releases`-returning fetch coverage (gap #1) ------------------------------------
2362    //
2363    // The s3 stub harness is otherwise only exercised by the async tests above. These pin the
2364    // *sync* `ReleaseUpdate` fetch methods on the same loopback stub: the one-element
2365    // `get_latest_release` wrap, the strictly-newer-filtered `get_newer_releases` list, the
2366    // current_version carry, and `is_update_available()` agreement between the two paths.
2367
2368    #[test]
2369    fn get_latest_release_sync_wraps_newest_and_carries_current_version() {
2370        // `get_latest_release` (sync) picks the highest version from the bucket listing and wraps
2371        // it in a one-element `Releases` carrying the configured current version, so the pre-check
2372        // works off the single newest release.
2373        let xml = list_bucket_xml(&[
2374            "myapp-0.9.0-x86_64-linux",
2375            "myapp-2.1.0-x86_64-linux",
2376            "myapp-1.0.0-x86_64-linux",
2377        ]);
2378        let base = stub(vec![Resp {
2379            status: "200 OK",
2380            body: xml,
2381        }]);
2382        let upd = s3_update_sync(&base, "1.0.0");
2383        let releases = upd.get_latest_release().unwrap();
2384        assert_eq!(
2385            releases.all().len(),
2386            1,
2387            "get_latest_release yields a one-element Releases"
2388        );
2389        assert_eq!(releases.latest().unwrap().version(), "2.1.0");
2390        assert!(
2391            releases.is_update_available().unwrap(),
2392            "2.1.0 > 1.0.0 via the one-element Releases pre-check"
2393        );
2394    }
2395
2396    #[test]
2397    fn get_newer_releases_sync_filters_to_newer_and_prechecks() {
2398        // `get_newer_releases` (sync) returns a `Releases` of strictly-newer releases (newest
2399        // first); `.is_update_available()` / `.latest()` work off it without a second fetch.
2400        let xml = list_bucket_xml(&[
2401            "myapp-0.9.0-x86_64-linux",
2402            "myapp-1.0.0-x86_64-linux",
2403            "myapp-1.5.0-x86_64-linux",
2404            "myapp-2.0.0-x86_64-linux",
2405        ]);
2406        let base = stub(vec![Resp {
2407            status: "200 OK",
2408            body: xml,
2409        }]);
2410        let upd = s3_update_sync(&base, "1.0.0");
2411        let releases = upd.get_newer_releases().unwrap();
2412        let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2413        assert_eq!(
2414            versions,
2415            vec!["2.0.0", "1.5.0"],
2416            "only releases strictly newer than current, newest-first"
2417        );
2418        assert_eq!(releases.latest().unwrap().version(), "2.0.0");
2419        assert!(releases.is_update_available().unwrap());
2420    }
2421
2422    // --- `ReleaseList::fetch` returns a listing `Releases` with NO current version,
2423    // so `current_version()` is `None` and `is_update_available()` errors with EXACTLY
2424    // `NoCurrentVersion`. `into_vec()` recovers the parsed release vec.
2425    #[test]
2426    fn release_list_fetch_returns_listing_releases_without_current_version() {
2427        let xml = list_bucket_xml(&["myapp-2.0.0-x86_64-linux", "myapp-1.0.0-x86_64-linux"]);
2428        let base = stub(vec![Resp {
2429            status: "200 OK",
2430            body: xml,
2431        }]);
2432        let releases = super::ReleaseList::configure()
2433            .endpoint(super::Endpoint::Generic(base.clone()))
2434            .bucket_name("test-bucket")
2435            .build()
2436            .unwrap()
2437            .fetch()
2438            .unwrap();
2439        assert_eq!(
2440            releases.current_version(),
2441            None,
2442            "a bare s3 listing carries no current version"
2443        );
2444        assert!(
2445            matches!(
2446                releases.is_update_available(),
2447                Err(crate::errors::Error::NoCurrentVersion)
2448            ),
2449            "is_update_available() on an s3 listing must error with NoCurrentVersion, got {:?}",
2450            releases.is_update_available()
2451        );
2452        let mut versions: Vec<String> = releases
2453            .into_vec()
2454            .into_iter()
2455            .map(|r| r.version().to_string())
2456            .collect();
2457        versions.sort();
2458        assert_eq!(
2459            versions,
2460            vec!["1.0.0".to_string(), "2.0.0".to_string()],
2461            "into_vec() recovers the parsed releases"
2462        );
2463    }
2464
2465    // --- async sibling of `release_list_fetch_returns_listing_releases_without_current_version`:
2466    // `ReleaseList::fetch_async` yields the same bare listing (no current version, NoCurrentVersion
2467    // from `is_update_available()`, `into_vec()` recovers the releases).
2468    #[cfg(feature = "async")]
2469    #[tokio::test]
2470    async fn release_list_fetch_async_returns_listing_releases_without_current_version() {
2471        let xml = list_bucket_xml(&["myapp-2.0.0-x86_64-linux", "myapp-1.0.0-x86_64-linux"]);
2472        let base = stub(vec![Resp {
2473            status: "200 OK",
2474            body: xml,
2475        }]);
2476        let releases = super::ReleaseList::configure()
2477            .endpoint(super::Endpoint::Generic(base.clone()))
2478            .bucket_name("test-bucket")
2479            .build()
2480            .unwrap()
2481            .fetch_async()
2482            .await
2483            .unwrap();
2484        assert_eq!(
2485            releases.current_version(),
2486            None,
2487            "a bare s3 listing carries no current version"
2488        );
2489        assert!(
2490            matches!(
2491                releases.is_update_available(),
2492                Err(crate::errors::Error::NoCurrentVersion)
2493            ),
2494            "is_update_available() on an s3 listing must error with NoCurrentVersion, got {:?}",
2495            releases.is_update_available()
2496        );
2497        let mut versions: Vec<String> = releases
2498            .into_vec()
2499            .into_iter()
2500            .map(|r| r.version().to_string())
2501            .collect();
2502        versions.sort();
2503        assert_eq!(
2504            versions,
2505            vec!["1.0.0".to_string(), "2.0.0".to_string()],
2506            "into_vec() recovers the parsed releases"
2507        );
2508    }
2509
2510    #[test]
2511    fn sync_is_update_available_agrees_between_paths_when_up_to_date() {
2512        // when the bucket's newest release equals the current version, the
2513        // one-element `get_latest_release` path (which keeps the newest even if equal) and the
2514        // strictly-newer-filtered `get_newer_releases` path must BOTH report not-available.
2515        let xml = || {
2516            list_bucket_xml(&[
2517                "myapp-2.0.0-x86_64-linux",
2518                "myapp-1.0.0-x86_64-linux",
2519                "myapp-0.9.0-x86_64-linux",
2520            ])
2521        };
2522
2523        let base = stub(vec![Resp {
2524            status: "200 OK",
2525            body: xml(),
2526        }]);
2527        let upd = s3_update_sync(&base, "2.0.0");
2528        let single = upd.get_latest_release().unwrap();
2529        assert_eq!(single.latest().unwrap().version(), "2.0.0");
2530        assert!(
2531            !single.is_update_available().unwrap(),
2532            "get_latest_release: newest (2.0.0) == current => not available"
2533        );
2534
2535        let base = stub(vec![Resp {
2536            status: "200 OK",
2537            body: xml(),
2538        }]);
2539        let upd = s3_update_sync(&base, "2.0.0");
2540        let list = upd.get_newer_releases().unwrap();
2541        assert!(
2542            list.all().is_empty(),
2543            "nothing strictly newer than 2.0.0 => empty list"
2544        );
2545        assert!(
2546            !list.is_update_available().unwrap(),
2547            "get_newer_releases agrees: not available"
2548        );
2549    }
2550
2551    /// Assert an s3 fetch result is the EXACT structured status error expected for `expected_status`,
2552    /// not merely "one of the three status variants". The load-bearing contract is that a non-2xx
2553    /// listing response is an `Err` carrying the precise status mapping (404 -> `NotFound`,
2554    /// 401/403 -> `Unauthorized`, else -> `HttpStatus`) and that `http_status()` recovers the code.
2555    /// Both reqwest and ureq must produce the identical variant for the same status, so this is
2556    /// client-agnostic and pins cross-client agreement.
2557    fn assert_status_err(
2558        res: crate::errors::Result<crate::update::Releases>,
2559        expected_status: u16,
2560    ) {
2561        use crate::errors::Error;
2562        let err = match res {
2563            Err(e) => e,
2564            Ok(_) => panic!(
2565                "a non-2xx ({}) listing response must surface as Err, got Ok",
2566                expected_status
2567            ),
2568        };
2569        assert_eq!(
2570            err.http_status(),
2571            Some(expected_status),
2572            "http_status() must recover the injected status {}, got {:?}",
2573            expected_status,
2574            err
2575        );
2576        match expected_status {
2577            404 => assert!(
2578                matches!(err, Error::NotFound { .. }),
2579                "status 404 must surface as Error::NotFound, got {:?}",
2580                err
2581            ),
2582            401 | 403 => assert!(
2583                matches!(err, Error::Unauthorized { status, .. } if status == expected_status),
2584                "status {} must surface as Error::Unauthorized, got {:?}",
2585                expected_status,
2586                err
2587            ),
2588            _ => assert!(
2589                matches!(err, Error::HttpStatus { status, .. } if status == expected_status),
2590                "status {} must surface as Error::HttpStatus, got {:?}",
2591                expected_status,
2592                err
2593            ),
2594        }
2595    }
2596
2597    /// Serve `status` (with an HTTP error body) over a fresh loopback stub and return the sync
2598    /// fetch result for `get_latest_release`. A fresh stub is required per call because the
2599    /// loopback server serves one response per connection.
2600    fn fetch_with_status(status: &'static str) -> crate::errors::Result<crate::update::Releases> {
2601        let base = stub(vec![Resp {
2602            status,
2603            body: "<Error><Code>NoSuchBucket</Code></Error>".to_string(),
2604        }]);
2605        let upd = s3_update_sync(&base, "0.1.0");
2606        upd.get_latest_release()
2607    }
2608
2609    #[test]
2610    fn fetch_404_surfaces_not_found() {
2611        // The s3 fetch path dropped its own `status()` check and now relies on
2612        // `http_client::get`/`send` bailing on a non-2xx status. A 404 must surface as the exact
2613        // `Error::NotFound` variant (not merely "some error", and never an `Ok` parsed from the
2614        // error body), on the sync lane of whichever http client is built in.
2615        assert_status_err(fetch_with_status("404 Not Found"), 404);
2616
2617        // `get_newer_releases` shares the same fetch path; a fresh stub is required because the
2618        // loopback server serves one response per connection.
2619        let base = stub(vec![Resp {
2620            status: "404 Not Found",
2621            body: "<Error><Code>NoSuchBucket</Code></Error>".to_string(),
2622        }]);
2623        let upd = s3_update_sync(&base, "0.1.0");
2624        assert_status_err(upd.get_newer_releases(), 404);
2625    }
2626
2627    #[test]
2628    fn fetch_401_and_403_surface_unauthorized() {
2629        // 401 and 403 both map to the `Unauthorized` variant, carrying their exact code.
2630        assert_status_err(fetch_with_status("401 Unauthorized"), 401);
2631        assert_status_err(fetch_with_status("403 Forbidden"), 403);
2632    }
2633
2634    #[test]
2635    fn fetch_500_and_503_surface_http_status() {
2636        // A server-error status that is not 404/401/403 maps to `HttpStatus` with its exact code.
2637        assert_status_err(fetch_with_status("500 Internal Server Error"), 500);
2638        assert_status_err(fetch_with_status("503 Service Unavailable"), 503);
2639    }
2640
2641    #[test]
2642    fn fetch_400_surfaces_http_status() {
2643        // Boundary: a 4xx that is not 404/401/403 (here 400) maps to `HttpStatus`, not
2644        // `Unauthorized`/`NotFound`.
2645        assert_status_err(fetch_with_status("400 Bad Request"), 400);
2646    }
2647
2648    #[test]
2649    fn get_release_version_sync_finds_exact_version() {
2650        // `get_release_version` (sync) returns only the release matching the requested version, and
2651        // errors when none matches.
2652        let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"]);
2653        let base = stub(vec![Resp {
2654            status: "200 OK",
2655            body: xml,
2656        }]);
2657        let upd = s3_update_sync(&base, "0.1.0");
2658        let rel = upd.get_release_version("1.0.0").unwrap();
2659        assert_eq!(rel.version(), "1.0.0");
2660
2661        let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]);
2662        let base = stub(vec![Resp {
2663            status: "200 OK",
2664            body: xml,
2665        }]);
2666        let upd = s3_update_sync(&base, "0.1.0");
2667        assert!(
2668            matches!(
2669                upd.get_release_version("9.9.9"),
2670                Err(crate::errors::Error::NoReleaseFound { .. })
2671            ),
2672            "missing version must surface as Error::NoReleaseFound"
2673        );
2674    }
2675
2676    #[cfg(feature = "async")]
2677    #[tokio::test]
2678    async fn fetch_releases_from_s3_async_parses_xml_response() {
2679        // Drive `fetch_releases_from_s3_async` against a loopback stub that returns a valid S3
2680        // `ListBucketResult` XML body, and assert the parsed releases.
2681        let xml = list_bucket_xml(&["myapp-2.1.0-x86_64-linux"]);
2682        let base = stub(vec![Resp {
2683            status: "200 OK",
2684            body: xml,
2685        }]);
2686        let upd = s3_update(&base, "0.1.0");
2687        let releases = upd.get_latest_release_async().await.unwrap();
2688        let rel = releases.latest().expect("one-element Releases");
2689        assert_eq!(rel.version(), "2.1.0");
2690        assert_eq!(rel.name(), "myapp");
2691    }
2692
2693    #[cfg(feature = "async")]
2694    #[tokio::test]
2695    async fn get_newer_releases_async_filters_to_newer_only() {
2696        // A ListBucketResult with releases at versions 0.9.0, 1.0.0, 1.5.0, and 2.0.0.
2697        // With current_version=1.0.0, only 1.5.0 and 2.0.0 should survive (newest-first).
2698        let xml = list_bucket_xml(&[
2699            "myapp-0.9.0-x86_64-linux",
2700            "myapp-1.0.0-x86_64-linux",
2701            "myapp-1.5.0-x86_64-linux",
2702            "myapp-2.0.0-x86_64-linux",
2703        ]);
2704        let base = stub(vec![Resp {
2705            status: "200 OK",
2706            body: xml,
2707        }]);
2708        let upd = s3_update(&base, "1.0.0");
2709        let releases = upd.get_newer_releases_async().await.unwrap();
2710        let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
2711        assert_eq!(
2712            versions,
2713            vec!["2.0.0", "1.5.0"],
2714            "only releases strictly newer than current, newest-first"
2715        );
2716    }
2717
2718    #[cfg(feature = "async")]
2719    #[tokio::test]
2720    async fn get_release_version_async_finds_exact_version() {
2721        // A ListBucketResult with two releases; `get_release_version_async` must return only the
2722        // one matching the requested version.
2723        let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"]);
2724        let base = stub(vec![Resp {
2725            status: "200 OK",
2726            body: xml,
2727        }]);
2728        let upd = s3_update(&base, "0.1.0");
2729        let rel = upd.get_release_version_async("1.0.0").await.unwrap();
2730        assert_eq!(rel.version(), "1.0.0");
2731    }
2732
2733    #[cfg(feature = "async")]
2734    #[tokio::test]
2735    async fn get_release_version_async_errors_on_missing_version() {
2736        // When the requested version does not exist in the bucket listing, the call must error
2737        // with `Error::Release`.
2738        let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux"]);
2739        let base = stub(vec![Resp {
2740            status: "200 OK",
2741            body: xml,
2742        }]);
2743        let upd = s3_update(&base, "0.1.0");
2744        let res = upd.get_release_version_async("9.9.9").await;
2745        assert!(
2746            matches!(res, Err(crate::errors::Error::NoReleaseFound { .. })),
2747            "missing version must surface as Error::NoReleaseFound"
2748        );
2749    }
2750
2751    #[cfg(feature = "async")]
2752    #[tokio::test]
2753    async fn is_update_available_async_true_then_false() {
2754        // the pre-check is `get_latest_release_async().await?.is_update_available()`.
2755        // The bucket's newest release is 2.0.0, so an update is available from 1.0.0 but not from
2756        // 2.0.0. A fresh stub is needed per call because the loopback server serves one response
2757        // per connection.
2758        let xml = list_bucket_xml(&["myapp-1.0.0-x86_64-linux", "myapp-2.0.0-x86_64-linux"]);
2759        let base = stub(vec![Resp {
2760            status: "200 OK",
2761            body: xml.clone(),
2762        }]);
2763        let upd = s3_update(&base, "1.0.0");
2764        assert!(
2765            upd.get_latest_release_async()
2766                .await
2767                .unwrap()
2768                .is_update_available()
2769                .unwrap(),
2770            "2.0.0 > 1.0.0 => update available"
2771        );
2772
2773        let base = stub(vec![Resp {
2774            status: "200 OK",
2775            body: xml,
2776        }]);
2777        let upd = s3_update(&base, "2.0.0");
2778        assert!(
2779            !upd.get_latest_release_async()
2780                .await
2781                .unwrap()
2782                .is_update_available()
2783                .unwrap(),
2784            "2.0.0 not newer than 2.0.0 => no update"
2785        );
2786    }
2787
2788    #[cfg(feature = "async")]
2789    #[tokio::test]
2790    async fn get_latest_release_async_multi_asset_merge() {
2791        // Two <Contents> entries for the same name+version are merged into a single release with
2792        // two assets. `get_latest_release_async` must return that merged release.
2793        let xml = list_bucket_xml(&["myapp-3.0.0-x86_64-linux", "myapp-3.0.0-aarch64-linux"]);
2794        let base = stub(vec![Resp {
2795            status: "200 OK",
2796            body: xml,
2797        }]);
2798        let upd = s3_update(&base, "0.1.0");
2799        let releases = upd.get_latest_release_async().await.unwrap();
2800        let rel = releases.latest().expect("one-element Releases");
2801        assert_eq!(rel.version(), "3.0.0");
2802        assert_eq!(
2803            rel.assets.len(),
2804            2,
2805            "both assets present after async multi-asset merge"
2806        );
2807    }
2808
2809    fn rel(version: &str) -> Release {
2810        Release::builder().version(version).build().unwrap()
2811    }
2812
2813    #[test]
2814    fn pick_latest_returns_highest_version() {
2815        let releases = [rel("1.0.0"), rel("2.3.1"), rel("2.0.0"), rel("1.9.9")];
2816        assert_eq!(super::pick_latest(&releases).unwrap().version(), "2.3.1");
2817    }
2818
2819    #[test]
2820    fn pick_latest_errors_on_empty() {
2821        assert!(super::pick_latest(&[]).is_err());
2822    }
2823
2824    // selection parity between the s3 `pick_latest`/`sort_newer` paths and the orchestrator's
2825    // `choose_latest_release`, all now built on the shared `cmp_releases_newest_first` comparator.
2826    // For a set with a newest compatible release, every path must agree on the same release
2827    // regardless of input order.
2828    #[test]
2829    fn selection_parity_pick_latest_sort_newer_and_choose_latest_release() {
2830        // Unordered candidate list, all strictly newer than 1.0.0 and mutually compatible.
2831        let make = || {
2832            vec![
2833                rel("1.3.0"),
2834                rel("1.1.0"),
2835                rel("1.4.2"),
2836                rel("1.0.5"),
2837                rel("1.2.0"),
2838            ]
2839        };
2840
2841        // s3 `pick_latest` selects the highest version overall.
2842        assert_eq!(super::pick_latest(&make()).unwrap().version(), "1.4.2");
2843
2844        // s3 `sort_newer` (newest-first) puts the same release first.
2845        let sorted = super::sort_newer(make(), "1.0.0");
2846        assert_eq!(sorted.first().unwrap().version(), "1.4.2");
2847
2848        // The orchestrator's `choose_latest_release` picks the newest compatible release — the same
2849        // one — regardless of the input order.
2850        let chosen = crate::update::testing::choose_latest_release_for_test(make(), "1.0.0")
2851            .unwrap()
2852            .expect("a newer compatible release is chosen");
2853        assert_eq!(chosen.version(), "1.4.2");
2854
2855        // And the reversed input must not change any of them.
2856        let mut reversed = make();
2857        reversed.reverse();
2858        assert_eq!(super::pick_latest(&reversed).unwrap().version(), "1.4.2");
2859        let chosen_rev = crate::update::testing::choose_latest_release_for_test(reversed, "1.0.0")
2860            .unwrap()
2861            .expect("a newer compatible release is chosen");
2862        assert_eq!(chosen_rev.version(), "1.4.2");
2863    }
2864
2865    #[test]
2866    fn pick_latest_ignores_unparseable_versions() {
2867        // `pick_latest` does NOT pre-filter, so its comparator's `Err(_)` branch (unparseable
2868        // version string) is reachable here. A release with a non-semver version must be ignored
2869        // and the highest parseable version still chosen. (`choose_latest_release`/`sort_newer`
2870        // pre-filter unparseable versions, so their comparator `Err(_)` arm is unreachable.)
2871        let releases = [rel("1.0.0"), rel("not-a-version"), rel("2.1.0")];
2872        assert_eq!(super::pick_latest(&releases).unwrap().version(), "2.1.0");
2873
2874        // Even when the unparseable one is first/last, it never wins.
2875        let releases = [rel("bogus"), rel("1.5.0")];
2876        assert_eq!(super::pick_latest(&releases).unwrap().version(), "1.5.0");
2877    }
2878
2879    #[test]
2880    fn sort_newer_ignores_unparseable_versions() {
2881        // The pre-filter drops the unparseable version before the sort; only parseable, strictly
2882        // newer versions survive, newest-first.
2883        let releases = vec![rel("garbage"), rel("2.0.0"), rel("1.5.0"), rel("1.0.0")];
2884        let newer = super::sort_newer(releases, "1.0.0");
2885        let versions: Vec<_> = newer.iter().map(|r| r.version()).collect();
2886        assert_eq!(versions, vec!["2.0.0", "1.5.0"]);
2887    }
2888
2889    #[cfg(feature = "s3-auth")]
2890    #[test]
2891    fn access_key_is_reexported_and_built_from_tuples() {
2892        // `AccessKey` is re-exported at the backend module level and is built via the tuple `From`
2893        // impls (it is `#[non_exhaustive]`, so no struct literal from outside this module).
2894        let from_strs: super::AccessKey = ("AKIA-id", "secret").into();
2895        assert_eq!(from_strs.access_key_id, "AKIA-id");
2896        assert_eq!(from_strs.secret_access_key, "secret");
2897
2898        let from_owned: super::AccessKey = (String::from("id2"), String::from("secret2")).into();
2899        assert_eq!(from_owned.access_key_id, "id2");
2900        assert_eq!(from_owned.secret_access_key, "secret2");
2901    }
2902
2903    #[cfg(feature = "s3-auth")]
2904    #[test]
2905    fn access_key_setter_accepts_tuple_and_reexported_type() {
2906        // The `access_key` setter takes `impl Into<AccessKey>`, so both a bare tuple and an
2907        // already-built `AccessKey` (named via the re-export) compile and build.
2908        let _ = Update::configure()
2909            .bucket_name("bucket")
2910            .region("us-east-1")
2911            .bin_name("my_bin")
2912            .current_version("0.1.0")
2913            .access_key(("id", "secret"))
2914            .build()
2915            .unwrap();
2916
2917        let key: super::AccessKey = ("id", "secret").into();
2918        let _ = Update::configure()
2919            .bucket_name("bucket")
2920            .region("us-east-1")
2921            .bin_name("my_bin")
2922            .current_version("0.1.0")
2923            .access_key(key)
2924            .build()
2925            .unwrap();
2926    }
2927
2928    #[test]
2929    fn filter_target_setter_exists_on_release_list_builder() {
2930        // The renamed `filter_target` setter must exist on the s3 `ReleaseListBuilder` and the
2931        // builder must build with the required fields present.
2932        let _list = super::ReleaseList::configure()
2933            .bucket_name("bucket")
2934            .region("us-east-1")
2935            .filter_target("x86_64-unknown-linux-gnu")
2936            .build()
2937            .unwrap();
2938    }
2939
2940    #[test]
2941    fn release_list_build_surfaces_invalid_header() {
2942        // A bad header on the `ReleaseListBuilder` must fail at `build()` via `request.check()`
2943        // with `Error::Config`, not panic.
2944        let res = super::ReleaseList::configure()
2945            .bucket_name("bucket")
2946            .request_header("inva lid", "ok")
2947            .build();
2948        assert!(
2949            matches!(res, Err(crate::errors::Error::InvalidHeader { .. })),
2950            "invalid header must surface as Error::InvalidHeader from ReleaseList build()"
2951        );
2952    }
2953
2954    #[test]
2955    fn sort_newer_keeps_only_newer_descending() {
2956        let releases = vec![rel("0.9.0"), rel("1.5.0"), rel("1.0.0"), rel("2.0.0")];
2957        let newer = super::sort_newer(releases, "1.0.0");
2958        // 0.9.0 and 1.0.0 are not strictly newer than 1.0.0; the rest are, newest-first.
2959        let versions: Vec<_> = newer.iter().map(|r| r.version()).collect();
2960        assert_eq!(versions, vec!["2.0.0", "1.5.0"]);
2961    }
2962
2963    #[test]
2964    fn find_version_matches_exact() {
2965        let releases = [rel("1.0.0"), rel("1.2.3"), rel("2.0.0")];
2966        assert_eq!(
2967            super::find_version(&releases, "1.2.3").unwrap().version(),
2968            "1.2.3"
2969        );
2970        assert!(super::find_version(&releases, "9.9.9").is_err());
2971    }
2972
2973    #[test]
2974    fn find_version_matches_v_prefixed_release_tag() {
2975        // Regression (I5): stored versions are bare semver (the S3 key parser strips any leading
2976        // `v`), so a `.release_tag("v1.2.3")` must still resolve. `find_version` normalizes the
2977        // requested tag by trimming a leading `v` before comparing; both the `v`-prefixed and the
2978        // bare form must select the same release, and a genuinely-absent tag still errors.
2979        let releases = [rel("1.0.0"), rel("1.2.3"), rel("2.0.0")];
2980        assert_eq!(
2981            super::find_version(&releases, "v1.2.3").unwrap().version(),
2982            "1.2.3",
2983            "a v-prefixed release_tag must match the bare stored semver"
2984        );
2985        assert_eq!(
2986            super::find_version(&releases, "1.2.3").unwrap().version(),
2987            "1.2.3",
2988            "the bare form must keep working"
2989        );
2990        assert!(
2991            super::find_version(&releases, "v9.9.9").is_err(),
2992            "a v-prefixed tag with no matching release still errors"
2993        );
2994    }
2995
2996    fn configured() -> Update {
2997        Update::configure()
2998            .bucket_name("bucket")
2999            .asset_prefix("prefix")
3000            .region("us-east-1")
3001            .bin_name("my_bin")
3002            .target("x86_64-unknown-linux-gnu")
3003            .asset_identifier("musl")
3004            .current_version("0.1.0")
3005            .build()
3006            .unwrap()
3007    }
3008
3009    #[test]
3010    fn identifier_and_str_accessors_are_wired() {
3011        let upd = configured();
3012        // `identifier` is newly supported on the s3 builder.
3013        assert_eq!(upd.asset_identifier(), Some("musl"));
3014        assert_eq!(upd.target(), "x86_64-unknown-linux-gnu");
3015        assert_eq!(upd.current_version(), "0.1.0");
3016    }
3017
3018    #[test]
3019    fn default_api_headers_is_a_noop() {
3020        // the `UpdateConfig::api_headers` trait default is a no-op (empty header map) - the
3021        // authorization scheme lives in the per-backend `RequestConfig`, not baked here. s3 passes
3022        // no `{api_headers}` override, so it gets the default: no headers, never an error (even for
3023        // a token that would not encode as a header value).
3024        let upd = configured();
3025        assert!(upd.api_headers(Some("bad\ntoken")).unwrap().is_empty());
3026        assert!(upd.api_headers(Some("good-token")).unwrap().is_empty());
3027        assert!(upd.api_headers(None).unwrap().is_empty());
3028    }
3029
3030    // ---------------------------------------------------------------------------
3031    // build_s3_api_url: endpoint/region/prefix URL construction (pure, no network)
3032    // ---------------------------------------------------------------------------
3033
3034    /// Call `build_s3_api_url`, threading the `s3-auth`-only `access_key` argument behind the
3035    /// feature gate so the same call site compiles with and without `s3-auth`. With no access key
3036    /// the returned `api_url` is unsigned, so the tests below can assert on the raw URL shape. Uses
3037    /// the default `max-keys` page size and no continuation token.
3038    fn api_url(
3039        endpoint: super::Endpoint,
3040        bucket: &str,
3041        region: Option<&str>,
3042        prefix: Option<&str>,
3043    ) -> crate::errors::Result<(String, String)> {
3044        super::build_s3_api_url(
3045            &endpoint,
3046            bucket,
3047            &region.map(str::to_owned),
3048            &prefix.map(str::to_owned),
3049            super::DEFAULT_MAX_KEYS,
3050            None,
3051            #[cfg(feature = "s3-auth")]
3052            Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS),
3053            #[cfg(feature = "s3-auth")]
3054            &None,
3055        )
3056    }
3057
3058    #[test]
3059    fn build_s3_api_url_s3_endpoint_shape() {
3060        // Endpoint::S3 forms `https://<bucket>.s3.<region>.amazonaws.com/` as the download base,
3061        // and the listing url appends the v2 `list-type=2&max-keys=...` query.
3062        let (base, url) =
3063            api_url(super::Endpoint::S3, "my-bucket", Some("eu-west-1"), None).unwrap();
3064        assert_eq!(base, "https://my-bucket.s3.eu-west-1.amazonaws.com/");
3065        assert_eq!(
3066            url,
3067            "https://my-bucket.s3.eu-west-1.amazonaws.com/?list-type=2&max-keys=1000"
3068        );
3069    }
3070
3071    #[test]
3072    fn build_s3_api_url_dualstack_endpoint_shape() {
3073        // Endpoint::S3DualStack injects the `dualstack` infix into the host.
3074        let (base, url) =
3075            api_url(super::Endpoint::S3DualStack, "b", Some("us-east-2"), None).unwrap();
3076        assert_eq!(base, "https://b.s3.dualstack.us-east-2.amazonaws.com/");
3077        assert!(url.starts_with("https://b.s3.dualstack.us-east-2.amazonaws.com/?list-type=2"));
3078    }
3079
3080    #[test]
3081    fn build_s3_api_url_digitalocean_endpoint_shape() {
3082        // Endpoint::DigitalOceanSpaces uses `<bucket>.<region>.digitaloceanspaces.com`.
3083        let (base, url) = api_url(
3084            super::Endpoint::DigitalOceanSpaces,
3085            "space",
3086            Some("nyc3"),
3087            None,
3088        )
3089        .unwrap();
3090        assert_eq!(base, "https://space.nyc3.digitaloceanspaces.com/");
3091        assert!(url.starts_with("https://space.nyc3.digitaloceanspaces.com/?list-type=2"));
3092    }
3093
3094    #[test]
3095    fn build_s3_api_url_gcs_ignores_region_but_uses_list_type_2() {
3096        // Endpoint::GCS targets `storage.googleapis.com/<bucket>/` and does NOT embed a region.
3097        // GCS's XML API supports the S3 ListObjectsV2 `list-type=2` param, which yields
3098        // `<NextContinuationToken>` pagination the parser follows; without it GCS falls back to V1
3099        // `<NextMarker>` pagination the parser ignores, silently dropping later pages. So the GCS
3100        // listing query must carry `list-type=2`, matching the S3/DualStack/DO/Generic arms.
3101        let (base, url) = api_url(super::Endpoint::GCS, "gbucket", None, None).unwrap();
3102        assert_eq!(base, "https://storage.googleapis.com/gbucket/");
3103        assert_eq!(
3104            url,
3105            "https://storage.googleapis.com/gbucket/?list-type=2&max-keys=1000"
3106        );
3107        assert!(
3108            url.contains("list-type=2"),
3109            "GCS listing must request list-type=2 so V2 NextContinuationToken pagination is used"
3110        );
3111    }
3112
3113    #[test]
3114    fn build_s3_api_url_generic_passes_endpoint_through() {
3115        // Endpoint::Generic uses the supplied URL verbatim as the download base (region is not
3116        // consumed) and appends the v2 `list-type=2` listing query.
3117        let (base, url) = api_url(
3118            super::Endpoint::Generic("https://s3.example.com/bucket/".to_owned()),
3119            "ignored-bucket",
3120            None,
3121            None,
3122        )
3123        .unwrap();
3124        assert_eq!(base, "https://s3.example.com/bucket/");
3125        assert_eq!(
3126            url,
3127            "https://s3.example.com/bucket/?list-type=2&max-keys=1000"
3128        );
3129    }
3130
3131    #[test]
3132    fn build_s3_api_url_generic_normalizes_missing_trailing_slash() {
3133        // Asset keys are appended directly to the download base (`format!("{base}{key}")`), so a
3134        // Generic endpoint supplied without a trailing `/` must be normalized; otherwise every
3135        // download URL is malformed (`...buckethey-1.0.0-linux`) and, under s3-auth, signed wrong.
3136        let (base, url) = api_url(
3137            super::Endpoint::Generic("https://s3.example.com/bucket".to_owned()),
3138            "ignored-bucket",
3139            None,
3140            None,
3141        )
3142        .unwrap();
3143        assert_eq!(base, "https://s3.example.com/bucket/");
3144        assert_eq!(
3145            url,
3146            "https://s3.example.com/bucket/?list-type=2&max-keys=1000"
3147        );
3148    }
3149
3150    #[test]
3151    fn build_s3_api_url_appends_asset_prefix() {
3152        // A configured asset_prefix is appended as `&prefix=<value>` to the listing query,
3153        // percent-encoded (so reserved characters do not corrupt the query or the signed form);
3154        // with no prefix the segment is absent.
3155        let (_base, with_prefix) = api_url(
3156            super::Endpoint::S3,
3157            "b",
3158            Some("us-east-1"),
3159            Some("releases/"),
3160        )
3161        .unwrap();
3162        assert!(
3163            with_prefix.ends_with("&prefix=releases%2F"),
3164            "prefix must be appended percent-encoded: {}",
3165            with_prefix
3166        );
3167        let (_base, no_prefix) =
3168            api_url(super::Endpoint::S3, "b", Some("us-east-1"), None).unwrap();
3169        assert!(
3170            !no_prefix.contains("prefix="),
3171            "no prefix segment when asset_prefix is None"
3172        );
3173    }
3174
3175    #[test]
3176    fn build_s3_api_url_missing_region_errors_for_region_endpoints() {
3177        // S3, S3DualStack and DigitalOceanSpaces all interpolate the region into the host, so a
3178        // missing region must surface as `Error::Config` (not a panic or a malformed URL).
3179        for ep in [
3180            super::Endpoint::S3,
3181            super::Endpoint::S3DualStack,
3182            super::Endpoint::DigitalOceanSpaces,
3183        ] {
3184            let res = api_url(ep, "b", None, None);
3185            assert!(
3186                matches!(
3187                    res,
3188                    Err(crate::errors::Error::MissingField { field: "region" })
3189                ),
3190                "region-requiring endpoint without region must error with Error::MissingField"
3191            );
3192        }
3193    }
3194
3195    #[test]
3196    fn build_s3_api_url_generic_and_gcs_succeed_without_region() {
3197        // Generic and GCS never read the region, so both must build successfully when region is
3198        // absent (the region-requiring endpoints are covered by the error test above).
3199        assert!(api_url(super::Endpoint::GCS, "b", None, None).is_ok());
3200        assert!(
3201            api_url(
3202                super::Endpoint::Generic("https://s3.example.com/".to_owned()),
3203                "b",
3204                None,
3205                None
3206            )
3207            .is_ok()
3208        );
3209    }
3210
3211    // The endpoint/region pairing is now validated at `build()` time (not deferred to the first
3212    // network call), so a region-requiring endpoint without a region fails where every other
3213    // required-field error is reported.
3214    #[test]
3215    fn build_errors_without_region_for_region_endpoints() {
3216        let res = Update::configure()
3217            .endpoint(super::Endpoint::S3)
3218            .bucket_name("bucket")
3219            .bin_name("bin")
3220            .current_version("0.1.0")
3221            .build();
3222        assert!(
3223            matches!(
3224                res,
3225                Err(crate::errors::Error::MissingField { field: "region" })
3226            ),
3227            "S3 endpoint without region must fail at build() with Error::MissingField"
3228        );
3229
3230        let list = super::ReleaseList::configure()
3231            .endpoint(super::Endpoint::DigitalOceanSpaces)
3232            .bucket_name("bucket")
3233            .build();
3234        assert!(
3235            matches!(
3236                list,
3237                Err(crate::errors::Error::MissingField { field: "region" })
3238            ),
3239            "ReleaseList build() must also enforce the region requirement"
3240        );
3241    }
3242
3243    // Async sibling of `build_errors_without_region_for_region_endpoints`: `build_async()` runs the
3244    // same `check_endpoint_region` validation as the sync `build()`, so a region-requiring endpoint
3245    // without a region must fail at `build_async()` (not be deferred to the first network call), and
3246    // a region-free endpoint (GCS/Generic) must build without one.
3247    #[cfg(feature = "async")]
3248    #[test]
3249    fn build_async_errors_without_region_for_region_endpoints() {
3250        let res = Update::configure()
3251            .endpoint(super::Endpoint::S3)
3252            .bucket_name("b")
3253            .bin_name("x")
3254            .current_version("0.1.0")
3255            .build_async();
3256        assert!(
3257            matches!(
3258                res,
3259                Err(crate::errors::Error::MissingField { field: "region" })
3260            ),
3261            "S3 endpoint without region must fail at build_async() with Error::MissingField, got {:?}",
3262            res.map(|_| "Ok")
3263        );
3264    }
3265
3266    #[cfg(feature = "async")]
3267    #[test]
3268    fn build_async_succeeds_without_region_for_generic_and_gcs() {
3269        assert!(
3270            Update::configure()
3271                .endpoint(super::Endpoint::GCS)
3272                .bucket_name("b")
3273                .bin_name("x")
3274                .current_version("0.1.0")
3275                .build_async()
3276                .is_ok(),
3277            "GCS endpoint needs no region at build_async()"
3278        );
3279        assert!(
3280            Update::configure()
3281                .endpoint(super::Endpoint::Generic(
3282                    "https://s3.example.com/".to_owned()
3283                ))
3284                .bucket_name("b")
3285                .bin_name("x")
3286                .current_version("0.1.0")
3287                .build_async()
3288                .is_ok(),
3289            "Generic endpoint needs no region at build_async()"
3290        );
3291    }
3292
3293    #[test]
3294    fn build_succeeds_without_region_for_generic_and_gcs() {
3295        assert!(
3296            Update::configure()
3297                .endpoint(super::Endpoint::GCS)
3298                .bucket_name("bucket")
3299                .bin_name("bin")
3300                .current_version("0.1.0")
3301                .build()
3302                .is_ok()
3303        );
3304        assert!(
3305            Update::configure()
3306                .endpoint(super::Endpoint::Generic(
3307                    "https://s3.example.com/".to_owned()
3308                ))
3309                .bucket_name("bucket")
3310                .bin_name("bin")
3311                .current_version("0.1.0")
3312                .build()
3313                .is_ok()
3314        );
3315    }
3316
3317    // ---------------------------------------------------------------------------
3318    // s3-auth: SigV4 query-signing structural invariants (deterministic, no real
3319    // signature assertions since the timestamp/HMAC are time-dependent)
3320    // ---------------------------------------------------------------------------
3321
3322    #[cfg(feature = "s3-auth")]
3323    #[test]
3324    fn s3_signature_v4_no_access_key_returns_url_unchanged() {
3325        // With no access key the signer is a no-op: the URL is returned verbatim, so an
3326        // unauthenticated (public-bucket) request carries no SigV4 query params.
3327        let url = "https://b.s3.us-east-1.amazonaws.com/key?list-type=2";
3328        let out = super::auth::s3_signature_v4(url, &Some("us-east-1".into()), &None, 300).unwrap();
3329        assert_eq!(out, url, "missing access key must return the URL unchanged");
3330    }
3331
3332    #[cfg(feature = "s3-auth")]
3333    #[test]
3334    fn s3_signature_v4_signed_url_has_required_query_params() {
3335        // With an access key the signer appends the AWS SigV4 presigned-query params. We cannot
3336        // assert the exact signature (it depends on the current wall-clock timestamp and HMAC),
3337        // but the structural invariants are deterministic.
3338        let url = "https://b.s3.us-east-1.amazonaws.com/path/to/key";
3339        let key: super::AccessKey = ("AKIAEXAMPLE", "secretkey").into();
3340        let out =
3341            super::auth::s3_signature_v4(url, &Some("us-east-1".into()), &Some(key), 300).unwrap();
3342        assert!(out.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
3343        assert!(out.contains("X-Amz-Credential="));
3344        assert!(out.contains("X-Amz-Date="));
3345        assert!(out.contains("X-Amz-Expires=300"));
3346        assert!(out.contains("X-Amz-SignedHeaders=host"));
3347        assert!(out.contains("X-Amz-Signature="));
3348        // The signature is the final param and is a lowercase hex SHA-256 HMAC (64 hex chars).
3349        let sig = out
3350            .rsplit("X-Amz-Signature=")
3351            .next()
3352            .expect("signature param present");
3353        assert_eq!(sig.len(), 64, "SigV4 signature is 32 bytes hex-encoded");
3354        assert!(
3355            sig.bytes().all(|b| b.is_ascii_hexdigit()),
3356            "signature must be lowercase hex"
3357        );
3358        // The credential scope embeds the region and the s3/aws4_request terminator. The whole
3359        // X-Amz-Credential value is URI-encoded in the query string, so the scope separators are
3360        // percent-encoded slashes (`%2F`).
3361        assert!(
3362            out.contains("%2Fus-east-1%2Fs3%2Faws4_request"),
3363            "credential scope must embed region and service scope: {}",
3364            out
3365        );
3366        // The base path is preserved ahead of the query string.
3367        assert!(out.starts_with("https://b.s3.us-east-1.amazonaws.com/path/to/key?"));
3368    }
3369
3370    // variant-routing: the regex-build `InvalidResponse` branch in `parse_s3_response`
3371    // (~line 960) maps a `regex::Error` into `Error::InvalidResponse { source: Box::new(err) }`.
3372    // The pattern compiled there is a fixed string literal that always builds, so that exact branch
3373    // is statically unreachable from any test input (no interpolation, no runtime data). What IS
3374    // verifiable is the error-routing the branch performs: a real `regex::Error` boxed the same way
3375    // must produce `Error::InvalidResponse` whose `source()` chains the regex error. Only the
3376    // XML-parse `InvalidResponse` branch (~line 1038) is exercised end-to-end; this pins the
3377    // regex-build mapping by type so a regression that routed regex build failures elsewhere (or
3378    // dropped the `source`) is caught.
3379    #[test]
3380    fn regex_build_error_maps_to_invalid_response_with_source() {
3381        use std::error::Error as _;
3382        // An intentionally-malformed pattern produces a genuine `regex::Error`. The pattern is
3383        // assembled at runtime (not a literal) so the clippy `invalid_regex` lint -- which only
3384        // validates literal patterns -- does not reject this deliberately-broken input.
3385        let bad_pattern = String::from("(");
3386        let err =
3387            super::Regex::new(&bad_pattern).expect_err("an unbalanced group must fail to compile");
3388        let inner_shown = err.to_string();
3389        let mapped = crate::errors::Error::InvalidResponse {
3390            source: Box::new(err),
3391        };
3392        assert!(
3393            matches!(mapped, crate::errors::Error::InvalidResponse { .. }),
3394            "a regex build failure must route to Error::InvalidResponse, got {:?}",
3395            mapped
3396        );
3397        let chained = mapped
3398            .source()
3399            .expect("InvalidResponse from a regex error must chain a source()");
3400        assert!(
3401            chained.to_string().contains(&inner_shown),
3402            "source() must surface the underlying regex error, got: {}",
3403            chained
3404        );
3405    }
3406
3407    // variant-routing: the SigV4 host-extraction failure in `s3_signature_v4` (~line 699). A URL
3408    // that parses but has no authority/host (a non-special scheme such as `mailto:`) reaches
3409    // `url.host_str() == None` and must route to EXACTLY `Error::S3Auth`, with the offending URL
3410    // embedded in the source message.
3411    #[cfg(feature = "s3-auth")]
3412    #[test]
3413    fn s3_signature_v4_hostless_url_routes_to_s3auth() {
3414        let key: super::AccessKey = ("AKIA", "secret").into();
3415        // `mailto:` parses (so we get past `Url::parse`) but has no host, so `host_str()` is None
3416        // and the `ok_or_else` fires the `S3Auth` branch.
3417        let res = super::auth::s3_signature_v4("mailto:nobody@example.com", &None, &Some(key), 300);
3418        match res {
3419            Err(crate::errors::Error::S3Auth(source)) => {
3420                assert!(
3421                    source.to_string().contains("mailto:nobody@example.com"),
3422                    "S3Auth source must embed the offending URL, got: {}",
3423                    source
3424                );
3425            }
3426            other => panic!(
3427                "a hostless signed URL must route to Error::S3Auth, got {:?}",
3428                other
3429            ),
3430        }
3431    }
3432
3433    #[cfg(feature = "s3-auth")]
3434    #[test]
3435    fn s3_signature_v4_defaults_region_to_us_east_1() {
3436        // When region is None the signer falls back to `us-east-1` in the credential scope.
3437        let key: super::AccessKey = ("AKIA", "secret").into();
3438        let out =
3439            super::auth::s3_signature_v4("https://b.s3.amazonaws.com/k", &None, &Some(key), 300)
3440                .unwrap();
3441        assert!(
3442            out.contains("%2Fus-east-1%2Fs3%2Faws4_request"),
3443            "absent region must default to us-east-1: {}",
3444            out
3445        );
3446    }
3447
3448    // --- signature_ttl is threaded into the SigV4 X-Amz-Expires of signed URLs --------
3449
3450    #[cfg(feature = "s3-auth")]
3451    #[test]
3452    fn signature_ttl_appears_as_the_expiry_in_signed_urls() {
3453        // The configured `signature_ttl` must drive the `X-Amz-Expires=` query param of the signed
3454        // listing URL, replacing the previously hardcoded 300s. Drive `build_s3_api_url` with a
3455        // non-default TTL and assert the expiry matches.
3456        let key: super::AccessKey = ("AKIA", "secret").into();
3457        let region = Some("us-east-1".to_owned());
3458        let (_base, signed) = super::build_s3_api_url(
3459            &super::Endpoint::S3,
3460            "b",
3461            &region,
3462            &None,
3463            super::DEFAULT_MAX_KEYS,
3464            None,
3465            Duration::from_secs(7200),
3466            &Some(key),
3467        )
3468        .unwrap();
3469        assert!(
3470            signed.contains("X-Amz-Expires=7200"),
3471            "the configured signature_ttl must appear as X-Amz-Expires, got: {}",
3472            signed
3473        );
3474        assert!(
3475            !signed.contains("X-Amz-Expires=300"),
3476            "the default 300s must be overridden by the configured TTL"
3477        );
3478    }
3479
3480    #[cfg(feature = "s3-auth")]
3481    #[test]
3482    fn signature_ttl_setter_threads_into_the_built_update() {
3483        // End-to-end: the `signature_ttl` builder setter must reach the signed listing URL. Build
3484        // an `Update` with a 600s TTL and a Generic endpoint (no region needed), then sign its
3485        // listing plan's URL and check the expiry. (We assert via the plan's URL since the listing
3486        // plan signs the listing URL at construction.)
3487        let upd = Update::configure()
3488            .endpoint(super::Endpoint::S3)
3489            .bucket_name("b")
3490            .region("us-east-1")
3491            .bin_name("myapp")
3492            .current_version("0.1.0")
3493            .access_key(("AKIA", "secret"))
3494            .signature_ttl(Duration::from_secs(600))
3495            .build_update()
3496            .unwrap();
3497        let plan = upd.listing_plan().unwrap();
3498        assert!(
3499            plan.url.contains("X-Amz-Expires=600"),
3500            "the configured signature_ttl must thread into the signed listing URL, got: {}",
3501            plan.url
3502        );
3503    }
3504
3505    #[cfg(feature = "s3-auth")]
3506    #[test]
3507    fn build_s3_api_url_signs_listing_url_when_access_key_present() {
3508        // The listing url returned by `build_s3_api_url` is signed when an access key is supplied,
3509        // and left unsigned otherwise. This exercises the s3-auth branch at the call site (not
3510        // just the bare signer), preserving the existing `list-type=2` query under signing.
3511        let key: super::AccessKey = ("AKIA", "secret").into();
3512        let region = Some("us-east-1".to_owned());
3513        let ttl = Duration::from_secs(super::DEFAULT_SIGNATURE_TTL_SECS);
3514        let (_base, signed) = super::build_s3_api_url(
3515            &super::Endpoint::S3,
3516            "b",
3517            &region,
3518            &None,
3519            super::DEFAULT_MAX_KEYS,
3520            None,
3521            ttl,
3522            &Some(key),
3523        )
3524        .unwrap();
3525        assert!(signed.contains("X-Amz-Signature="));
3526        assert!(signed.contains("X-Amz-Credential="));
3527        assert!(
3528            signed.contains("list-type=2"),
3529            "the original listing query must survive signing"
3530        );
3531
3532        let (_base, unsigned) = super::build_s3_api_url(
3533            &super::Endpoint::S3,
3534            "b",
3535            &region,
3536            &None,
3537            super::DEFAULT_MAX_KEYS,
3538            None,
3539            ttl,
3540            &None,
3541        )
3542        .unwrap();
3543        assert!(
3544            !unsigned.contains("X-Amz-Signature="),
3545            "no access key => unsigned listing url"
3546        );
3547    }
3548
3549    #[cfg(feature = "s3-auth")]
3550    #[test]
3551    fn parse_s3_response_signs_download_urls_when_access_key_present() {
3552        // Under s3-auth, `parse_s3_response` signs each asset's download URL. The unsigned vs
3553        // signed distinction is the load-bearing behavior: with an access key the asset URL gains
3554        // the SigV4 presign params; with none it stays a plain URL.
3555        let xml = list_bucket_xml(&["myapp-1.2.3-x86_64-linux"]);
3556        let region = Some("us-east-1".to_owned());
3557
3558        let key: super::AccessKey = ("AKIA", "secret").into();
3559        let signed = parse_s3_response(
3560            xml.as_bytes(),
3561            "https://b.s3.us-east-1.amazonaws.com/",
3562            &region,
3563            &Some(key),
3564        )
3565        .unwrap();
3566        let signed_url = signed[0].assets[0].download_url();
3567        assert!(
3568            signed_url.contains("X-Amz-Signature=") && signed_url.contains("X-Amz-Credential="),
3569            "signed asset download url must carry SigV4 params: {}",
3570            signed_url
3571        );
3572
3573        let unsigned = parse_s3_response(
3574            xml.as_bytes(),
3575            "https://b.s3.us-east-1.amazonaws.com/",
3576            &region,
3577            &None,
3578        )
3579        .unwrap();
3580        let unsigned_url = unsigned[0].assets[0].download_url();
3581        assert!(
3582            !unsigned_url.contains("X-Amz-Signature="),
3583            "no access key => unsigned asset download url: {}",
3584            unsigned_url
3585        );
3586        assert_eq!(
3587            unsigned_url, "https://b.s3.us-east-1.amazonaws.com/myapp-1.2.3-x86_64-linux",
3588            "unsigned download url is the plain base+key"
3589        );
3590    }
3591
3592    #[test]
3593    fn api_headers_uses_the_trait_default_no_override() {
3594        // s3 passes NO `{api_headers}` override to `impl_update_config_accessors!`, so it gets the
3595        // `UpdateConfig` trait default. After B5 that default is a no-op (no User-Agent, no
3596        // Authorization): s3 authenticates via SigV4 on the URL, not via this header path.
3597        let upd = configured();
3598        let headers = upd.api_headers(Some("secret")).unwrap();
3599        assert!(
3600            headers
3601                .get(crate::http_client::header::USER_AGENT)
3602                .is_none(),
3603            "the default api_headers (no override) must not set a User-Agent"
3604        );
3605        assert!(
3606            headers
3607                .get(crate::http_client::header::AUTHORIZATION)
3608                .is_none(),
3609            "the default api_headers is a no-op; s3 signs the URL instead of setting an auth header"
3610        );
3611    }
3612
3613    // the deprecated s3 `auth_token` shims have been removed. s3 authenticates via
3614    // `.access_key((id, secret))` under the `s3-auth` feature; there is no `auth_token` setter on
3615    // either s3 builder. (A compile-fail test would require trybuild; the removal is covered by the
3616    // shim methods no longer existing.)
3617
3618    // the `endpoint(impl Into<Endpoint>)` setter must resolve a bare `&str` and a
3619    // `String` through the `From` impls into `Endpoint::Generic`, so callers can pass a URL string
3620    // directly without naming the enum. Pins both `From<&str>` and `From<String>`.
3621    #[test]
3622    fn endpoint_from_str_and_string_resolve_to_generic() {
3623        // From<&str>
3624        let from_str: super::Endpoint = "https://minio.example.com/bucket/".into();
3625        assert!(
3626            matches!(&from_str, super::Endpoint::Generic(u) if u == "https://minio.example.com/bucket/"),
3627            "a &str must resolve to Endpoint::Generic with the URL verbatim, got {from_str:?}"
3628        );
3629        // From<String>
3630        let owned = String::from("https://gcs.example.com/bucket/");
3631        let from_string: super::Endpoint = owned.into();
3632        assert!(
3633            matches!(&from_string, super::Endpoint::Generic(u) if u == "https://gcs.example.com/bucket/"),
3634            "a String must resolve to Endpoint::Generic with the URL verbatim, got {from_string:?}"
3635        );
3636    }
3637
3638    // The setter accepts `Into<Endpoint>` so a `&str` reaches the build path and is used as the
3639    // download base verbatim (Generic passes the endpoint through). This proves the setter's
3640    // `impl Into<Endpoint>` bound actually resolves a string at a real call site, not just the
3641    // `From` impl in isolation.
3642    #[test]
3643    fn endpoint_setter_accepts_a_bare_str() {
3644        let upd = super::Update::configure()
3645            .bucket_name("b")
3646            .asset_prefix("p")
3647            .endpoint("https://generic.example.com/bucket/")
3648            .bin_name("app")
3649            .current_version("0.1.0")
3650            .build();
3651        // The Generic endpoint needs no region, so the build must succeed and carry the string
3652        // through (region-requiring endpoints would error without a region).
3653        assert!(
3654            upd.is_ok(),
3655            "the endpoint(&str) setter must resolve to a Generic endpoint and build, got {:?}",
3656            upd.err()
3657        );
3658    }
3659}