Skip to main content

self_update/backends/
github.rs

1/*!
2GitHub releases
3*/
4use crate::http_client::{HeaderMap, header};
5
6use crate::backends::common::{CommonBuilderConfig, CommonConfig, RequestConfig};
7use crate::backends::{Page, PageRequest, first_page_url, next_link, run_paginated};
8use crate::version::bump_is_greater;
9use crate::{
10    errors::*,
11    update::{Release, ReleaseAsset, ReleaseUpdate, Releases},
12};
13use serde::Deserialize;
14
15/// GitHub's canonical API host. A token resolved from the environment is bound to whatever host the
16/// application configured, so `build()` warns when that host is neither this one nor an
17/// acknowledged `allow_auth_host` entry (see
18/// [`env_token_host_decision`](crate::backends::common::env_token_host_decision)).
19const CANONICAL_AUTH_HOST: &str = "api.github.com";
20
21/// GitHub release-asset JSON shape. Private DTO deserialized directly from the response bytes, then
22/// converted into the public [`ReleaseAsset`]. Keeping it private means `Deserialize` is never part
23/// of the public `ReleaseAsset` API.
24#[derive(Deserialize)]
25struct AssetDto {
26    name: Option<String>,
27    url: Option<String>,
28    /// Content digest in `algorithm:hex` form (e.g. `sha256:2cf24d…`); github publishes one per
29    /// asset since mid-2025. Optional so older payloads (and enterprise instances) still parse.
30    digest: Option<String>,
31}
32
33impl AssetDto {
34    fn into_asset(self) -> Result<ReleaseAsset> {
35        let download_url = self.url.ok_or_else(|| Error::missing_asset_field("url"))?;
36        let name = self
37            .name
38            .ok_or_else(|| Error::missing_asset_field("name"))?;
39        let asset = ReleaseAsset::new(name, download_url);
40        Ok(match self.digest {
41            Some(digest) => asset.with_digest(digest),
42            None => asset,
43        })
44    }
45}
46
47/// GitHub release JSON shape. Private DTO deserialized directly from the response bytes (replacing
48/// the old `serde_json::Value` walk), then converted into the public [`Release`].
49#[derive(Deserialize)]
50struct ReleaseDto {
51    tag_name: Option<String>,
52    created_at: Option<String>,
53    name: Option<String>,
54    body: Option<String>,
55    html_url: Option<String>,
56    assets: Option<Vec<AssetDto>>,
57}
58
59impl ReleaseDto {
60    fn into_release(self, tag_prefix: Option<&str>) -> Result<Release> {
61        let tag = self
62            .tag_name
63            .ok_or_else(|| Error::missing_asset_field("tag_name"))?;
64        let date = self
65            .created_at
66            .ok_or_else(|| Error::missing_asset_field("created_at"))?;
67        let assets = self
68            .assets
69            .ok_or_else(|| Error::missing_asset_field("assets"))?;
70        let name = self.name.unwrap_or_else(|| tag.clone());
71        let assets = assets
72            .into_iter()
73            .map(AssetDto::into_asset)
74            .collect::<Result<Vec<ReleaseAsset>>>()?;
75        let version =
76            crate::backends::common::strip_tag_prefix(&tag, tag_prefix).ok_or_else(|| {
77                crate::backends::common::tag_prefix_mismatch_error(
78                    &tag,
79                    tag_prefix.unwrap_or_default(),
80                )
81            })?;
82        let mut builder = Release::builder();
83        builder
84            .name(name)
85            .version(version)
86            .date(date)
87            .assets(assets);
88        if let Some(body) = self.body {
89            builder.body(body);
90        }
91        if let Some(url) = self.html_url {
92            builder.release_notes_url(url);
93        }
94        builder
95            .build()
96            .map_err(|e| crate::backends::common::name_tag_in_semver_error(&tag, e))
97    }
98}
99
100/// `ReleaseList` Builder
101///
102/// `Debug` is hand-written (not derived) so `auth_token` renders as `"<token>"` instead of printing
103/// a live credential from a `log::debug!("{builder:?}")`.
104#[derive(Clone)]
105#[must_use]
106pub struct ReleaseListBuilder {
107    repo_owner: Option<String>,
108    repo_name: Option<String>,
109    target: Option<String>,
110    auth_token: Option<String>,
111    /// `true` when `auth_token` came from `auth_token_from_env()`; cleared by `auth_token(..)`.
112    auth_token_from_env: bool,
113    custom_url: Option<String>,
114    request: RequestConfig,
115}
116
117impl std::fmt::Debug for ReleaseListBuilder {
118    /// Every field, with the token redacted exactly as `RequestConfig`'s `Debug` does.
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        // Exhaustive, no `..`: a field added to the struct and not listed here is a compile error
121        // (E6, same guard as the gitea/gitee twins and both `common.rs` impls).
122        let Self {
123            repo_owner,
124            repo_name,
125            target,
126            auth_token,
127            auth_token_from_env,
128            custom_url,
129            request,
130        } = self;
131        f.debug_struct("ReleaseListBuilder")
132            .field("repo_owner", repo_owner)
133            .field("repo_name", repo_name)
134            .field("target", target)
135            .field("auth_token", &auth_token.as_ref().map(|_| "<token>"))
136            .field("auth_token_from_env", auth_token_from_env)
137            .field("custom_url", custom_url)
138            .field("request", request)
139            .finish()
140    }
141}
142
143impl ReleaseListBuilder {
144    /// Required. Set the repo owner, used to build a github api url
145    pub fn repo_owner(&mut self, owner: impl Into<String>) -> &mut Self {
146        self.repo_owner = Some(owner.into());
147        self
148    }
149
150    /// Required. Set the repo name, used to build a github api url
151    pub fn repo_name(&mut self, name: impl Into<String>) -> &mut Self {
152        self.repo_name = Some(name.into());
153        self
154    }
155
156    /// Set the optional arch `target` name, used to filter available releases
157    pub fn filter_target(&mut self, target: impl Into<String>) -> &mut Self {
158        self.target = Some(target.into());
159        self
160    }
161
162    /// Set the optional github url, e.g. for a github enterprise installation.
163    /// The url should provide the path to your API endpoint and end without a trailing slash,
164    /// for example `https://api.github.com` or `https://github.mycorp.com/api/v3`
165    ///
166    /// **Semantic note:** this setter takes the full API base URL (ending at the version prefix,
167    /// e.g. `.../api/v3`). The gitea and gitlab backends instead accept an instance host and
168    /// append the API path internally; the s3 backend uses an `endpoint` setter. The difference
169    /// is intentional: GitHub enterprise instances expose configurable API prefixes, whereas the
170    /// other backends have fixed API paths relative to their host.
171    pub fn api_base_url(&mut self, url: impl Into<String>) -> &mut Self {
172        self.custom_url = Some(url.into());
173        self
174    }
175
176    /// Set the authorization token, used in requests to the github api url
177    ///
178    /// This is to support private repos where you need a GitHub auth token.
179    /// **Make sure not to bake the token into your app**; it is recommended
180    /// you obtain it via another mechanism, such as environment variables
181    /// or prompting the user for input
182    ///
183    /// A token also raises GitHub's API rate limit from 60 to 5000 requests/hour (no scopes are
184    /// needed for a public repo); see the crate-level "GitHub rate limits" section.
185    ///
186    /// The token can also be taken from the environment with
187    /// [`auth_token_from_env`](Self::auth_token_from_env). This setter always wins over that one,
188    /// in either call order.
189    pub fn auth_token(&mut self, auth_token: impl Into<String>) -> &mut Self {
190        crate::backends::common::set_explicit_auth_token(
191            &mut self.auth_token,
192            &mut self.auth_token_from_env,
193            auth_token,
194        );
195        self
196    }
197
198    impl_auth_token_from_env!(
199        token: auth_token,
200        env_sourced: auth_token_from_env,
201        // `gh help environment` documents "GH_TOKEN, GITHUB_TOKEN (in order of precedence)", so
202        // GH_TOKEN wins: inside GitHub Actions GITHUB_TOKEN is auto-populated, whereas a user who
203        // exports GH_TOKEN did so deliberately.
204        vars: ["GH_TOKEN", "GITHUB_TOKEN"],
205        rationale: "The main reason to reach for this is GitHub's unauthenticated budget of 60 \
206                    requests per hour, which is counted **per source IP**: behind a NAT'd corporate \
207                    network it is shared with everyone else on that IP and can be exhausted by \
208                    other people entirely, surfacing as \
209                    [`RateLimited`](crate::errors::Error::RateLimited). A token moves the \
210                    count to its own 5000/hour budget; see the crate-level \"GitHub rate limits\" \
211                    section.\n\nThe variables match the `gh` CLI's precedence. Note that `gh` also \
212                    reads `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` for a GitHub Enterprise \
213                    host; this crate does not, so an enterprise `api_base_url` still needs one of \
214                    the variables above (or an explicit `auth_token(..)`).",
215    );
216
217    request_config_setters!(request);
218
219    /// Verify builder args, returning a `ReleaseList`
220    pub fn build(&self) -> Result<ReleaseList> {
221        // Thread the auth token + github's `token` scheme into the request so the shared
222        // `apply_auth` applies it on the listing path (honoring a user override).
223        let mut request = self.request.clone();
224        request.auth_scheme = crate::backends::common::AuthScheme::Token;
225        request.auth_token = self.auth_token.clone();
226        request.auth_base_host = crate::backends::common::host_of(
227            self.custom_url
228                .as_deref()
229                .unwrap_or("https://api.github.com"),
230        );
231        request.build_client();
232        request.check()?;
233        // An env-sourced token is bound to whatever host was configured, which the request-time host
234        // gate cannot flag (the configured host *is* `auth_base_host`); warn when that is not
235        // github's canonical API host (or an acknowledged `allow_auth_host` entry). github always has
236        // a canonical host, so the token is still sent either way (DECIDED, A1) -- checked after
237        // `request.check()?` so a builder that is about to fail validation does not also log.
238        crate::backends::common::env_token_host_decision(
239            self.auth_token_from_env,
240            request.auth_base_host.as_deref(),
241            &request.auth_hosts,
242            Some(CANONICAL_AUTH_HOST),
243        );
244        Ok(ReleaseList {
245            repo_owner: if let Some(ref owner) = self.repo_owner {
246                owner.to_owned()
247            } else {
248                return Err(Error::MissingField {
249                    field: "repo_owner",
250                });
251            },
252            repo_name: if let Some(ref name) = self.repo_name {
253                name.to_owned()
254            } else {
255                return Err(Error::MissingField { field: "repo_name" });
256            },
257            target: self.target.clone(),
258            custom_url: self.custom_url.clone(),
259            request,
260        })
261    }
262}
263
264/// `ReleaseList` provides a builder api for querying a GitHub repo,
265/// returning a `Vec` of available `Release`s
266#[derive(Clone, Debug)]
267pub struct ReleaseList {
268    repo_owner: String,
269    repo_name: String,
270    target: Option<String>,
271    custom_url: Option<String>,
272    request: RequestConfig,
273}
274impl ReleaseList {
275    /// Initialize a ReleaseListBuilder
276    pub fn configure() -> ReleaseListBuilder {
277        ReleaseListBuilder {
278            repo_owner: None,
279            repo_name: None,
280            target: None,
281            auth_token: None,
282            auth_token_from_env: false,
283            custom_url: None,
284            request: RequestConfig::default(),
285        }
286    }
287
288    /// Retrieve the available `Release`s as a [`Releases`].
289    ///
290    /// If a `filter_target` is set, only releases carrying an asset whose name contains it are
291    /// returned. The result carries no current version (it is a bare listing), so
292    /// [`Releases::current_version`] is `None`; use [`Releases::into_vec`] to recover the raw
293    /// `Vec<Release>`.
294    pub fn fetch(&self) -> Result<Releases> {
295        let api_url = format!(
296            "{}/repos/{}/{}/releases",
297            self.custom_url
298                .as_deref()
299                .unwrap_or("https://api.github.com"),
300            urlencoding::encode(&self.repo_owner),
301            urlencoding::encode(&self.repo_name)
302        );
303        // An unfiltered listing must walk ALL pages: `stop_at = None`.
304        let releases = run_paginated(releases_plan(&api_url, None, None)?, &self.request)?;
305        let releases = match self.target {
306            None => releases,
307            Some(ref target) => releases
308                .into_iter()
309                .filter(|r| r.has_target_asset(target))
310                .collect::<Vec<_>>(),
311        };
312        Ok(Releases::from_listing(releases))
313    }
314
315    /// Async sibling of [`fetch`](Self::fetch).
316    #[cfg(feature = "async")]
317    pub async fn fetch_async(&self) -> Result<Releases> {
318        let api_url = format!(
319            "{}/repos/{}/{}/releases",
320            self.custom_url
321                .as_deref()
322                .unwrap_or("https://api.github.com"),
323            urlencoding::encode(&self.repo_owner),
324            urlencoding::encode(&self.repo_name)
325        );
326        // An unfiltered listing must walk ALL pages: `stop_at = None`.
327        let releases = crate::backends::run_paginated_async(
328            releases_plan(&api_url, None, None)?,
329            &self.request,
330        )
331        .await?;
332        let releases = match self.target {
333            None => releases,
334            Some(ref target) => releases
335                .into_iter()
336                .filter(|r| r.has_target_asset(target))
337                .collect::<Vec<_>>(),
338        };
339        Ok(Releases::from_listing(releases))
340    }
341}
342
343/// `github::Update` builder
344///
345/// Configure download and installation from
346/// `https://api.github.com/repos/<repo_owner>/<repo_name>/releases/latest`
347#[derive(Clone, Debug, Default)]
348#[must_use]
349pub struct UpdateBuilder {
350    repo_owner: Option<String>,
351    repo_name: Option<String>,
352    custom_url: Option<String>,
353    common: CommonBuilderConfig,
354}
355
356impl UpdateBuilder {
357    /// Initialize a new builder
358    pub fn new() -> Self {
359        Default::default()
360    }
361
362    /// Required. Set the repo owner, used to build a github api url
363    pub fn repo_owner(&mut self, owner: impl Into<String>) -> &mut Self {
364        self.repo_owner = Some(owner.into());
365        self
366    }
367
368    /// Required. Set the repo name, used to build a github api url
369    pub fn repo_name(&mut self, name: impl Into<String>) -> &mut Self {
370        self.repo_name = Some(name.into());
371        self
372    }
373
374    /// Set the optional github url, e.g. for a github enterprise installation.
375    /// The url should provide the path to your API endpoint and end without a trailing slash,
376    /// for example `https://api.github.com` or `https://github.mycorp.com/api/v3`
377    pub fn api_base_url(&mut self, url: impl Into<String>) -> &mut Self {
378        self.custom_url = Some(url.into());
379        self
380    }
381
382    /// Set the tag prefix used to derive a release version from its tag. Defaults to unset, which
383    /// trims a leading `v` (so `v1.2.3` and `1.2.3` both yield `1.2.3`). Set it to, e.g., `myapp-`
384    /// for a monorepo whose tags look like `myapp-1.2.3` (or `myapp-v1.2.3`); tags without the
385    /// prefix are then skipped from the listing rather than mis-parsed.
386    pub fn tag_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
387        self.common.tag_prefix = Some(prefix.into());
388        self
389    }
390
391    impl_common_builder_setters!(
392        // `gh help environment` documents "GH_TOKEN, GITHUB_TOKEN (in order of precedence)", so
393        // GH_TOKEN wins: inside GitHub Actions GITHUB_TOKEN is auto-populated, whereas a user who
394        // exports GH_TOKEN did so deliberately.
395        auth_env: ["GH_TOKEN", "GITHUB_TOKEN"],
396        rationale: "The main reason to reach for this is GitHub's unauthenticated budget of 60 \
397                    requests per hour, which is counted **per source IP**: behind a NAT'd corporate \
398                    network it is shared with everyone else on that IP and can be exhausted by \
399                    other people entirely, surfacing as \
400                    [`RateLimited`](crate::errors::Error::RateLimited). A token moves the \
401                    count to its own 5000/hour budget; see the crate-level \"GitHub rate limits\" \
402                    section.\n\nThe variables match the `gh` CLI's precedence. Note that `gh` also \
403                    reads `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` for a GitHub Enterprise \
404                    host; this crate does not, so an enterprise `api_base_url` still needs one of \
405                    the variables above (or an explicit `auth_token(..)`).",
406    );
407
408    fn build_update(&self) -> Result<Update> {
409        Ok(Update {
410            repo_owner: if let Some(ref owner) = self.repo_owner {
411                owner.to_owned()
412            } else {
413                return Err(Error::MissingField {
414                    field: "repo_owner",
415                });
416            },
417            repo_name: if let Some(ref name) = self.repo_name {
418                name.to_owned()
419            } else {
420                return Err(Error::MissingField { field: "repo_name" });
421            },
422            custom_url: self.custom_url.clone(),
423            common: {
424                let mut resolved = self.common.build()?;
425                // Github authenticates with `token <token>`; set the scheme explicitly rather than
426                // relying on `AuthScheme::default()` (gitlab overrides to `Bearer` the same way).
427                resolved.request.auth_scheme = crate::backends::common::AuthScheme::Token;
428                // The github API host (asset download URLs are on the same host) receives the token;
429                // a server-supplied URL on any other host does not.
430                resolved.request.auth_base_host = crate::backends::common::host_of(
431                    self.custom_url
432                        .as_deref()
433                        .unwrap_or("https://api.github.com"),
434                );
435                // An env-sourced token is bound to whatever host was configured, which the
436                // request-time host gate cannot flag (the configured host *is* `auth_base_host`);
437                // warn when that is not github's canonical API host (or an acknowledged
438                // `allow_auth_host` entry). github always has a canonical host, so the token is
439                // still sent either way (DECIDED, A1).
440                crate::backends::common::env_token_host_decision(
441                    self.common.auth_token_from_env,
442                    resolved.request.auth_base_host.as_deref(),
443                    &resolved.request.auth_hosts,
444                    Some(CANONICAL_AUTH_HOST),
445                );
446                resolved
447            },
448        })
449    }
450
451    /// Confirm config and create a ready-to-use `Update`.
452    ///
453    /// Returns the concrete [`Update`], which is `Send` (so it can move to a worker thread) and
454    /// exposes the update verbs (`update`, `update_extended`, `get_latest_release`, ...) as inherent
455    /// methods.
456    ///
457    /// * Errors:
458    ///     * Invalid `Update` configuration
459    pub fn build(&self) -> Result<Update> {
460        self.build_update()
461    }
462
463    /// Confirm config and create a ready-to-use [`AsyncUpdate`] for the async API (`update_async`).
464    ///
465    /// Unlike [`build`](Self::build) this returns the distinct [`AsyncUpdate`] newtype, which exposes
466    /// only the inherent `*_async` verbs, so a stray blocking `.update()` on an async-built updater
467    /// is a compile error rather than a silent block of the executor.
468    #[cfg(feature = "async")]
469    pub fn build_async(&self) -> Result<AsyncUpdate> {
470        Ok(AsyncUpdate(self.build_update()?))
471    }
472}
473
474/// Updates to a specified or latest release distributed via GitHub
475#[derive(Debug)]
476#[non_exhaustive]
477pub struct Update {
478    repo_owner: String,
479    repo_name: String,
480    custom_url: Option<String>,
481    common: CommonConfig,
482}
483impl Update {
484    /// Initialize a new `Update` builder
485    pub fn configure() -> UpdateBuilder {
486        UpdateBuilder::new()
487    }
488
489    /// API base URL (the custom URL for enterprise installs, or the public github API). Shared by
490    /// the sync and async fetch paths so they can't drift.
491    fn api_base(&self) -> &str {
492        self.custom_url
493            .as_deref()
494            .unwrap_or("https://api.github.com")
495    }
496}
497
498impl crate::update::sealed::Sealed for Update {}
499
500impl Update {
501    /// The `/repos/{owner}/{name}/releases` listing URL.
502    fn releases_url(&self) -> String {
503        format!(
504            "{}/repos/{}/{}/releases",
505            self.api_base(),
506            urlencoding::encode(&self.repo_owner),
507            urlencoding::encode(&self.repo_name)
508        )
509    }
510
511    /// The `/repos/{owner}/{name}/releases/latest` single-newest-release URL.
512    fn latest_url(&self) -> String {
513        format!(
514            "{}/repos/{}/{}/releases/latest",
515            self.api_base(),
516            urlencoding::encode(&self.repo_owner),
517            urlencoding::encode(&self.repo_name)
518        )
519    }
520
521    /// The `/repos/{owner}/{name}/releases/tags/{ver}` single-release-by-tag URL.
522    fn tag_url(&self, ver: &str) -> String {
523        format!(
524            "{}/repos/{}/{}/releases/tags/{}",
525            self.api_base(),
526            urlencoding::encode(&self.repo_owner),
527            urlencoding::encode(&self.repo_name),
528            urlencoding::encode(ver)
529        )
530    }
531}
532
533impl ReleaseUpdate for Update {
534    fn get_latest_release(&self) -> Result<Releases> {
535        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
536        let releases = run_paginated(
537            single_plan(self.latest_url(), self.common.tag_prefix.as_deref())?,
538            &self.common.request,
539        )?;
540        let release = releases
541            .into_iter()
542            .next()
543            .ok_or_else(|| Error::NoReleaseFound { target: None })?;
544        Ok(Releases::new(vec![release], current_version))
545    }
546
547    fn get_newer_releases(&self) -> Result<Releases> {
548        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
549        let releases = run_paginated(
550            releases_plan(
551                &self.releases_url(),
552                Some(&current_version),
553                self.common.tag_prefix.as_deref(),
554            )?,
555            &self.common.request,
556        )?;
557        Ok(Releases::new(releases, current_version))
558    }
559
560    fn get_release_version(&self, ver: &str) -> Result<Release> {
561        let releases = run_paginated(
562            single_plan(self.tag_url(ver), self.common.tag_prefix.as_deref())?,
563            &self.common.request,
564        )?;
565        releases
566            .into_iter()
567            .next()
568            .ok_or_else(|| Error::NoReleaseFound { target: None })
569    }
570}
571
572impl_sync_update_verbs!(Update);
573
574/// Async-only updater returned by [`UpdateBuilder::build_async`].
575///
576/// A newtype over the blocking [`Update`] that exposes **only** the inherent `*_async` verbs. Using
577/// it (instead of returning `Update` from `build_async`) makes a blocking call on an async-built
578/// updater — e.g. `build_async()?.update()` — a compile error, so the async executor cannot be
579/// silently blocked.
580#[cfg(feature = "async")]
581#[derive(Debug)]
582pub struct AsyncUpdate(Update);
583
584#[cfg(feature = "async")]
585impl_async_update_verbs!(AsyncUpdate);
586
587impl_update_config_accessors!(Update, {
588    fn api_headers(&self, _auth_token: Option<&str>) -> Result<HeaderMap> {
589        api_headers()
590    }
591});
592
593/// Transport-free plan to fetch the paginated `releases` array, parsing each page with
594/// the private `ReleaseDto` and following GitHub's `Link: rel="next"` pagination.
595///
596/// `stop_at` filters per-item: when `Some(current_version)` each release that is not strictly
597/// newer than it is omitted from the collected list, but pagination continues to subsequent pages
598/// regardless (a backport release — older semver, newer creation date — must not halt the walk
599/// and cause a genuinely newer release on a later page to be missed). When `None` the listing is
600/// unfiltered and every page is walked (used by `ReleaseList`).
601fn releases_plan(
602    base_url: &str,
603    stop_at: Option<&str>,
604    tag_prefix: Option<&str>,
605) -> Result<PageRequest<Release>> {
606    let headers = api_headers()?;
607    let stop_at = stop_at.map(str::to_owned);
608    Ok(release_array_page(
609        first_page_url(base_url),
610        headers,
611        stop_at,
612        tag_prefix.map(str::to_owned),
613    ))
614}
615
616/// Build one `releases`-array [`PageRequest`], capturing what it needs to build the next page.
617fn release_array_page(
618    url: String,
619    headers: HeaderMap,
620    stop_at: Option<String>,
621    tag_prefix: Option<String>,
622) -> PageRequest<Release> {
623    PageRequest {
624        url,
625        headers,
626        parse: Box::new(move |body, resp_headers| {
627            // Deserialize the page directly into the private DTO vec (no intermediate
628            // `serde_json::Value` tree), then convert each into a public `Release`.
629            let dtos: Vec<ReleaseDto> =
630                serde_json::from_slice(body).map_err(|e| Error::InvalidResponse {
631                    source: Box::new(e),
632                })?;
633            let mut items = Vec::new();
634            for dto in dtos {
635                let release = match dto.into_release(tag_prefix.as_deref()) {
636                    Ok(release) => release,
637                    // A non-semver tag (`nightly`, `latest`, a date tag) is not a release the
638                    // updater can compare; skip it rather than failing the whole listing, so a
639                    // repository mixing rolling tags with semver releases stays updatable.
640                    Err(e @ Error::SemVer(_)) => {
641                        log::debug!("self_update: skipping listed release: {e}");
642                        continue;
643                    }
644                    Err(e) => return Err(e),
645                };
646                // Skip releases not strictly newer than the current version, but do NOT stop
647                // pagination. A backport release (older semver, newer creation date) must not
648                // halt the walk; a genuinely newer release on a later page must still be found.
649                if let Some(ref current) = stop_at
650                    && !bump_is_greater(current, release.version()).unwrap_or(false)
651                {
652                    continue;
653                }
654                items.push(release);
655            }
656            let next = next_link(resp_headers)
657                .map(|next_url| -> Result<PageRequest<Release>> {
658                    Ok(release_array_page(
659                        next_url,
660                        api_headers()?,
661                        stop_at.clone(),
662                        tag_prefix.clone(),
663                    ))
664                })
665                .transpose()?;
666            Ok(Page {
667                items,
668                next,
669                stop: false,
670            })
671        }),
672    }
673}
674
675/// Transport-free plan to fetch a single release *object* (the `/releases/latest` and
676/// `/releases/tags/{ver}` endpoints), parsed via the private `ReleaseDto` into a one-item page.
677fn single_plan(url: String, tag_prefix: Option<&str>) -> Result<PageRequest<Release>> {
678    let headers = api_headers()?;
679    let tag_prefix = tag_prefix.map(str::to_owned);
680    Ok(PageRequest {
681        url,
682        headers,
683        parse: Box::new(move |body, _resp_headers| {
684            // The single-release endpoints return a bare release object; deserialize it directly
685            // into the DTO and convert. An unparseable body is `InvalidResponse`, matching the
686            // paginated listing parser.
687            let dto: ReleaseDto =
688                serde_json::from_slice(body).map_err(crate::errors::Error::invalid_response)?;
689            Ok(Page::last(vec![dto.into_release(tag_prefix.as_deref())?]))
690        }),
691    })
692}
693
694#[cfg(feature = "async")]
695impl crate::update::AsyncReleaseUpdate for Update {
696    async fn get_latest_release_async(&self) -> Result<Releases> {
697        use crate::backends::run_paginated_async;
698        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
699        let releases = run_paginated_async(
700            single_plan(self.latest_url(), self.common.tag_prefix.as_deref())?,
701            &self.common.request,
702        )
703        .await?;
704        let release = releases
705            .into_iter()
706            .next()
707            .ok_or_else(|| Error::NoReleaseFound { target: None })?;
708        Ok(Releases::new(vec![release], current_version))
709    }
710
711    async fn get_newer_releases_async(&self) -> Result<Releases> {
712        use crate::backends::run_paginated_async;
713        let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
714        let releases = run_paginated_async(
715            releases_plan(
716                &self.releases_url(),
717                Some(&current_version),
718                self.common.tag_prefix.as_deref(),
719            )?,
720            &self.common.request,
721        )
722        .await?;
723        Ok(Releases::new(releases, current_version))
724    }
725
726    async fn get_release_version_async(&self, ver: &str) -> Result<Release> {
727        use crate::backends::run_paginated_async;
728        let releases = run_paginated_async(
729            single_plan(self.tag_url(ver), self.common.tag_prefix.as_deref())?,
730            &self.common.request,
731        )
732        .await?;
733        releases
734            .into_iter()
735            .next()
736            .ok_or_else(|| Error::NoReleaseFound { target: None })
737    }
738}
739
740/// Build github's base request headers (the shared `self_update/<version>` User-Agent; github
741/// rejects requests with no User-Agent). The Authorization header is no longer set here: the auth
742/// scheme/token is applied centrally by the shared
743/// [`apply_auth`](crate::backends::common::RequestConfig::apply_auth) on both the listing and
744/// download paths, which also honors a user `request_header(AUTHORIZATION, ..)` override.
745fn api_headers() -> Result<header::HeaderMap> {
746    let mut headers = header::HeaderMap::new();
747    headers.insert(
748        header::USER_AGENT,
749        crate::DEFAULT_USER_AGENT
750            .parse()
751            .expect("github invalid user-agent"),
752    );
753    Ok(headers)
754}
755
756#[cfg(test)]
757mod tests {
758    use std::io::{Read, Write};
759    use std::net::TcpListener;
760
761    // The crate-private internal accessors (`request_timeout`, `verify_callback`, `asset_matcher`,
762    // ...) now live on `UpdateInternals`; bring it into scope so `upd.request_timeout()` etc.
763    // resolve.
764    #[allow(unused_imports)]
765    use crate::update::UpdateInternals;
766
767    // The public config accessors (`api_headers`, `no_confirm`, `show_output`, ...) live on the
768    // sealed `UpdateConfig` trait; bring it into scope so they resolve on the concrete `Update`.
769    use crate::update::UpdateConfig;
770
771    // --- AUTH-1: the environment-sourced auth token -------------------------------------------
772
773    // AUTH-1: `auth_token_from_env()` exists on both github builders and is chainable. This is
774    // effectively a "the method exists and does not panic" check, not a behavioral one: it reads the
775    // REAL process environment, so it means something different on a clean machine (nothing set,
776    // exercises the no-op path) than on a dev box exporting `GH_TOKEN`/`GITHUB_TOKEN` (exercises the
777    // pickup path) -- either way it only asserts `build()` stays `Ok`, which passes in both cases.
778    // The env-var precedence itself is unit-tested in `backends::common` without touching process
779    // env; the actual pickup-from-environment behavior is pinned on the wire by the per-backend
780    // integration binary `tests/auth_token_env_github.rs`, which controls the environment directly.
781    #[test]
782    fn auth_token_from_env_is_available_on_both_builders() {
783        super::Update::configure()
784            .repo_owner("o")
785            .repo_name("r")
786            .bin_name("app")
787            .current_version("0.1.0")
788            .auth_token_from_env()
789            .build()
790            .expect("an env-sourced token must leave the update builder buildable");
791        super::ReleaseList::configure()
792            .repo_owner("o")
793            .repo_name("r")
794            .auth_token_from_env()
795            .build()
796            .expect("an env-sourced token must leave the release-list builder buildable");
797    }
798
799    // The exact variable list, on both builders. `gh help environment` documents
800    // "GH_TOKEN, GITHUB_TOKEN (in order of precedence)", so `GH_TOKEN` must come FIRST -- the list
801    // used to be the reverse of the CLI it claims to match, and inside GitHub Actions (where
802    // `GITHUB_TOKEN` is auto-populated) that silently ignored a deliberately exported `GH_TOKEN`.
803    // Without this assertion nothing catches a typo, a reordering, or another backend's list
804    // arriving here by copy-paste.
805    #[test]
806    fn auth_token_env_vars_are_gh_token_then_github_token() {
807        assert_eq!(
808            super::UpdateBuilder::AUTH_TOKEN_ENV_VARS,
809            ["GH_TOKEN", "GITHUB_TOKEN"]
810        );
811        assert_eq!(
812            super::ReleaseListBuilder::AUTH_TOKEN_ENV_VARS,
813            ["GH_TOKEN", "GITHUB_TOKEN"]
814        );
815    }
816
817    // C: an explicit `auth_token(..)` always wins over the environment, in EITHER call order -- the
818    // pair is order-independent like every other setter pair. (Env-independent: it asserts the
819    // explicit value, which beats whatever the environment may or may not hold.)
820    #[test]
821    fn an_explicit_auth_token_wins_over_the_env_lookup_in_either_order() {
822        use crate::http_client::header::{AUTHORIZATION, HeaderMap};
823        let env_then_explicit = super::Update::configure()
824            .repo_owner("o")
825            .repo_name("r")
826            .bin_name("app")
827            .current_version("0.1.0")
828            .auth_token_from_env()
829            .auth_token("explicit")
830            .build()
831            .unwrap();
832        let explicit_then_env = super::Update::configure()
833            .repo_owner("o")
834            .repo_name("r")
835            .bin_name("app")
836            .current_version("0.1.0")
837            .auth_token("explicit")
838            .auth_token_from_env()
839            .build()
840            .unwrap();
841        for upd in [env_then_explicit, explicit_then_env] {
842            let mut headers = HeaderMap::new();
843            upd.request_config()
844                .apply_auth("https://api.github.com/repos/o/r/releases", &mut headers)
845                .unwrap();
846            assert_eq!(
847                headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
848                "token explicit",
849                "an explicit auth_token(..) must win in either call order"
850            );
851        }
852    }
853
854    // Same rule on the `ReleaseList` builder, read off the resolved request config.
855    #[test]
856    fn release_list_explicit_auth_token_wins_over_the_env_lookup_in_either_order() {
857        let env_then_explicit = super::ReleaseList::configure()
858            .repo_owner("o")
859            .repo_name("r")
860            .auth_token_from_env()
861            .auth_token("explicit")
862            .build()
863            .unwrap();
864        let explicit_then_env = super::ReleaseList::configure()
865            .repo_owner("o")
866            .repo_name("r")
867            .auth_token("explicit")
868            .auth_token_from_env()
869            .build()
870            .unwrap();
871        for list in [env_then_explicit, explicit_then_env] {
872            assert_eq!(list.request.auth_token.as_deref(), Some("explicit"));
873        }
874    }
875
876    // K: `has_auth_token()` answers "is a token configured?" on both builders without the
877    // application reimplementing the variable list. (The env-pickup half is covered by the
878    // single-test integration binary `tests/auth_token_env_github.rs`, which may set process env.)
879    #[test]
880    fn has_auth_token_reports_an_explicitly_set_token() {
881        let mut upd = super::Update::configure();
882        assert!(
883            !upd.has_auth_token(),
884            "a fresh builder has no token: the update runs anonymously"
885        );
886        upd.auth_token("explicit");
887        assert!(upd.has_auth_token());
888
889        let mut list = super::ReleaseList::configure();
890        assert!(!list.has_auth_token());
891        list.auth_token("explicit");
892        assert!(list.has_auth_token());
893    }
894
895    // A5: a blank explicit token (empty or all-whitespace) is not "configured" -- otherwise
896    // `auth_token(cfg.token.unwrap_or_default())` against a missing config value would report a
897    // token is set when none actually is, and `apply_auth` would go on to send a literal
898    // `Authorization: token ` header.
899    #[test]
900    fn has_auth_token_treats_a_blank_explicit_token_as_unset() {
901        let mut upd = super::Update::configure();
902        upd.auth_token("");
903        assert!(
904            !upd.has_auth_token(),
905            "an empty token must not count as configured"
906        );
907        upd.auth_token("   ");
908        assert!(
909            !upd.has_auth_token(),
910            "an all-whitespace token must not count as configured"
911        );
912
913        let mut list = super::ReleaseList::configure();
914        list.auth_token("");
915        assert!(!list.has_auth_token());
916        list.auth_token("   ");
917        assert!(!list.has_auth_token());
918    }
919
920    // A1 (DECIDED): github has a canonical host, so an env-sourced token bound to an unacknowledged
921    // custom host is still SENT (only the warning differs from the canonical-host case; gitea is the
922    // backend that withholds instead -- see `backends::gitea`'s equivalent test). Threading
923    // `auth_hosts` through the host-decision call at `build()` must not regress this.
924    #[test]
925    fn release_list_still_sends_an_env_sourced_token_off_the_canonical_host() {
926        let mut list = super::ReleaseList::configure();
927        list.repo_owner("o")
928            .repo_name("r")
929            .api_base_url("https://github.mycorp.com/api/v3");
930        list.auth_token = Some("ambient".to_string());
931        list.auth_token_from_env = true;
932        let built = list
933            .build()
934            .expect("an unacknowledged host must not fail build()");
935        assert_eq!(
936            built.request.auth_token.as_deref(),
937            Some("ambient"),
938            "github must still send an env-sourced token off its canonical host"
939        );
940    }
941
942    // A: `Debug` on either builder must never print the token. Both derive/embed a plaintext
943    // `Option<String>`, so a plain `log::debug!("{builder:?}")` used to dump a live credential --
944    // and `auth_token_from_env()` is exactly what puts an ambient CI credential there.
945    #[test]
946    fn builder_debug_redacts_the_auth_token() {
947        let mut upd = super::Update::configure();
948        upd.repo_owner("owner-o")
949            .repo_name("r")
950            .bin_name("app")
951            .current_version("0.1.0")
952            .auth_token("ghp_supersecret");
953        let rendered = format!("{upd:?}");
954        assert!(
955            !rendered.contains("ghp_supersecret"),
956            "the UpdateBuilder must not print the token, got: {rendered}"
957        );
958        assert!(rendered.contains("<token>"), "got: {rendered}");
959        assert!(
960            rendered.contains("owner-o"),
961            "other fields must survive the hand-written Debug, got: {rendered}"
962        );
963
964        let mut list = super::ReleaseList::configure();
965        list.repo_owner("owner-o")
966            .repo_name("r")
967            .auth_token("ghp_supersecret");
968        let rendered = format!("{list:?}");
969        assert!(
970            !rendered.contains("ghp_supersecret"),
971            "the ReleaseListBuilder must not print the token, got: {rendered}"
972        );
973        assert!(rendered.contains("<token>"), "got: {rendered}");
974        assert!(rendered.contains("owner-o"), "got: {rendered}");
975    }
976
977    // A hand-written `Debug` can leak a secret, and it can also silently *lose* a field -- a
978    // regression the "does not contain the secret" assertion above would happily pass. Pin the
979    // full field list of `ReleaseListBuilder`'s (this is the debug dump an application prints when
980    // an update misbehaves; a dropped `custom_url` or `auth_token_from_env` makes it useless).
981    #[test]
982    fn release_list_builder_debug_renders_every_field() {
983        let rendered = format!("{:?}", super::ReleaseList::configure());
984        for field in [
985            "repo_owner",
986            "repo_name",
987            "target",
988            "auth_token",
989            "auth_token_from_env",
990            "custom_url",
991            "request",
992        ] {
993            assert!(
994                rendered.contains(&format!("{field}:")),
995                "the hand-written Debug dropped `{field}`, got: {rendered}"
996            );
997        }
998    }
999
1000    // A: the redaction must hold on every public type reachable from a configured builder, not just
1001    // on the builders themselves. `Update` / `AsyncUpdate` / `ReleaseList` are what an application
1002    // actually keeps around (and dumps into a bug report); they carry the resolved token inside
1003    // `RequestConfig`, which is a different `Debug` impl from the builders' -- so it needs its own
1004    // assertion rather than an assumption.
1005    #[test]
1006    fn built_types_debug_redacts_the_auth_token() {
1007        let upd = super::Update::configure()
1008            .repo_owner("owner-o")
1009            .repo_name("r")
1010            .bin_name("app")
1011            .current_version("0.1.0")
1012            .auth_token("ghp_supersecret")
1013            .build()
1014            .unwrap();
1015        let rendered = format!("{upd:?}");
1016        assert!(
1017            !rendered.contains("ghp_supersecret"),
1018            "the built Update must not print the token, got: {rendered}"
1019        );
1020        assert!(
1021            rendered.contains("<token>"),
1022            "the token must still render as the redaction marker, got: {rendered}"
1023        );
1024        assert!(
1025            rendered.contains("owner-o"),
1026            "non-secret fields must survive, got: {rendered}"
1027        );
1028
1029        let list = super::ReleaseList::configure()
1030            .repo_owner("owner-o")
1031            .repo_name("r")
1032            .auth_token("ghp_supersecret")
1033            .build()
1034            .unwrap();
1035        let rendered = format!("{list:?}");
1036        assert!(
1037            !rendered.contains("ghp_supersecret"),
1038            "the built ReleaseList must not print the token, got: {rendered}"
1039        );
1040        assert!(rendered.contains("<token>"), "got: {rendered}");
1041        assert!(rendered.contains("owner-o"), "got: {rendered}");
1042
1043        // The async newtype wraps the same `Update`, but it is a separate public type with its own
1044        // derived `Debug`.
1045        #[cfg(feature = "async")]
1046        {
1047            let upd = super::Update::configure()
1048                .repo_owner("owner-o")
1049                .repo_name("r")
1050                .bin_name("app")
1051                .current_version("0.1.0")
1052                .auth_token("ghp_supersecret")
1053                .build_async()
1054                .unwrap();
1055            let rendered = format!("{upd:?}");
1056            assert!(
1057                !rendered.contains("ghp_supersecret"),
1058                "the built AsyncUpdate must not print the token, got: {rendered}"
1059            );
1060            assert!(rendered.contains("<token>"), "got: {rendered}");
1061        }
1062    }
1063
1064    // The single-release endpoints (`/releases/latest`, `/releases/tags/{ver}`) surface an
1065    // unparseable body as `InvalidResponse`, matching the paginated listing parser (previously
1066    // they mapped to `Error::Json`, forcing callers to match two variants for one failure).
1067    #[test]
1068    fn single_plan_parse_failure_is_invalid_response() {
1069        let req =
1070            super::single_plan("https://example.test/releases/latest".to_string(), None).unwrap();
1071        let res = (req.parse)(b"not-json", &crate::http_client::HeaderMap::new());
1072        assert!(
1073            matches!(res, Err(crate::errors::Error::InvalidResponse { .. })),
1074            "a malformed single-release body must map to InvalidResponse"
1075        );
1076    }
1077
1078    // A rolling non-semver tag (`nightly`) in the listing must be skipped, not fail the whole
1079    // fetch: repositories commonly mix rolling tags with semver releases. Pre-release/build
1080    // suffixes are valid semver and must NOT be skipped. A capital-`V` prefix is not trimmed
1081    // (only lowercase `v` is), so a `V`-tagged release is skipped like any other unparseable
1082    // tag; this documents the boundary of the trim.
1083    // A configured `tag_prefix` derives the version from monorepo-style tags (`myapp-1.2.3`,
1084    // `myapp-v1.3.0`); tags without the prefix are skipped rather than mis-parsed.
1085    #[test]
1086    fn listing_with_tag_prefix_parses_prefixed_tags_and_skips_others() {
1087        let req = super::release_array_page(
1088            "https://example.test/releases".to_string(),
1089            crate::http_client::HeaderMap::new(),
1090            None,
1091            Some("myapp-".to_string()),
1092        );
1093        let body = releases_array_json(&["myapp-1.2.3", "otherapp-2.0.0", "myapp-v1.3.0", "1.0.0"]);
1094        let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap();
1095        let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect();
1096        assert_eq!(
1097            versions,
1098            vec!["1.2.3", "1.3.0"],
1099            "only `myapp-`-prefixed tags are parsed (with an optional inner `v`); the rest are skipped"
1100        );
1101    }
1102
1103    #[test]
1104    fn listing_skips_non_semver_tags() {
1105        let req = super::release_array_page(
1106            "https://example.test/releases".to_string(),
1107            crate::http_client::HeaderMap::new(),
1108            None,
1109            None,
1110        );
1111        let body = releases_array_json(&[
1112            "nightly",
1113            "v2.0.0-rc.1+build",
1114            "v1.2.3",
1115            "2024-06-01",
1116            "V1.1.0",
1117            "v1.0.0",
1118        ]);
1119        let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap();
1120        let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect();
1121        assert_eq!(
1122            versions,
1123            vec!["2.0.0-rc.1+build", "1.2.3", "1.0.0"],
1124            "non-semver (incl. capital-V) tags are skipped; semver incl. pre-release survives"
1125        );
1126    }
1127
1128    // End-to-end over the loopback stub: a first page consisting entirely of non-semver tags
1129    // (with a Link to page 2) must not stop the walk; page 2's release is still collected.
1130    #[test]
1131    fn fetch_continues_past_an_all_non_semver_page() {
1132        let (base, captured) = stub_capturing(|base| {
1133            vec![
1134                Resp {
1135                    status: "200 OK",
1136                    link: Some(format!("{base}/repos/o/r/releases?page=2")),
1137                    body: releases_array_json(&["nightly", "latest"]),
1138                },
1139                Resp {
1140                    status: "200 OK",
1141                    link: None,
1142                    body: releases_array_json(&["v3.0.0"]),
1143                },
1144            ]
1145        });
1146        let releases = fetch_all_releases(
1147            &format!("{base}/repos/o/r/releases"),
1148            &crate::backends::common::RequestConfig::default(),
1149        )
1150        .unwrap();
1151        let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1152        assert_eq!(versions, vec!["3.0.0"]);
1153        assert_eq!(
1154            captured.lock().unwrap().len(),
1155            2,
1156            "an all-skipped page 1 must not stop pagination; page 2 must be requested"
1157        );
1158    }
1159
1160    // The async driver shares the same parse closures; pin that the skip behaves identically
1161    // through `run_paginated_async`.
1162    #[cfg(feature = "async")]
1163    #[tokio::test]
1164    async fn fetch_async_skips_non_semver_tags() {
1165        let base = stub(|_| {
1166            vec![Resp {
1167                status: "200 OK",
1168                link: None,
1169                body: releases_array_json(&["nightly", "v1.2.3"]),
1170            }]
1171        });
1172        let releases = fetch_all_releases_async(
1173            &format!("{base}/repos/o/r/releases"),
1174            &crate::backends::common::RequestConfig::default(),
1175        )
1176        .await
1177        .unwrap();
1178        let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1179        assert_eq!(versions, vec!["1.2.3"]);
1180    }
1181
1182    // The same skip applies on the filtered (`stop_at`) walk used by `get_newer_releases`,
1183    // which feeds `update()`: a rolling tag must not abort an update check.
1184    #[test]
1185    fn filtered_listing_skips_non_semver_tags() {
1186        let req = super::release_array_page(
1187            "https://example.test/releases".to_string(),
1188            crate::http_client::HeaderMap::new(),
1189            Some("1.0.0".to_string()),
1190            None,
1191        );
1192        let body = releases_array_json(&["nightly", "v1.2.3", "v0.9.0"]);
1193        let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap();
1194        let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect();
1195        assert_eq!(versions, vec!["1.2.3"]);
1196    }
1197
1198    // The single-release endpoints cannot skip: a pinned non-semver tag errors, and the error
1199    // must name the offending tag rather than surfacing a bare semver parse failure.
1200    #[test]
1201    fn single_plan_non_semver_tag_errors_naming_the_tag() {
1202        let req = super::single_plan(
1203            "https://example.test/releases/tags/nightly".to_string(),
1204            None,
1205        )
1206        .unwrap();
1207        let res = (req.parse)(
1208            release_obj_json("nightly").as_bytes(),
1209            &crate::http_client::HeaderMap::new(),
1210        );
1211        match res {
1212            Err(crate::errors::Error::SemVer(e)) => {
1213                assert!(
1214                    e.to_string().contains("nightly"),
1215                    "the error must name the offending tag, got: {e}"
1216                );
1217            }
1218            Err(other) => panic!("expected Error::SemVer, got {other:?}"),
1219            Ok(_) => panic!("a non-semver pinned tag must error"),
1220        }
1221    }
1222
1223    /// Test wrapper: drive the sans-io `releases_plan` through the sync `run_paginated` driver.
1224    /// `stop_at = None` => walk all pages (the unfiltered listing behavior).
1225    fn fetch_all_releases(
1226        base_url: &str,
1227        req: &crate::backends::common::RequestConfig,
1228    ) -> crate::errors::Result<Vec<super::Release>> {
1229        crate::backends::run_paginated(super::releases_plan(base_url, None, None)?, req)
1230    }
1231
1232    /// Async test wrapper over `releases_plan` + the async driver. `stop_at = None`.
1233    #[cfg(feature = "async")]
1234    async fn fetch_all_releases_async(
1235        base_url: &str,
1236        req: &crate::backends::common::RequestConfig,
1237    ) -> crate::errors::Result<Vec<super::Release>> {
1238        crate::backends::run_paginated_async(super::releases_plan(base_url, None, None)?, req).await
1239    }
1240
1241    struct Resp {
1242        status: &'static str,
1243        link: Option<String>,
1244        body: String,
1245    }
1246
1247    /// Bind a loopback listener and serve `make(base_url)`'s responses in order, one per
1248    /// incoming connection, on a background thread. Returns the server's base URL
1249    /// (`http://127.0.0.1:<port>`). No external network is used.
1250    fn stub(make: impl FnOnce(&str) -> Vec<Resp>) -> String {
1251        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1252        let base = format!("http://{}", listener.local_addr().unwrap());
1253        let responses = make(&base);
1254        std::thread::spawn(move || {
1255            for r in responses {
1256                let (mut stream, _) = match listener.accept() {
1257                    Ok(c) => c,
1258                    Err(_) => return,
1259                };
1260                let mut buf = [0u8; 4096];
1261                let _ = stream.read(&mut buf); // drain the request line/headers
1262                let mut out = format!(
1263                    "HTTP/1.1 {}\r\nContent-Type: application/json\r\n",
1264                    r.status
1265                );
1266                if let Some(link) = r.link {
1267                    out.push_str(&format!("Link: <{link}>; rel=\"next\"\r\n"));
1268                }
1269                out.push_str(&format!(
1270                    "Content-Length: {}\r\nConnection: close\r\n\r\n{}",
1271                    r.body.len(),
1272                    r.body
1273                ));
1274                let _ = stream.write_all(out.as_bytes());
1275                let _ = stream.flush();
1276            }
1277        });
1278        base
1279    }
1280
1281    fn release_json(tag: &str) -> String {
1282        format!(
1283            r#"[{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[]}}]"#
1284        )
1285    }
1286
1287    fn release_obj_json(tag: &str) -> String {
1288        format!(
1289            r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[]}}"#
1290        )
1291    }
1292
1293    /// A github-format releases JSON array with one entry per tag (newest-first as listed).
1294    fn releases_array_json(tags: &[&str]) -> String {
1295        let objs = tags
1296            .iter()
1297            .map(|tag| {
1298                format!(
1299                    r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[]}}"#
1300                )
1301            })
1302            .collect::<Vec<_>>()
1303            .join(",");
1304        format!("[{objs}]")
1305    }
1306
1307    // A release carrying `html_url` populates `Release::release_notes_url`; a release without it
1308    // leaves the URL `None`.
1309    #[test]
1310    fn release_dto_populates_release_notes_url_from_html_url() {
1311        let with_url: super::ReleaseDto = serde_json::from_str(
1312            r#"{"tag_name":"v1.2.3","created_at":"2020-01-01T00:00:00Z","name":"v1.2.3",
1313                "html_url":"https://github.com/o/r/releases/tag/v1.2.3","assets":[]}"#,
1314        )
1315        .unwrap();
1316        let release = with_url.into_release(None).unwrap();
1317        assert_eq!(
1318            release.release_notes_url(),
1319            Some("https://github.com/o/r/releases/tag/v1.2.3")
1320        );
1321
1322        let without: super::ReleaseDto = serde_json::from_str(
1323            r#"{"tag_name":"v1.2.3","created_at":"2020-01-01T00:00:00Z","name":"v1.2.3","assets":[]}"#,
1324        )
1325        .unwrap();
1326        assert_eq!(
1327            without.into_release(None).unwrap().release_notes_url(),
1328            None
1329        );
1330    }
1331
1332    // --- git release-scan early-stop (selection parity + page-2 never requested) -------
1333
1334    #[test]
1335    fn get_newer_releases_continues_past_non_newer_releases_and_fetches_page_two() {
1336        // Page 1 contains both newer (v2.0.0, v1.5.0) and non-newer (v1.0.0, v0.9.0) releases.
1337        // Non-newer releases must NOT halt pagination — page 2 is requested and its newer
1338        // release (v3.0.0) is included in the result alongside the newer items from page 1.
1339        // (The old early-stop bug would have returned only ["2.0.0", "1.5.0"] in 1 request.)
1340        let (base, captured) = stub_capturing(|base| {
1341            vec![
1342                Resp {
1343                    status: "200 OK",
1344                    link: Some(format!("{base}/repos/o/r/releases?page=2")),
1345                    body: releases_array_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]),
1346                },
1347                Resp {
1348                    status: "200 OK",
1349                    link: None,
1350                    body: releases_array_json(&["v3.0.0"]),
1351                },
1352            ]
1353        });
1354        let upd = github_update_sync(&base, "1.0.0");
1355        let releases = upd.get_newer_releases().unwrap();
1356        let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
1357        // Non-newer items (v1.0.0, v0.9.0) are filtered out per-item; newer items from both
1358        // pages are kept. v3.0.0 from page 2 is present, proving pagination was not halted.
1359        assert_eq!(versions, vec!["2.0.0", "1.5.0", "3.0.0"]);
1360        assert_eq!(
1361            captured.lock().unwrap().len(),
1362            2,
1363            "non-newer releases must not halt pagination; both pages must be requested"
1364        );
1365    }
1366
1367    #[test]
1368    fn early_stop_selects_same_release_as_a_full_walk() {
1369        // Selection parity: the early-stopped `get_newer_releases` must let the updater select the
1370        // SAME release as a full unfiltered walk would. Drive the choice via the same
1371        // `choose_latest_release` the orchestrator uses, comparing the early-stop list against a
1372        // full-walk list of the identical releases.
1373        let early_first_page = releases_array_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]);
1374        let (base, _captured) = stub_capturing(move |base| {
1375            vec![
1376                Resp {
1377                    status: "200 OK",
1378                    link: Some(format!("{base}/repos/o/r/releases?page=2")),
1379                    body: early_first_page,
1380                },
1381                Resp {
1382                    status: "200 OK",
1383                    link: None,
1384                    body: releases_array_json(&["v0.5.0"]),
1385                },
1386            ]
1387        });
1388        let upd = github_update_sync(&base, "1.0.0");
1389        let early = upd.get_newer_releases().unwrap().into_vec();
1390        let early_choice =
1391            crate::update::testing::choose_latest_release_for_test(early, "1.0.0").unwrap();
1392
1393        // A full walk would also see v1.0.0/v0.9.0/v0.5.0, but those are filtered/older, so the
1394        // newest compatible release is the same: v1.5.0 (compatible with 1.0.0; 2.0.0 is a major
1395        // bump and only chosen as a fallback if no compatible exists).
1396        let full = vec![
1397            crate::update::Release::builder()
1398                .version("2.0.0")
1399                .build()
1400                .unwrap(),
1401            crate::update::Release::builder()
1402                .version("1.5.0")
1403                .build()
1404                .unwrap(),
1405            crate::update::Release::builder()
1406                .version("1.0.0")
1407                .build()
1408                .unwrap(),
1409            crate::update::Release::builder()
1410                .version("0.9.0")
1411                .build()
1412                .unwrap(),
1413            crate::update::Release::builder()
1414                .version("0.5.0")
1415                .build()
1416                .unwrap(),
1417        ];
1418        let full_choice =
1419            crate::update::testing::choose_latest_release_for_test(full, "1.0.0").unwrap();
1420        assert_eq!(
1421            early_choice.map(|r| r.version().to_string()),
1422            full_choice.map(|r| r.version().to_string()),
1423            "early-stop must select the same release as a full walk"
1424        );
1425    }
1426
1427    #[test]
1428    fn release_list_fetch_walks_all_pages_unfiltered() {
1429        // `ReleaseList::fetch` is an UNFILTERED listing (stop_at = None) and must keep walking
1430        // ALL pages - even when a page contains releases older than any current version (there is no
1431        // current version here). Page 1 advertises page 2; both must be accumulated.
1432        let (base, captured) = stub_capturing(|base| {
1433            vec![
1434                Resp {
1435                    status: "200 OK",
1436                    link: Some(format!("{base}/repos/o/r/releases?page=2")),
1437                    body: releases_array_json(&["v2.0.0", "v0.5.0"]),
1438                },
1439                Resp {
1440                    status: "200 OK",
1441                    link: None,
1442                    body: releases_array_json(&["v0.1.0"]),
1443                },
1444            ]
1445        });
1446        let releases = super::ReleaseList::configure()
1447            .api_base_url(&base)
1448            .repo_owner("o")
1449            .repo_name("r")
1450            .build()
1451            .unwrap()
1452            .fetch()
1453            .unwrap();
1454        // `ReleaseList::fetch` returns a `Releases` (with no current version); recover the raw
1455        // vec via `into_vec()`.
1456        let releases = releases.into_vec();
1457        let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1458        assert_eq!(
1459            versions,
1460            vec!["2.0.0", "0.5.0", "0.1.0"],
1461            "the unfiltered ReleaseList must accumulate ALL pages, older releases included"
1462        );
1463        assert_eq!(
1464            captured.lock().unwrap().len(),
1465            2,
1466            "both pages must be requested for the unfiltered listing"
1467        );
1468    }
1469
1470    // --- `ReleaseList::fetch` returns a `Releases`; `into_vec()` recovers the releases ----------
1471
1472    #[test]
1473    fn release_list_fetch_returns_releases_and_into_vec_recovers_them() {
1474        // `ReleaseList::fetch` returns a `Releases` carrying NO current version
1475        // (a bare listing), so `current_version()` is `None` and `is_update_available()` errors;
1476        // `into_vec()` recovers the underlying `Vec<Release>` in listing order.
1477        let base = stub(|_| {
1478            vec![Resp {
1479                status: "200 OK",
1480                link: None,
1481                body: releases_array_json(&["v2.0.0", "v1.0.0"]),
1482            }]
1483        });
1484        let releases = super::ReleaseList::configure()
1485            .api_base_url(&base)
1486            .repo_owner("o")
1487            .repo_name("r")
1488            .build()
1489            .unwrap()
1490            .fetch()
1491            .unwrap();
1492        assert_eq!(
1493            releases.current_version(),
1494            None,
1495            "a bare listing carries no current version"
1496        );
1497        assert!(
1498            releases.is_update_available().is_err(),
1499            "a listing with no current version cannot answer is_update_available()"
1500        );
1501        let recovered = releases.into_vec();
1502        let versions: Vec<&str> = recovered.iter().map(|r| r.version()).collect();
1503        assert_eq!(versions, vec!["2.0.0", "1.0.0"]);
1504    }
1505
1506    #[cfg(feature = "async")]
1507    #[tokio::test]
1508    async fn release_list_fetch_async_returns_releases_and_into_vec_recovers_them() {
1509        // Async sibling of `release_list_fetch_returns_releases_and_into_vec_recovers_them`:
1510        // `ReleaseList::fetch_async` returns a `Releases` carrying NO current version
1511        // (a bare listing), so `current_version()` is `None` and `is_update_available()` errors;
1512        // `into_vec()` recovers the underlying `Vec<Release>` in listing order.
1513        let base = stub(|_| {
1514            vec![Resp {
1515                status: "200 OK",
1516                link: None,
1517                body: releases_array_json(&["v2.0.0", "v1.0.0"]),
1518            }]
1519        });
1520        let releases = super::ReleaseList::configure()
1521            .api_base_url(&base)
1522            .repo_owner("o")
1523            .repo_name("r")
1524            .build()
1525            .unwrap()
1526            .fetch_async()
1527            .await
1528            .unwrap();
1529        assert_eq!(
1530            releases.current_version(),
1531            None,
1532            "a bare listing carries no current version"
1533        );
1534        assert!(
1535            releases.is_update_available().is_err(),
1536            "a listing with no current version cannot answer is_update_available()"
1537        );
1538        let recovered = releases.into_vec();
1539        let versions: Vec<&str> = recovered.iter().map(|r| r.version()).collect();
1540        assert_eq!(versions, vec!["2.0.0", "1.0.0"]);
1541    }
1542
1543    // --- the github DTO parses a sample payload into a correct `Release` ----------------
1544
1545    #[test]
1546    fn github_dto_parses_sample_payload_through_getters() {
1547        // A realistic github release object (tag, name, created_at, body, two assets) must parse
1548        // via the private `ReleaseDto` into a public `Release` whose getters return the expected
1549        // values: the leading `v` is stripped from the version, the asset `url`/`name`/`digest`
1550        // map across (a missing `digest` maps to `None`), and the body is carried.
1551        let body = r#"{
1552            "tag_name": "v4.5.6",
1553            "name": "Release 4.5.6",
1554            "created_at": "2024-01-02T03:04:05Z",
1555            "body": "the release notes",
1556            "assets": [
1557                { "name": "app-x86_64-unknown-linux-gnu.tar.gz", "url": "https://api/asset/1",
1558                  "digest": "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" },
1559                { "name": "app-aarch64-apple-darwin.tar.gz", "url": "https://api/asset/2" }
1560            ]
1561        }"#;
1562        let base = stub(move |_| {
1563            vec![Resp {
1564                status: "200 OK",
1565                link: None,
1566                body: body.to_string(),
1567            }]
1568        });
1569        // `get_latest_release` hits `/releases/latest`, which returns a bare release OBJECT parsed
1570        // by the single-object DTO path.
1571        let upd = github_update_sync(&base, "1.0.0");
1572        let releases = upd.get_latest_release().unwrap();
1573        let rel = releases.latest().expect("one-element Releases");
1574        assert_eq!(rel.version(), "4.5.6", "leading v stripped");
1575        assert_eq!(rel.name(), "Release 4.5.6");
1576        assert_eq!(rel.date(), "2024-01-02T03:04:05Z");
1577        assert_eq!(rel.body(), Some("the release notes"));
1578        assert_eq!(rel.assets().len(), 2);
1579        assert_eq!(
1580            rel.assets()[0].name(),
1581            "app-x86_64-unknown-linux-gnu.tar.gz"
1582        );
1583        assert_eq!(rel.assets()[0].download_url(), "https://api/asset/1");
1584        assert_eq!(
1585            rel.assets()[0].digest(),
1586            Some("sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"),
1587            "the API's per-asset digest field is carried onto the asset"
1588        );
1589        assert_eq!(
1590            rel.assets()[1].digest(),
1591            None,
1592            "an asset without a digest field parses with digest None"
1593        );
1594    }
1595
1596    // --- sync/async fetch parity (same plans + parsers) ----------------------------------------
1597
1598    #[cfg(feature = "async")]
1599    #[tokio::test]
1600    async fn sync_and_async_get_newer_releases_agree_on_identical_responses() {
1601        // Both paths share `releases_plan` + the parser + the early-stop filter, so for the SAME
1602        // stubbed body they must yield the IDENTICAL filtered, ordered release list. Drive the sync
1603        // fetch and the async fetch against two separate stubs serving the same body, and compare.
1604        let body = releases_array_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]);
1605
1606        let sync_body = body.clone();
1607        // The sync fetch uses a blocking client; run it off the async executor so its runtime is
1608        // not dropped inside this async context.
1609        let sync_versions: Vec<String> = tokio::task::spawn_blocking(move || {
1610            let sync_base = stub(move |_| {
1611                vec![Resp {
1612                    status: "200 OK",
1613                    link: None,
1614                    body: sync_body,
1615                }]
1616            });
1617            github_update_sync(&sync_base, "1.0.0")
1618                .get_newer_releases()
1619                .unwrap()
1620                .all()
1621                .iter()
1622                .map(|r| r.version().to_string())
1623                .collect()
1624        })
1625        .await
1626        .unwrap();
1627
1628        let async_base = stub(move |_| {
1629            vec![Resp {
1630                status: "200 OK",
1631                link: None,
1632                body,
1633            }]
1634        });
1635        let upd = super::Update::configure()
1636            .repo_owner("o")
1637            .repo_name("r")
1638            .bin_name("app")
1639            .current_version("1.0.0")
1640            .api_base_url(&async_base)
1641            .build_async()
1642            .unwrap();
1643        let async_versions: Vec<String> = upd
1644            .get_newer_releases_async()
1645            .await
1646            .unwrap()
1647            .all()
1648            .iter()
1649            .map(|r| r.version().to_string())
1650            .collect();
1651
1652        assert_eq!(
1653            sync_versions, async_versions,
1654            "sync and async fetch must return the identical releases for the same response"
1655        );
1656        assert_eq!(
1657            sync_versions,
1658            vec!["2.0.0".to_string(), "1.5.0".to_string()],
1659            "and both apply the strictly-newer per-item filter"
1660        );
1661    }
1662
1663    #[cfg(feature = "async")]
1664    #[tokio::test]
1665    async fn fetch_all_releases_async_follows_pagination() {
1666        let base = stub(|base| {
1667            vec![
1668                Resp {
1669                    status: "200 OK",
1670                    link: Some(format!("{base}/releases?page=2")),
1671                    body: release_json("v1.0.0"),
1672                },
1673                Resp {
1674                    status: "200 OK",
1675                    link: None,
1676                    body: release_json("v0.9.0"),
1677                },
1678            ]
1679        });
1680        let releases = fetch_all_releases_async(
1681            &format!("{base}/releases"),
1682            &crate::backends::common::RequestConfig::default(),
1683        )
1684        .await
1685        .unwrap();
1686        assert_eq!(
1687            releases.len(),
1688            2,
1689            "both pages accumulated over async transport"
1690        );
1691        assert_eq!(releases[0].version(), "1.0.0");
1692        assert_eq!(releases[1].version(), "0.9.0");
1693    }
1694
1695    #[cfg(feature = "async")]
1696    #[tokio::test]
1697    async fn get_latest_release_async_parses_release() {
1698        let base = stub(|_| {
1699            vec![Resp {
1700                status: "200 OK",
1701                link: None,
1702                body: release_obj_json("v3.1.0"),
1703            }]
1704        });
1705        let upd = super::Update::configure()
1706            .repo_owner("o")
1707            .repo_name("r")
1708            .bin_name("app")
1709            .current_version("0.1.0")
1710            .api_base_url(&base)
1711            .build_async()
1712            .unwrap();
1713        let releases = upd.get_latest_release_async().await.unwrap();
1714        let rel = releases.latest().expect("one-element Releases");
1715        assert_eq!(rel.version(), "3.1.0");
1716    }
1717
1718    #[cfg(feature = "async")]
1719    #[tokio::test]
1720    async fn update_async_reports_up_to_date() {
1721        // The only release (v1.0.0) is older than the current version, so the async update flow
1722        // fetches + filters and reports up-to-date without downloading anything.
1723        let base = stub(|_| {
1724            vec![Resp {
1725                status: "200 OK",
1726                link: None,
1727                body: release_json("v1.0.0"),
1728            }]
1729        });
1730        let upd = super::Update::configure()
1731            .repo_owner("o")
1732            .repo_name("r")
1733            .bin_name("app")
1734            .current_version("2.0.0")
1735            .api_base_url(&base)
1736            .no_confirm(true)
1737            .show_output(false)
1738            .build_async()
1739            .unwrap();
1740        let status = upd.update_extended_async().await.unwrap();
1741        assert!(status.is_up_to_date(), "an older release means up-to-date");
1742    }
1743
1744    #[cfg(feature = "async")]
1745    #[tokio::test]
1746    async fn is_update_available_async_reports_newest_newer_or_none() {
1747        // Exercises the inherent `AsyncUpdate::is_update_available_async` verb emitted by
1748        // `impl_async_update_verbs!`: from an older current version it returns the newest
1749        // strictly-newer release; from a current version at/above the newest it returns `None`.
1750        let body = r#"[{"tag_name":"v2.0.0","created_at":"2020-01-01T00:00:00Z","name":"v2.0.0","assets":[]},{"tag_name":"v0.9.0","created_at":"2020-01-01T00:00:00Z","name":"v0.9.0","assets":[]}]"#;
1751        let mk = |cur: &'static str| {
1752            let base = stub(move |_| {
1753                vec![Resp {
1754                    status: "200 OK",
1755                    link: None,
1756                    body: body.to_string(),
1757                }]
1758            });
1759            super::Update::configure()
1760                .repo_owner("o")
1761                .repo_name("r")
1762                .bin_name("app")
1763                .current_version(cur)
1764                .api_base_url(&base)
1765                .build_async()
1766                .unwrap()
1767        };
1768        let newer = mk("1.0.0").is_update_available_async().await.unwrap();
1769        assert_eq!(
1770            newer.map(|r| r.version().to_string()),
1771            Some("2.0.0".to_string()),
1772            "from 1.0.0 the 2.0.0 release is available"
1773        );
1774        assert!(
1775            mk("2.0.0")
1776                .is_update_available_async()
1777                .await
1778                .unwrap()
1779                .is_none(),
1780            "from 2.0.0 nothing newer => None"
1781        );
1782    }
1783
1784    #[test]
1785    fn get_newer_releases_sync_returns_releases_and_precheck() {
1786        // D1 (sync, github): `get_newer_releases()` returns a `Releases` carrying the configured
1787        // current version; `.is_update_available()` / `.latest()` work off it without a 2nd fetch.
1788        // The stub lists v2.0.0 and v0.9.0; with current 1.0.0 only 2.0.0 is newer.
1789        let base = stub(|_| {
1790            vec![Resp {
1791                status: "200 OK",
1792                link: None,
1793                body: r#"[{"tag_name":"v2.0.0","created_at":"2020-01-01T00:00:00Z","name":"v2.0.0","assets":[]},{"tag_name":"v0.9.0","created_at":"2020-01-01T00:00:00Z","name":"v0.9.0","assets":[]}]"#.to_string(),
1794            }]
1795        });
1796        let upd = super::Update::configure()
1797            .repo_owner("o")
1798            .repo_name("r")
1799            .bin_name("app")
1800            .current_version("1.0.0")
1801            .api_base_url(&base)
1802            .build()
1803            .unwrap();
1804        let releases = upd.get_newer_releases().unwrap();
1805        let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
1806        assert_eq!(versions, vec!["2.0.0"], "only strictly-newer releases kept");
1807        assert_eq!(releases.latest().unwrap().version(), "2.0.0");
1808        assert!(
1809            releases.is_update_available().unwrap(),
1810            "2.0.0 > 1.0.0 via the returned Releases"
1811        );
1812    }
1813
1814    fn github_update_sync(base: &str, current_version: &str) -> super::Update {
1815        super::Update::configure()
1816            .repo_owner("o")
1817            .repo_name("r")
1818            .bin_name("app")
1819            .current_version(current_version)
1820            .api_base_url(base)
1821            .build()
1822            .unwrap()
1823    }
1824
1825    #[test]
1826    fn get_latest_release_sync_wraps_single_object_into_one_element_releases() {
1827        // gap #4 (sync, github): `get_latest_release` hits `/releases/latest`, which returns a
1828        // single release *object* (not an array). The sync path must parse that bare object,
1829        // strip the leading `v`, and wrap it in a one-element `Releases` carrying the current
1830        // version, so `.is_update_available()` works off the single newest release.
1831        let base = stub(|_| {
1832            vec![Resp {
1833                status: "200 OK",
1834                link: None,
1835                body: release_obj_json("v3.1.0"),
1836            }]
1837        });
1838        let upd = github_update_sync(&base, "1.0.0");
1839        let releases = upd.get_latest_release().unwrap();
1840        assert_eq!(
1841            releases.all().len(),
1842            1,
1843            "get_latest_release yields a one-element Releases"
1844        );
1845        assert_eq!(releases.latest().unwrap().version(), "3.1.0");
1846        assert!(
1847            releases.is_update_available().unwrap(),
1848            "3.1.0 > 1.0.0 via the one-element Releases pre-check"
1849        );
1850    }
1851
1852    #[test]
1853    fn get_latest_release_sync_reports_not_available_when_newest_equals_current() {
1854        // gap #4 (sync, github): `/releases/latest` returns the newest tag even when it equals the
1855        // current version, so the one-element `Releases` must report not-available (no false
1856        // positive), agreeing with the strictly-newer-filtered list path.
1857        let base = stub(|_| {
1858            vec![Resp {
1859                status: "200 OK",
1860                link: None,
1861                body: release_obj_json("v1.0.0"),
1862            }]
1863        });
1864        let upd = github_update_sync(&base, "1.0.0");
1865        let releases = upd.get_latest_release().unwrap();
1866        assert_eq!(releases.latest().unwrap().version(), "1.0.0");
1867        assert!(
1868            !releases.is_update_available().unwrap(),
1869            "newest (1.0.0) == current => not available on the one-element path"
1870        );
1871    }
1872
1873    #[test]
1874    fn update_extended_sync_reports_up_to_date_through_the_orchestrator() {
1875        // gap #3 (sync, git backend): the sync `update_extended()` orchestrator must drive
1876        // fetch -> choose_latest_release(releases.into_vec()) to an UpToDate outcome when the only
1877        // listed release is older than current, without touching the download. This is the git
1878        // backend analogue of the custom-backend sync end-to-end tests and the github *async*
1879        // up-to-date test.
1880        let base = stub(|_| {
1881            vec![Resp {
1882                status: "200 OK",
1883                link: None,
1884                body: release_json("v1.0.0"),
1885            }]
1886        });
1887        let upd = super::Update::configure()
1888            .repo_owner("o")
1889            .repo_name("r")
1890            .bin_name("app")
1891            .current_version("2.0.0")
1892            .api_base_url(&base)
1893            .no_confirm(true)
1894            .show_output(false)
1895            .build()
1896            .unwrap();
1897        let status = upd.update_extended().unwrap();
1898        assert!(
1899            status.is_up_to_date(),
1900            "an older listed release means up-to-date through the sync orchestrator"
1901        );
1902    }
1903
1904    #[test]
1905    fn fetch_all_releases_follows_link_pagination() {
1906        // Page 1 advertises a `rel="next"` to page 2; page 2 has no next link.
1907        let base = stub(|base| {
1908            vec![
1909                Resp {
1910                    status: "200 OK",
1911                    link: Some(format!("{base}/releases?page=2")),
1912                    body: release_json("v1.0.0"),
1913                },
1914                Resp {
1915                    status: "200 OK",
1916                    link: None,
1917                    body: release_json("v0.9.0"),
1918                },
1919            ]
1920        });
1921        let releases = fetch_all_releases(
1922            &format!("{base}/releases"),
1923            &crate::backends::common::RequestConfig::default(),
1924        )
1925        .unwrap();
1926        assert_eq!(
1927            releases.len(),
1928            2,
1929            "releases from both pages are accumulated"
1930        );
1931        assert_eq!(releases[0].version(), "1.0.0");
1932        assert_eq!(releases[1].version(), "0.9.0");
1933    }
1934
1935    #[test]
1936    fn fetch_all_releases_errors_on_http_error_status() {
1937        let base = stub(|_| {
1938            vec![Resp {
1939                status: "404 Not Found",
1940                link: None,
1941                body: "nope".to_string(),
1942            }]
1943        });
1944        let res = fetch_all_releases(
1945            &format!("{base}/releases"),
1946            &crate::backends::common::RequestConfig::default(),
1947        );
1948        // A non-2xx status always produces a structured status variant (NotFound /
1949        // Unauthorized / HttpStatus). Both reqwest and ureq map consistently after this change.
1950        assert!(matches!(
1951            res,
1952            Err(crate::errors::Error::NotFound { .. })
1953                | Err(crate::errors::Error::Unauthorized { .. })
1954                | Err(crate::errors::Error::HttpStatus { .. })
1955        ));
1956    }
1957
1958    #[test]
1959    fn fetch_all_releases_errors_when_body_is_not_an_array() {
1960        let base = stub(|_| {
1961            vec![Resp {
1962                status: "200 OK",
1963                link: None,
1964                body: "{}".to_string(),
1965            }]
1966        });
1967        let res = fetch_all_releases(
1968            &format!("{base}/releases"),
1969            &crate::backends::common::RequestConfig::default(),
1970        );
1971        assert!(
1972            matches!(res, Err(crate::errors::Error::InvalidResponse { .. })),
1973            "a non-array listing body must surface as Error::InvalidResponse, got {:?}",
1974            res
1975        );
1976    }
1977
1978    /// Like [`stub`], but also captures each incoming raw request so tests can assert on what
1979    /// the client actually sent.
1980    fn stub_capturing(
1981        make: impl FnOnce(&str) -> Vec<Resp>,
1982    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1983        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1984        let base = format!("http://{}", listener.local_addr().unwrap());
1985        let responses = make(&base);
1986        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1987        let sink = captured.clone();
1988        std::thread::spawn(move || {
1989            for r in responses {
1990                let (mut stream, _) = match listener.accept() {
1991                    Ok(c) => c,
1992                    Err(_) => return,
1993                };
1994                let mut buf = [0u8; 4096];
1995                let n = stream.read(&mut buf).unwrap_or(0);
1996                sink.lock()
1997                    .unwrap()
1998                    .push(String::from_utf8_lossy(&buf[..n]).into_owned());
1999                let mut out = format!(
2000                    "HTTP/1.1 {}\r\nContent-Type: application/json\r\n",
2001                    r.status
2002                );
2003                if let Some(link) = r.link {
2004                    out.push_str(&format!("Link: <{link}>; rel=\"next\"\r\n"));
2005                }
2006                out.push_str(&format!(
2007                    "Content-Length: {}\r\nConnection: close\r\n\r\n{}",
2008                    r.body.len(),
2009                    r.body
2010                ));
2011                let _ = stream.write_all(out.as_bytes());
2012                let _ = stream.flush();
2013            }
2014        });
2015        (base, captured)
2016    }
2017
2018    #[test]
2019    fn get_release_version_percent_encodes_the_tag_in_the_url() {
2020        // The caller-supplied tag is interpolated into the request URL and must be
2021        // percent-encoded. A tag with a URL-special `+` must appear as `%2B` on the wire, never
2022        // raw. Without the fix the raw `+` reaches the path and this assertion fails.
2023        let (base, captured) = stub_capturing(|_| {
2024            vec![Resp {
2025                status: "200 OK",
2026                link: None,
2027                body: release_obj_json("v1.0.0+build.5"),
2028            }]
2029        });
2030        let upd = super::Update::configure()
2031            .repo_owner("o")
2032            .repo_name("r")
2033            .bin_name("app")
2034            .current_version("0.1.0")
2035            .api_base_url(&base)
2036            .build()
2037            .unwrap();
2038        let rel = upd.get_release_version("v1.0.0+build.5").unwrap();
2039        assert_eq!(rel.version(), "1.0.0+build.5");
2040        let request = &captured.lock().unwrap()[0];
2041        let request_line = request.lines().next().unwrap_or_default();
2042        assert!(
2043            request_line.contains("/releases/tags/v1.0.0%2Bbuild.5"),
2044            "tag should be percent-encoded in the request path, got: {}",
2045            request_line
2046        );
2047        assert!(
2048            !request_line.contains("v1.0.0+build.5"),
2049            "raw unencoded `+` must not reach the request path, got: {}",
2050            request_line
2051        );
2052    }
2053
2054    #[test]
2055    fn urls_percent_encode_repo_owner_and_name() {
2056        // `releases_url()`/`latest_url()`/`tag_url()` percent-encode `repo_owner` and
2057        // `repo_name`, matching the gitlab/gitea backends. github.com restricts these to
2058        // URL-safe characters, but a GitHub Enterprise namespace (or a copy-paste of this URL
2059        // construction) must not smuggle raw URL-special characters into the path.
2060        let (base, captured) = stub_capturing(|_| {
2061            vec![Resp {
2062                status: "200 OK",
2063                link: None,
2064                body: release_obj_json("v1.0.0"),
2065            }]
2066        });
2067        let upd = super::Update::configure()
2068            .repo_owner("own er")
2069            .repo_name("re#po")
2070            .bin_name("app")
2071            .current_version("0.1.0")
2072            .api_base_url(&base)
2073            .build()
2074            .unwrap();
2075        let _ = upd.get_release_version("v1.0.0").unwrap();
2076        let request = &captured.lock().unwrap()[0];
2077        let request_line = request.lines().next().unwrap_or_default();
2078        assert!(
2079            request_line.contains("/repos/own%20er/re%23po/releases/tags/"),
2080            "owner and name must be percent-encoded in the request path, got: {}",
2081            request_line
2082        );
2083    }
2084
2085    #[test]
2086    fn builder_stores_timeout_and_request_header() {
2087        use std::time::Duration;
2088        let upd = super::Update::configure()
2089            .repo_owner("o")
2090            .repo_name("r")
2091            .bin_name("app")
2092            .current_version("0.1.0")
2093            .timeout(Duration::from_secs(7))
2094            // `request_header` accepts `TryInto<HeaderName>`/`TryInto<HeaderValue>`, so plain
2095            // string args work (no `.parse().unwrap()` needed).
2096            .request_header("x-foo", "bar")
2097            .build()
2098            .unwrap();
2099        assert_eq!(upd.request_timeout(), Some(Duration::from_secs(7)));
2100        assert_eq!(
2101            upd.request_headers()
2102                .get("x-foo")
2103                .unwrap()
2104                .to_str()
2105                .unwrap(),
2106            "bar"
2107        );
2108    }
2109
2110    #[test]
2111    fn request_header_accepts_typed_args() {
2112        use crate::http_client::header::{HeaderName, HeaderValue};
2113        // Already-typed header name/value still work (identity `TryInto`), keeping old call sites
2114        // valid.
2115        let upd = super::Update::configure()
2116            .repo_owner("o")
2117            .repo_name("r")
2118            .bin_name("app")
2119            .current_version("0.1.0")
2120            .request_header(
2121                HeaderName::from_static("x-typed"),
2122                HeaderValue::from_static("v"),
2123            )
2124            .build()
2125            .unwrap();
2126        assert_eq!(upd.request_headers().get("x-typed").unwrap(), "v");
2127    }
2128
2129    #[test]
2130    fn api_headers_override_uses_github_user_agent() {
2131        // The `{api_headers}` override arm of `impl_update_config_accessors!` must wire github's
2132        // custom `api_headers` (the shared `self_update/<version>` User-Agent), not the trait
2133        // default (which sets no User-Agent). The auth scheme/token is not baked into
2134        // `api_headers`; it is applied centrally by `apply_auth` (asserted in
2135        // `github_token_scheme_applied_to_both_paths`).
2136        let upd = super::Update::configure()
2137            .repo_owner("o")
2138            .repo_name("r")
2139            .bin_name("app")
2140            .current_version("0.1.0")
2141            .build()
2142            .unwrap();
2143        let headers = upd.api_headers(Some("secret")).unwrap();
2144        assert_eq!(
2145            headers
2146                .get(crate::http_client::header::USER_AGENT)
2147                .unwrap()
2148                .to_str()
2149                .unwrap(),
2150            crate::DEFAULT_USER_AGENT
2151        );
2152        assert!(
2153            headers
2154                .get(crate::http_client::header::AUTHORIZATION)
2155                .is_none(),
2156            "api_headers no longer bakes auth; apply_auth applies the scheme"
2157        );
2158    }
2159
2160    // A malformed root certificate supplied via `add_root_certificate` surfaces end to end as
2161    // `Error::InvalidCertificate` from `build()` on both the Update and ReleaseList builders (the
2162    // deferred cert-build error is materialized by `build_client` and surfaced by `check`). The
2163    // reqwest client rejects a PEM-framed body that is not valid X.509 DER at client-build time.
2164    #[cfg(feature = "reqwest")]
2165    #[test]
2166    fn add_root_certificate_bad_cert_surfaces_from_build() {
2167        const BAD_PEM: &[u8] =
2168            b"-----BEGIN CERTIFICATE-----\nbm90IGEgdmFsaWQgY2VydA==\n-----END CERTIFICATE-----\n";
2169        let res = super::Update::configure()
2170            .repo_owner("o")
2171            .repo_name("r")
2172            .bin_name("app")
2173            .current_version("0.1.0")
2174            .add_root_certificate(crate::Certificate::from_pem(BAD_PEM.to_vec()))
2175            .build();
2176        assert!(
2177            matches!(res, Err(crate::errors::Error::InvalidCertificate { .. })),
2178            "a bad cert must surface as InvalidCertificate from Update build(), got {:?}",
2179            res.map(|_| "Ok")
2180        );
2181        let res = super::ReleaseList::configure()
2182            .repo_owner("o")
2183            .repo_name("r")
2184            .add_root_certificate(crate::Certificate::from_pem(BAD_PEM.to_vec()))
2185            .build();
2186        assert!(
2187            matches!(res, Err(crate::errors::Error::InvalidCertificate { .. })),
2188            "a bad cert must surface as InvalidCertificate from ReleaseList build(), got {:?}",
2189            res.map(|_| "Ok")
2190        );
2191    }
2192
2193    // spec: CORP-3-1, CORP-3-3
2194    // An unparseable proxy URL supplied via `proxy` surfaces end to end as `Error::InvalidProxy`
2195    // from `build()` on both the Update and ReleaseList builders -- the same deferred-error path as
2196    // `add_root_certificate`, and the proof that the setter emitted by `request_config_setters!` is
2197    // actually reachable on every backend builder. The password never reaches the error text.
2198    #[test]
2199    fn proxy_bad_url_surfaces_from_build() {
2200        let res = super::Update::configure()
2201            .repo_owner("o")
2202            .repo_name("r")
2203            .bin_name("app")
2204            .current_version("0.1.0")
2205            .proxy("http://corpuser:hunter2@ not a proxy url")
2206            .build();
2207        let err = res
2208            .map(|_| "Ok")
2209            .expect_err("an unparseable proxy must fail build()");
2210        assert!(
2211            matches!(err, crate::errors::Error::InvalidProxy { .. }),
2212            "a bad proxy URL must surface as InvalidProxy from Update build(), got {err:?}"
2213        );
2214        let rendered = err.to_string();
2215        assert!(
2216            !rendered.contains("hunter2") && rendered.contains("REDACTED"),
2217            "the proxy password must not reach the error text, got: {rendered}"
2218        );
2219        let res = super::ReleaseList::configure()
2220            .repo_owner("o")
2221            .repo_name("r")
2222            .proxy("http://corpuser:hunter2@ not a proxy url")
2223            .build();
2224        assert!(
2225            matches!(res, Err(crate::errors::Error::InvalidProxy { .. })),
2226            "a bad proxy URL must surface as InvalidProxy from ReleaseList build(), got {:?}",
2227            res.map(|_| "Ok")
2228        );
2229    }
2230
2231    // spec: CORP-3-2
2232    // A valid proxy is stored on the built config and reaches the request layer, so the listing
2233    // and download both route through it. `.proxy()` called twice keeps only the last URL.
2234    #[test]
2235    fn proxy_is_stored_and_last_call_wins() {
2236        let upd = super::Update::configure()
2237            .repo_owner("o")
2238            .repo_name("r")
2239            .bin_name("app")
2240            .current_version("0.1.0")
2241            .proxy("http://first.proxy:8080")
2242            .proxy("http://second.proxy:8080")
2243            .build()
2244            .expect("a valid proxy must build");
2245        assert_eq!(
2246            upd.request_config().proxy.as_deref(),
2247            Some("http://second.proxy:8080"),
2248            "the last `proxy` call must win"
2249        );
2250    }
2251
2252    // The asset download URL comes from the API `url` field, not `browser_download_url`. Both are
2253    // present on a real payload, and picking the wrong one is a silent, release-visible break: the
2254    // API url + `Accept: application/octet-stream` (see
2255    // `update::tests::download_path_requests_the_asset_as_octet_stream`) works for public AND
2256    // private releases, while `browser_download_url` 404s for a private one. Pin the choice.
2257    #[test]
2258    fn asset_download_url_comes_from_the_api_url_field() {
2259        let dto: super::ReleaseDto = serde_json::from_str(
2260            r#"{"tag_name":"v1.2.3","created_at":"2024-01-01T00:00:00Z","name":"1.2.3","assets":[
2261                 {"name":"app.tar.gz",
2262                  "url":"https://api.github.com/repos/o/r/releases/assets/1",
2263                  "browser_download_url":"https://github.com/o/r/releases/download/v1.2.3/app.tar.gz"}]}"#,
2264        )
2265        .expect("parse release json");
2266        let release = dto.into_release(None).expect("into_release");
2267        assert_eq!(
2268            release.assets()[0].download_url(),
2269            "https://api.github.com/repos/o/r/releases/assets/1",
2270            "the asset URL must be the API `url`, which serves private assets too"
2271        );
2272    }
2273
2274    // github resolves to the `token` scheme, applied by the shared `apply_auth` on the request
2275    // config that BOTH the listing and download paths consume. A configured auth_token renders as
2276    // `token <token>`; a user `request_header(AUTHORIZATION, ..)` override wins on both paths.
2277    #[test]
2278    fn github_token_scheme_applied_to_both_paths() {
2279        use crate::http_client::header::{AUTHORIZATION, HeaderMap};
2280        let upd = super::Update::configure()
2281            .repo_owner("o")
2282            .repo_name("r")
2283            .bin_name("app")
2284            .current_version("0.1.0")
2285            .auth_token("secret")
2286            .build()
2287            .unwrap();
2288        let mut headers = HeaderMap::new();
2289        upd.request_config()
2290            .apply_auth(
2291                "https://api.github.com/repos/o/r/releases/assets/1",
2292                &mut headers,
2293            )
2294            .unwrap();
2295        assert_eq!(
2296            headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
2297            "token secret",
2298            "github authenticates with the token scheme"
2299        );
2300
2301        // A user AUTHORIZATION override (via request_header) wins: apply_auth is a no-op.
2302        let upd = super::Update::configure()
2303            .repo_owner("o")
2304            .repo_name("r")
2305            .bin_name("app")
2306            .current_version("0.1.0")
2307            .auth_token("secret")
2308            .request_header(AUTHORIZATION, "Bearer user-override")
2309            .build()
2310            .unwrap();
2311        let mut headers = upd.request_config().headers.clone();
2312        upd.request_config()
2313            .apply_auth(
2314                "https://api.github.com/repos/o/r/releases/assets/1",
2315                &mut headers,
2316            )
2317            .unwrap();
2318        assert_eq!(
2319            headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
2320            "Bearer user-override",
2321            "a user AUTHORIZATION override must win over the backend token scheme"
2322        );
2323    }
2324
2325    // an auth token that cannot be encoded as a header value surfaces as
2326    // `Error::InvalidAuthToken` and chains the underlying header-parse error through `source()`.
2327    // The derivation lives in `apply_auth`.
2328    #[test]
2329    fn invalid_auth_token_chains_source() {
2330        use crate::http_client::header::HeaderMap;
2331        use std::error::Error as _;
2332        let upd = super::Update::configure()
2333            .repo_owner("o")
2334            .repo_name("r")
2335            .bin_name("app")
2336            .current_version("0.1.0")
2337            .auth_token("bad\nvalue")
2338            .build()
2339            .unwrap();
2340        let mut headers = HeaderMap::new();
2341        let err = upd
2342            .request_config()
2343            .apply_auth(
2344                "https://api.github.com/repos/o/r/releases/assets/1",
2345                &mut headers,
2346            )
2347            .expect_err("an unencodable auth token must error");
2348        assert!(
2349            matches!(err, crate::errors::Error::InvalidAuthToken { .. }),
2350            "expected Error::InvalidAuthToken, got {:?}",
2351            err
2352        );
2353        assert!(
2354            err.source().is_some(),
2355            "InvalidAuthToken must chain a non-None source()"
2356        );
2357    }
2358
2359    #[test]
2360    fn request_header_surfaces_invalid_header_at_build() {
2361        // A header name that is not a valid HTTP token must fail at `build()` with `Error::Config`,
2362        // not panic in the setter.
2363        let res = super::Update::configure()
2364            .repo_owner("o")
2365            .repo_name("r")
2366            .bin_name("app")
2367            .current_version("0.1.0")
2368            .request_header("inva lid name", "ok")
2369            .build();
2370        assert!(
2371            matches!(res, Err(crate::errors::Error::InvalidHeader { .. })),
2372            "invalid header name should surface as Error::InvalidHeader from build()"
2373        );
2374    }
2375
2376    #[test]
2377    fn builder_stores_progress_callback() {
2378        let upd = super::Update::configure()
2379            .repo_owner("o")
2380            .repo_name("r")
2381            .bin_name("app")
2382            .current_version("0.1.0")
2383            .progress_callback(|_downloaded, _total| {})
2384            .build()
2385            .unwrap();
2386        // The callback is forwarded to the download step (accessor is internal/doc-hidden).
2387        assert!(upd.progress_callback().is_some());
2388    }
2389
2390    #[test]
2391    fn builder_stores_verify_hook() {
2392        let upd = super::Update::configure()
2393            .repo_owner("o")
2394            .repo_name("r")
2395            .bin_name("app")
2396            .current_version("0.1.0")
2397            .verify_binary(|_new_exe| Ok(()))
2398            .build()
2399            .unwrap();
2400        assert!(upd.verify_callback().is_some());
2401    }
2402
2403    #[test]
2404    #[cfg(feature = "checksums")]
2405    fn builder_stores_checksum() {
2406        let upd = super::Update::configure()
2407            .repo_owner("o")
2408            .repo_name("r")
2409            .bin_name("app")
2410            .current_version("0.1.0")
2411            .verify_checksum(crate::Checksum::Sha256("ab".repeat(32)))
2412            .build()
2413            .unwrap();
2414        assert!(upd.verify_checksum().is_some());
2415    }
2416
2417    // BNDL-1-1/BNDL-1-2: the bundle setters are part of the shared builder surface, so they exist
2418    // on a real backend's builder and their values reach the built `Update`'s accessors. Bundle
2419    // mode is off by default, in which case both accessors are `None` (the trait defaults).
2420    #[test]
2421    fn builder_stores_bundle_paths() {
2422        let plain = super::Update::configure()
2423            .repo_owner("o")
2424            .repo_name("r")
2425            .bin_name("app")
2426            .current_version("0.1.0")
2427            .build()
2428            .unwrap();
2429        assert!(
2430            plain.bundle_path_in_archive().is_none() && plain.bundle_install_path().is_none(),
2431            "bundle mode must be off unless bundle_path_in_archive is set"
2432        );
2433
2434        let bundled = super::Update::configure()
2435            .repo_owner("o")
2436            .repo_name("r")
2437            .bin_name("app")
2438            .current_version("0.1.0")
2439            .bundle_path_in_archive("{{ bin }}-{{ version }}/MyApp.app")
2440            .bundle_install_path("/Applications/MyApp.app")
2441            .build()
2442            .unwrap();
2443        assert_eq!(
2444            bundled.bundle_path_in_archive(),
2445            Some("{{ bin }}-{{ version }}/MyApp.app")
2446        );
2447        assert_eq!(
2448            bundled.bundle_install_path(),
2449            Some(std::path::Path::new("/Applications/MyApp.app"))
2450        );
2451    }
2452
2453    // BNDL-1-4: bundle mode combined with an explicit single-file setter is rejected by `build()`
2454    // (naming both sides) rather than silently dropping one and installing to the wrong path. The
2455    // value `bin_name` auto-derives is not an explicit call, so it never conflicts -- but an
2456    // explicit `bin_path_in_archive` does, even when it repeats the auto-derived value.
2457    #[test]
2458    fn builder_rejects_bundle_mode_combined_with_the_single_file_setters() {
2459        let conflict = |res: crate::errors::Result<super::Update>| match res {
2460            Err(crate::errors::Error::ConflictingConfig { field, conflict }) => {
2461                assert_eq!(field, "bundle_path_in_archive");
2462                conflict
2463            }
2464            other => panic!("expected ConflictingConfig, got {:?}", other.map(|_| ())),
2465        };
2466
2467        let res = super::Update::configure()
2468            .repo_owner("o")
2469            .repo_name("r")
2470            .bin_name("app")
2471            .current_version("0.1.0")
2472            .bundle_path_in_archive("MyApp.app")
2473            .bundle_install_path("/Applications/MyApp.app")
2474            .bin_install_path("/usr/local/bin/app")
2475            .build();
2476        assert_eq!(conflict(res), "bin_install_path");
2477
2478        let res = super::Update::configure()
2479            .repo_owner("o")
2480            .repo_name("r")
2481            .bin_name("app")
2482            .current_version("0.1.0")
2483            .bundle_path_in_archive("MyApp.app")
2484            .bundle_install_path("/Applications/MyApp.app")
2485            // Even repeating what `bin_name` already derived is an explicit call, and conflicts.
2486            .bin_path_in_archive("app")
2487            .build();
2488        assert_eq!(conflict(res), "bin_path_in_archive");
2489    }
2490
2491    #[test]
2492    fn builder_stores_asset_matcher() {
2493        let upd = super::Update::configure()
2494            .repo_owner("o")
2495            .repo_name("r")
2496            .bin_name("app")
2497            .current_version("0.1.0")
2498            .asset_matcher(|assets| assets.first().cloned())
2499            .build()
2500            .unwrap();
2501        assert!(upd.asset_matcher().is_some());
2502    }
2503
2504    #[test]
2505    fn asset_matcher_overrides_default_selection() {
2506        use crate::update::{Release, ReleaseAsset};
2507
2508        // Asset names the built-in target/OS/ARCH substring heuristic can't pick.
2509        let release = Release::builder()
2510            .version("1.0.0")
2511            .assets([
2512                ReleaseAsset::new("app-stable.bin", "https://example/stable"),
2513                ReleaseAsset::new("app-nightly.bin", "https://example/nightly"),
2514            ])
2515            .build()
2516            .unwrap();
2517
2518        // Default selection finds nothing (no asset contains the target triple / OS+ARCH).
2519        assert!(release.asset_for("some-unmatchable-target", None).is_none());
2520
2521        // A custom matcher can pick by an arbitrary rule.
2522        let upd = super::Update::configure()
2523            .repo_owner("o")
2524            .repo_name("r")
2525            .bin_name("app")
2526            .current_version("0.1.0")
2527            .asset_matcher(|assets| assets.iter().find(|a| a.name.contains("nightly")).cloned())
2528            .build()
2529            .unwrap();
2530        let matcher = upd.asset_matcher().expect("matcher stored");
2531        let chosen = matcher(release.assets()).expect("matcher selects an asset");
2532        assert_eq!(chosen.name(), "app-nightly.bin");
2533        assert_eq!(chosen.download_url(), "https://example/nightly");
2534    }
2535
2536    #[cfg(feature = "reqwest")]
2537    #[test]
2538    fn builder_stores_reqwest_client() {
2539        let client = reqwest::blocking::Client::builder().build().unwrap();
2540        let upd = super::Update::configure()
2541            .repo_owner("o")
2542            .repo_name("r")
2543            .bin_name("app")
2544            .current_version("0.1.0")
2545            .reqwest_client(client)
2546            .build()
2547            .unwrap();
2548        // The convenience setter wraps the client in a `ReqwestClient` and stores it as the
2549        // injected `Arc<dyn HttpClient>`.
2550        assert!(upd.request_client().is_some());
2551    }
2552
2553    /// A `HeaderMap` with a single marker header, used as an injected client's `default_headers`
2554    /// so the wire tests can prove the *injected* client (not a fresh per-call one) was used.
2555    #[cfg(feature = "reqwest")]
2556    fn marker_default_headers() -> crate::http_client::header::HeaderMap {
2557        use crate::http_client::header::{HeaderMap, HeaderName, HeaderValue};
2558        let mut headers = HeaderMap::new();
2559        headers.insert(
2560            HeaderName::from_static("x-injected-client"),
2561            HeaderValue::from_static("marker"),
2562        );
2563        headers
2564    }
2565
2566    #[cfg(feature = "reqwest")]
2567    #[test]
2568    fn injected_reqwest_client_is_used_on_the_wire() {
2569        use crate::backends::common::RequestConfig;
2570        let (base, captured) = stub_capturing(|_| {
2571            vec![Resp {
2572                status: "200 OK",
2573                link: None,
2574                body: release_json("v1.2.3"),
2575            }]
2576        });
2577        // The injected client carries a marker default header the per-call client would never add.
2578        let client = reqwest::blocking::Client::builder()
2579            .default_headers(marker_default_headers())
2580            .build()
2581            .unwrap();
2582        let cfg = RequestConfig {
2583            client: Some(std::sync::Arc::new(
2584                crate::http_client::ReqwestClient::from(client),
2585            )),
2586            ..Default::default()
2587        };
2588        let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2589        assert_eq!(releases.len(), 1);
2590        assert_eq!(releases[0].version(), "1.2.3");
2591        let request = captured.lock().unwrap()[0].to_lowercase();
2592        assert!(
2593            request.contains("x-injected-client: marker"),
2594            "the injected client's default header proves it was used (not a fresh client)"
2595        );
2596    }
2597
2598    #[cfg(feature = "async")]
2599    #[test]
2600    fn builder_stores_reqwest_async_client() {
2601        let client = reqwest::Client::builder().build().unwrap();
2602        let upd = super::Update::configure()
2603            .repo_owner("o")
2604            .repo_name("r")
2605            .bin_name("app")
2606            .current_version("0.1.0")
2607            .reqwest_async_client(client)
2608            .build()
2609            .unwrap();
2610        assert!(upd.request_async_client().is_some());
2611    }
2612
2613    #[cfg(feature = "async")]
2614    #[tokio::test]
2615    async fn injected_async_client_is_used_on_the_wire() {
2616        use crate::backends::common::RequestConfig;
2617        let (base, captured) = stub_capturing(|_| {
2618            vec![Resp {
2619                status: "200 OK",
2620                link: None,
2621                body: release_json("v2.0.0"),
2622            }]
2623        });
2624        let client = reqwest::Client::builder()
2625            .default_headers(marker_default_headers())
2626            .build()
2627            .unwrap();
2628        let cfg = RequestConfig {
2629            async_client: Some(std::sync::Arc::new(
2630                crate::http_client::ReqwestAsyncClient::from(client),
2631            )),
2632            ..Default::default()
2633        };
2634        let releases = fetch_all_releases_async(&format!("{base}/releases"), &cfg)
2635            .await
2636            .unwrap();
2637        assert_eq!(releases.len(), 1);
2638        assert_eq!(releases[0].version(), "2.0.0");
2639        let request = captured.lock().unwrap()[0].to_lowercase();
2640        assert!(
2641            request.contains("x-injected-client: marker"),
2642            "the injected async client's default header proves it was used"
2643        );
2644    }
2645
2646    #[cfg(feature = "ureq")]
2647    #[test]
2648    fn injected_ureq_agent_is_used_on_the_wire() {
2649        use crate::backends::common::RequestConfig;
2650        let base = stub(|_| {
2651            vec![Resp {
2652                status: "200 OK",
2653                link: None,
2654                body: release_json("v3.0.0"),
2655            }]
2656        });
2657        let agent = ureq::Agent::new_with_config(ureq::Agent::config_builder().build());
2658        let upd = super::Update::configure()
2659            .repo_owner("o")
2660            .repo_name("r")
2661            .bin_name("app")
2662            .current_version("0.1.0")
2663            .ureq_agent(agent)
2664            .build()
2665            .unwrap();
2666        assert!(upd.request_client().is_some());
2667
2668        // And the injected agent actually performs the request.
2669        let agent = ureq::Agent::new_with_config(ureq::Agent::config_builder().build());
2670        let cfg = RequestConfig {
2671            client: Some(std::sync::Arc::new(crate::http_client::UreqClient::from(
2672                agent,
2673            ))),
2674            ..Default::default()
2675        };
2676        let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2677        assert_eq!(releases.len(), 1);
2678        assert_eq!(releases[0].version(), "3.0.0");
2679    }
2680
2681    // --- trait-seam injection (client-agnostic, no reqwest/ureq) ------------------------
2682
2683    /// A test-double [`HttpResponse`](crate::http_client::HttpResponse) wrapping a canned JSON body.
2684    /// `json_value`/`text` read the stored body; `body` streams it. This proves a backend can be
2685    /// driven by an arbitrary response that is neither a reqwest nor a ureq type.
2686    struct FakeResponse {
2687        body: String,
2688        headers: crate::http_client::HeaderMap,
2689    }
2690
2691    impl crate::http_client::HttpResponse for FakeResponse {
2692        fn headers(&self) -> &crate::http_client::HeaderMap {
2693            &self.headers
2694        }
2695        fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
2696            Box::new(std::io::Cursor::new(self.body.into_bytes()))
2697        }
2698    }
2699
2700    /// A test-double [`HttpClient`](crate::http_client::HttpClient) that records every requested URL
2701    /// and returns a canned `Box<dyn HttpResponse>`. This is the testability payoff of the trait
2702    /// seam: a backend can be exercised with no network and no concrete client crate.
2703    struct FakeClient {
2704        body: String,
2705        requested: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2706    }
2707
2708    impl crate::http_client::HttpClient for FakeClient {
2709        fn get(
2710            &self,
2711            url: &str,
2712            _headers: &crate::http_client::HeaderMap,
2713            _timeout: Option<std::time::Duration>,
2714        ) -> crate::errors::Result<Box<dyn crate::http_client::HttpResponse>> {
2715            self.requested.lock().unwrap().push(url.to_string());
2716            Ok(Box::new(FakeResponse {
2717                body: self.body.clone(),
2718                headers: crate::http_client::HeaderMap::new(),
2719            }))
2720        }
2721    }
2722
2723    #[test]
2724    fn injected_fake_http_client_drives_a_backend_through_the_trait() {
2725        // The github fetch path reads the release listing through `HttpClient::get` /
2726        // `HttpResponse::json_value`. Inject a `FakeClient` (not reqwest/ureq) via `.http_client(...)`
2727        // and assert (1) the backend parsed the canned body and (2) the fake recorded the URL the
2728        // backend asked for — proving the request actually went through the injected trait object.
2729        use crate::backends::common::RequestConfig;
2730        let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2731        let cfg = RequestConfig {
2732            client: Some(std::sync::Arc::new(FakeClient {
2733                body: release_json("v4.5.6"),
2734                requested: requested.clone(),
2735            })),
2736            ..Default::default()
2737        };
2738        let releases = fetch_all_releases("https://example.test/repos/o/r/releases", &cfg).unwrap();
2739        assert_eq!(releases.len(), 1);
2740        assert_eq!(
2741            releases[0].version(),
2742            "4.5.6",
2743            "the backend parsed the fake client's canned body through the trait"
2744        );
2745        let urls = requested.lock().unwrap();
2746        assert_eq!(urls.len(), 1, "exactly one request was issued");
2747        assert!(
2748            urls[0].contains("/repos/o/r/releases"),
2749            "the fake client recorded the URL the backend requested through the trait, got {:?}",
2750            urls[0]
2751        );
2752    }
2753
2754    /// A test-double client that records the whole `HeaderMap` it was handed, so a test can assert
2755    /// on a header's *flags* (sensitivity) and not just on its rendered text.
2756    struct HeaderCapturingClient(
2757        std::sync::Arc<std::sync::Mutex<Vec<crate::http_client::HeaderMap>>>,
2758    );
2759
2760    impl crate::http_client::HttpClient for HeaderCapturingClient {
2761        fn get(
2762            &self,
2763            _url: &str,
2764            headers: &crate::http_client::HeaderMap,
2765            _timeout: Option<std::time::Duration>,
2766        ) -> crate::errors::Result<Box<dyn crate::http_client::HttpResponse>> {
2767            self.0.lock().unwrap().push(headers.clone());
2768            Ok(Box::new(FakeResponse {
2769                body: "[]".to_string(),
2770                headers: crate::http_client::HeaderMap::new(),
2771            }))
2772        }
2773    }
2774
2775    // A3, end of the line: `insert_header` marks a credential-bearing header sensitive, and that
2776    // marking has to survive the merge `send` performs (`base.insert(name.clone(), value.clone())`)
2777    // to reach the transport -- otherwise the redaction is only skin deep and a client that logs its
2778    // request headers still prints the credential. The value itself must arrive byte-for-byte: this
2779    // is the header `apply_auth` gives PRECEDENCE over the backend's own token, so mangling it would
2780    // silently deauthenticate every user of the override.
2781    #[test]
2782    fn a_user_supplied_authorization_header_reaches_the_transport_intact_and_sensitive() {
2783        use crate::backends::common::RequestConfig;
2784        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2785        let mut cfg = RequestConfig {
2786            // A backend token is configured too, so the precedence rule is exercised rather than
2787            // assumed: the user's header wins and the derived `token backend-token` is not sent.
2788            auth_token: Some("backend-token".to_string()),
2789            auth_base_host: Some("example.test".to_string()),
2790            client: Some(std::sync::Arc::new(HeaderCapturingClient(seen.clone()))),
2791            ..Default::default()
2792        };
2793        cfg.insert_header("Authorization", "Bearer user-supplied-secret");
2794        let _ = fetch_all_releases("https://example.test/repos/o/r/releases", &cfg).unwrap();
2795
2796        let seen = seen.lock().unwrap();
2797        assert_eq!(seen.len(), 1, "exactly one request must have been issued");
2798        let value = seen[0]
2799            .get(crate::http_client::header::AUTHORIZATION)
2800            .expect("the user-supplied Authorization header must reach the transport");
2801        assert_eq!(
2802            value.to_str().unwrap(),
2803            "Bearer user-supplied-secret",
2804            "the user's header wins over the backend token and is sent verbatim"
2805        );
2806        assert!(
2807            value.is_sensitive(),
2808            "the sensitivity marking must survive the header merge, or the credential still shows \
2809             up in a transport's request logging"
2810        );
2811    }
2812
2813    #[test]
2814    fn http_traits_are_object_safe() {
2815        // Compile-time assertion that the seam traits are object-safe: if a non-object-safe method
2816        // (e.g. a generic `json::<T>()`) crept back in, these `Box<dyn ...>` coercions would fail to
2817        // compile. `FakeClient`/`FakeResponse` exercise the dyn coercion concretely.
2818        let _client: Box<dyn crate::http_client::HttpClient> = Box::new(FakeClient {
2819            body: "[]".to_string(),
2820            requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2821        });
2822        let _resp: Box<dyn crate::http_client::HttpResponse> = Box::new(FakeResponse {
2823            body: "[]".to_string(),
2824            headers: crate::http_client::HeaderMap::new(),
2825        });
2826        // Arc<dyn HttpClient> is the injection carrier, so it must also be object-safe.
2827        let _arc: std::sync::Arc<dyn crate::http_client::HttpClient> =
2828            std::sync::Arc::new(FakeClient {
2829                body: "[]".to_string(),
2830                requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2831            });
2832    }
2833
2834    #[test]
2835    fn request_header_is_sent_on_the_wire() {
2836        use crate::backends::common::RequestConfig;
2837        use crate::http_client::header::{HeaderMap, HeaderName, HeaderValue};
2838        let (base, captured) = stub_capturing(|_| {
2839            vec![Resp {
2840                status: "200 OK",
2841                link: None,
2842                body: release_json("v1.0.0"),
2843            }]
2844        });
2845        let mut headers = HeaderMap::new();
2846        headers.insert(
2847            HeaderName::from_static("x-custom"),
2848            HeaderValue::from_static("hello"),
2849        );
2850        let cfg = RequestConfig {
2851            timeout: None,
2852            headers,
2853            ..Default::default()
2854        };
2855        let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2856        assert_eq!(releases.len(), 1);
2857        let request = captured.lock().unwrap()[0].to_lowercase();
2858        assert!(
2859            request.contains("x-custom: hello"),
2860            "custom header missing from request:\n{}",
2861            captured.lock().unwrap()[0]
2862        );
2863    }
2864
2865    #[test]
2866    fn timeout_aborts_an_unresponsive_request() {
2867        use crate::backends::common::RequestConfig;
2868        use std::time::{Duration, Instant};
2869        // Accept the connection but never send a response.
2870        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2871        let base = format!("http://{}", listener.local_addr().unwrap());
2872        std::thread::spawn(move || {
2873            let _held = listener.accept();
2874            std::thread::sleep(Duration::from_secs(5));
2875        });
2876        let cfg = RequestConfig {
2877            timeout: Some(Duration::from_millis(200)),
2878            ..Default::default()
2879        };
2880        let start = Instant::now();
2881        let res = fetch_all_releases(&format!("{base}/releases"), &cfg);
2882        assert!(res.is_err(), "expected a timeout error");
2883        assert!(
2884            start.elapsed() < Duration::from_secs(3),
2885            "request should have timed out quickly, took {:?}",
2886            start.elapsed()
2887        );
2888    }
2889
2890    #[test]
2891    fn retries_recover_from_transient_failures() {
2892        use crate::backends::common::RequestConfig;
2893        // First two attempts fail (503), the third succeeds.
2894        let base = stub(|_| {
2895            vec![
2896                Resp {
2897                    status: "503 Service Unavailable",
2898                    link: None,
2899                    body: "busy".to_string(),
2900                },
2901                Resp {
2902                    status: "503 Service Unavailable",
2903                    link: None,
2904                    body: "busy".to_string(),
2905                },
2906                Resp {
2907                    status: "200 OK",
2908                    link: None,
2909                    body: release_json("v1.0.0"),
2910                },
2911            ]
2912        });
2913        let cfg = RequestConfig {
2914            retries: 2,
2915            ..Default::default()
2916        };
2917        let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2918        assert_eq!(releases.len(), 1);
2919        assert_eq!(releases[0].version(), "1.0.0");
2920    }
2921
2922    #[test]
2923    fn retries_are_exhausted_and_then_error() {
2924        use crate::backends::common::RequestConfig;
2925        // One retry allowed -> two attempts, both 503 -> error.
2926        let base = stub(|_| {
2927            vec![
2928                Resp {
2929                    status: "503 Service Unavailable",
2930                    link: None,
2931                    body: "busy".to_string(),
2932                },
2933                Resp {
2934                    status: "503 Service Unavailable",
2935                    link: None,
2936                    body: "busy".to_string(),
2937                },
2938            ]
2939        });
2940        let cfg = RequestConfig {
2941            retries: 1,
2942            ..Default::default()
2943        };
2944        let res = fetch_all_releases(&format!("{base}/releases"), &cfg);
2945        assert!(res.is_err());
2946    }
2947
2948    #[test]
2949    fn release_list_applies_its_request_config() {
2950        // Confirms `ReleaseList`'s transport setters (here `retries`) flow through `fetch`.
2951        let base = stub(|_| {
2952            vec![
2953                Resp {
2954                    status: "503 Service Unavailable",
2955                    link: None,
2956                    body: "busy".to_string(),
2957                },
2958                Resp {
2959                    status: "200 OK",
2960                    link: None,
2961                    body: release_json("v2.0.0"),
2962                },
2963            ]
2964        });
2965        let releases = super::ReleaseList::configure()
2966            .api_base_url(&base)
2967            .repo_owner("o")
2968            .repo_name("r")
2969            .retries(1)
2970            .build()
2971            .unwrap()
2972            .fetch()
2973            .unwrap()
2974            .into_vec();
2975        assert_eq!(releases.len(), 1);
2976        assert_eq!(releases[0].version(), "2.0.0");
2977    }
2978
2979    // --- unattended() convenience ---------------------------------------------------
2980
2981    #[test]
2982    fn unattended_sets_no_confirm_and_hides_output() {
2983        // Build a config without calling `unattended()` first to confirm the defaults.
2984        let upd_default = super::Update::configure()
2985            .repo_owner("o")
2986            .repo_name("r")
2987            .bin_name("app")
2988            .current_version("0.1.0")
2989            .build()
2990            .unwrap();
2991        assert!(
2992            !upd_default.no_confirm(),
2993            "default no_confirm must be false"
2994        );
2995        assert!(
2996            upd_default.show_output(),
2997            "default show_output must be true"
2998        );
2999
3000        // After `unattended()` both flags flip.
3001        let upd = super::Update::configure()
3002            .repo_owner("o")
3003            .repo_name("r")
3004            .bin_name("app")
3005            .current_version("0.1.0")
3006            .unattended()
3007            .build()
3008            .unwrap();
3009        assert!(upd.no_confirm(), "unattended() must set no_confirm to true");
3010        assert!(
3011            !upd.show_output(),
3012            "unattended() must set show_output to false"
3013        );
3014    }
3015
3016    // `build()` returns a concrete `Update` that is `Send`, so it can move to a worker thread
3017    // (`std::thread::spawn(move || updater.update())`). A regression that made `Update` `!Send`
3018    // (e.g. an `Rc` field) would fail to compile here.
3019    #[test]
3020    fn built_update_is_send() {
3021        fn assert_send<T: Send>() {}
3022        assert_send::<super::Update>();
3023        let upd = super::Update::configure()
3024            .repo_owner("o")
3025            .repo_name("r")
3026            .bin_name("app")
3027            .current_version("0.1.0")
3028            .build()
3029            .unwrap();
3030        // Move it into a thread to exercise the `Send` bound end to end.
3031        std::thread::spawn(move || {
3032            let _ = &upd;
3033        })
3034        .join()
3035        .unwrap();
3036    }
3037
3038    // --- verifying_keys builder setter and accessor --------------------------------
3039
3040    #[cfg(feature = "signatures")]
3041    #[test]
3042    fn builder_stores_verify_keys() {
3043        // A 32-byte zeroed key slice (VerifyingKey = [u8; 32]) is enough to prove the
3044        // setter and accessor wire through.
3045        let key_bytes = [0u8; 32];
3046        let upd = super::Update::configure()
3047            .repo_owner("o")
3048            .repo_name("r")
3049            .bin_name("app")
3050            .current_version("0.1.0")
3051            .verifying_keys([key_bytes])
3052            .build()
3053            .unwrap();
3054        assert_eq!(
3055            upd.verifying_keys().len(),
3056            1,
3057            "verifying_keys() must return the key that was set"
3058        );
3059        assert_eq!(
3060            upd.verifying_keys()[0],
3061            key_bytes,
3062            "returned key bytes must match what was supplied"
3063        );
3064    }
3065
3066    // --- I2: api_headers takes no auth param; I1: api_headers_for uses .expect not .unwrap_or_default ---
3067
3068    #[test]
3069    fn api_headers_takes_no_auth_param_and_sets_user_agent() {
3070        // I2: api_headers() is now a zero-arg function (the unused _auth_token param was removed).
3071        // This test calls it with no arguments -- it would fail to compile against the old
3072        // `api_headers(_auth_token: Option<&str>)` signature.
3073        // The User-Agent assertion ensures a broken implementation cannot silently pass.
3074        let headers = super::api_headers().unwrap();
3075        assert_eq!(
3076            headers
3077                .get(crate::http_client::header::USER_AGENT)
3078                .unwrap()
3079                .to_str()
3080                .unwrap(),
3081            crate::DEFAULT_USER_AGENT,
3082            "api_headers() must set the shared self_update User-Agent"
3083        );
3084        assert!(
3085            headers
3086                .get(crate::http_client::header::AUTHORIZATION)
3087                .is_none(),
3088            "api_headers() must not set an Authorization header"
3089        );
3090    }
3091
3092    #[test]
3093    fn continuation_page_user_agent_header_is_present() {
3094        // I1: the continuation-page header builder must not silently drop the User-Agent.
3095        // Drive a two-page fetch via stub_capturing and assert the second page's request carries
3096        // the User-Agent header -- if api_headers_for() silently returned empty headers (the old
3097        // .unwrap_or_default() path), the User-Agent would be absent on page 2.
3098        let (base, captured) = stub_capturing(|base| {
3099            vec![
3100                Resp {
3101                    status: "200 OK",
3102                    link: Some(format!("{base}/releases?page=2")),
3103                    body: release_json("v2.0.0"),
3104                },
3105                Resp {
3106                    status: "200 OK",
3107                    link: None,
3108                    body: release_json("v1.0.0"),
3109                },
3110            ]
3111        });
3112        let releases = fetch_all_releases(
3113            &format!("{base}/releases"),
3114            &crate::backends::common::RequestConfig::default(),
3115        )
3116        .unwrap();
3117        assert_eq!(releases.len(), 2, "both pages must be fetched");
3118        let requests = captured.lock().unwrap();
3119        assert_eq!(
3120            requests.len(),
3121            2,
3122            "exactly two HTTP requests must be issued"
3123        );
3124        // Both requests must carry the User-Agent header.
3125        let expected_ua = format!("user-agent: {}", crate::DEFAULT_USER_AGENT.to_lowercase());
3126        for (i, req) in requests.iter().enumerate() {
3127            assert!(
3128                req.to_lowercase().contains(&expected_ua),
3129                "page {} request is missing the User-Agent header:\n{}",
3130                i + 1,
3131                req
3132            );
3133        }
3134    }
3135}