diff --git a/CHANGELOG.md b/CHANGELOG.md index a701469..1021a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,16 @@ ### Changed - `ReleaseBuilder::build()` validates that the version parses as bare semver and errors with `Error::SemVer` otherwise (a `v` prefix or a non-semver tag previously built fine and was - silently skipped, or errored opaquely, later in the update pipeline). -- All requests send the same `self_update/` User-Agent when the caller has not set one + silently skipped, or errored opaquely, later in the update pipeline). The github/gitlab/gitea + listings skip releases whose tag is not semver after trimming a leading lowercase `v` -- e.g. + a rolling `nightly` or `latest` tag alongside normal releases -- matching the pre-1.0 behavior + of ignoring them; each skip is logged at `log::debug!` (enable e.g. `env_logger` with + `RUST_LOG=self_update=debug` to see which tags were dropped). Fetching such a tag directly + (`release_tag`) errors with `Error::SemVer` naming the offending tag, with the original parse + failure on the `source()` chain. github's `get_latest_release` uses the API's dedicated + `/releases/latest` endpoint and also errors (naming the tag) if that designated release is not + semver; gitlab/gitea derive "latest" from the listing, skipping unparseable tags. +- All requests send the same `self-update/` User-Agent when the caller has not set one via `request_header`. Previously github sent `rust/self-update` and gitlab/gitea and the standalone `Download` sent `rust-reqwest/self-update` (wrong under the `ureq` client). - `ProgressStyle` is `#[non_exhaustive]`: construct with `ProgressStyle::new(template, chars)` @@ -43,6 +51,9 @@ pipeline prefers the newest semver-compatible one). - The github and gitea `Update` builders set their auth scheme explicitly instead of relying on `AuthScheme::default()` (no behavior change; removes a fragile implicit default). +- The `HttpClient` / `HttpResponse` / `AsyncHttpClient` / `AsyncHttpResponse` docs state the + trait-evolution policy `ReleaseSource` already had: new methods are only added in minor + releases with a default implementation, so custom transports keep compiling. ## [1.0.0-rc.5] Final polish from a full-surface review of rc.4: two breaking `Error`-constructor changes (folded diff --git a/src/backends/common.rs b/src/backends/common.rs index e422b5b..6f48dcf 100644 --- a/src/backends/common.rs +++ b/src/backends/common.rs @@ -52,8 +52,62 @@ impl AuthScheme { } } +/// The boxed inner error of an [`Error::SemVer`] produced from a server-supplied release tag: +/// names the offending tag in its message and keeps the original `semver` parse failure +/// reachable via [`std::error::Error::source`]. +#[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) +)] +#[derive(Debug)] +pub(crate) struct NonSemverTagError { + tag: String, + source: Box, +} + +impl std::fmt::Display for NonSemverTagError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "release tag `{}` is not a semver version: {}", + self.tag, self.source + ) + } +} + +impl std::error::Error for NonSemverTagError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&*self.source) + } +} + +/// Rewrap a semver-validation failure from `Release::builder().build()` so the error names the +/// offending release tag (`nightly`, `latest`, a date, ...) instead of surfacing a bare parse +/// failure with no context. The original parse error stays on the `source()` chain. Non-`SemVer` +/// errors pass through unchanged. +/// +/// Only the forge backends (github/gitlab/gitea) funnel server-supplied tags through the builder; +/// the attribute keeps builds without any of them warning-free. +#[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) +)] +pub(crate) fn name_tag_in_semver_error(tag: &str, err: Error) -> Error { + match err { + Error::SemVer(inner) => Error::SemVer(Box::new(NonSemverTagError { + tag: tag.to_owned(), + source: inner, + })), + other => other, + } +} + /// The lowercased host of a URL, for auth-origin comparison. Parses with `http::Uri` (always /// available, no `url` crate needed). Returns `None` when the URL has no host. +#[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) +)] pub(crate) fn host_of(url: &str) -> Option { url.parse::().ok()?.host().map(|h| { h.trim_start_matches('[') @@ -508,6 +562,39 @@ mod tests { const BAD_PEM_CERT: &[u8] = b"-----BEGIN CERTIFICATE-----\nbm90IGEgdmFsaWQgY2VydA==\n-----END CERTIFICATE-----\n"; + // `name_tag_in_semver_error` names the tag in the message and keeps the original + // `semver::Error` reachable through the `source()` chain (SemVer -> NonSemverTagError -> + // semver::Error), so callers walking the chain still find the parse failure. + #[test] + fn name_tag_in_semver_error_names_tag_and_keeps_source_chain() { + let parse_err = "nightly".parse::().unwrap_err(); + let parse_msg = parse_err.to_string(); + let wrapped = + super::name_tag_in_semver_error("nightly", crate::errors::Error::from(parse_err)); + let crate::errors::Error::SemVer(inner) = &wrapped else { + panic!("expected Error::SemVer, got {wrapped:?}"); + }; + assert!( + inner.to_string().contains("`nightly`"), + "the message must name the tag, got: {inner}" + ); + let chained = inner + .source() + .expect("the original semver parse error must stay on the chain"); + assert_eq!(chained.to_string(), parse_msg); + } + + // Non-SemVer errors pass through `name_tag_in_semver_error` unchanged. + #[test] + fn name_tag_in_semver_error_passes_other_errors_through() { + let err = crate::errors::Error::MissingField { field: "version" }; + let out = super::name_tag_in_semver_error("nightly", err); + assert!( + matches!(out, crate::errors::Error::MissingField { field: "version" }), + "non-SemVer errors must pass through unchanged, got {out:?}" + ); + } + #[test] fn insert_header_records_invalid_value_error() { // The setter is infallible; an invalid *value* (control char) is deferred to `check()` diff --git a/src/backends/gitea.rs b/src/backends/gitea.rs index 39885e4..4d8ba10 100644 --- a/src/backends/gitea.rs +++ b/src/backends/gitea.rs @@ -68,7 +68,9 @@ impl ReleaseDto { if let Some(body) = self.body { builder.body(body); } - builder.build() + builder + .build() + .map_err(|e| crate::backends::common::name_tag_in_semver_error(&tag, e)) } } @@ -476,7 +478,17 @@ fn release_array_page( })?; let mut items = Vec::new(); for dto in dtos { - let release = dto.into_release()?; + let release = match dto.into_release() { + Ok(release) => release, + // A non-semver tag (`nightly`, `latest`, a date tag) is not a release the + // updater can compare; skip it rather than failing the whole listing, so a + // repository mixing rolling tags with semver releases stays updatable. + Err(e @ Error::SemVer(_)) => { + log::debug!("self_update: skipping listed release: {e}"); + continue; + } + Err(e) => return Err(e), + }; // Skip releases not strictly newer than the current version, but do NOT stop // pagination. A backport release (older semver, newer creation date) must not // halt the walk; a genuinely newer release on a later page must still be found. @@ -507,6 +519,8 @@ fn release_array_page( /// Transport-free plan for the newest release: Gitea has no `/releases/latest`, so the listing's /// first element (newest-first order) is "latest". Fetches just the first page (no pagination). +/// Entries with non-semver rolling tags (`nightly`, ...) are skipped like the full listing does, +/// so "newest" is the first entry the updater can actually compare. fn newest_plan(base_url: &str) -> Result> { let headers = api_headers()?; Ok(PageRequest { @@ -517,11 +531,16 @@ fn newest_plan(base_url: &str) -> Result> { serde_json::from_slice(body).map_err(|e| Error::InvalidResponse { source: Box::new(e), })?; - let first = dtos - .into_iter() - .next() - .ok_or_else(|| Error::NoReleaseFound { target: None })?; - Ok(Page::last(vec![first.into_release()?])) + for dto in dtos { + match dto.into_release() { + Ok(release) => return Ok(Page::last(vec![release])), + Err(e @ Error::SemVer(_)) => { + log::debug!("self_update: skipping listed release: {e}"); + } + Err(e) => return Err(e), + } + } + Err(Error::NoReleaseFound { target: None }) }), }) } @@ -619,6 +638,72 @@ mod tests { ); } + // A rolling non-semver tag (`nightly`) in the listing must be skipped, not fail the whole + // fetch: repositories commonly mix rolling tags with semver releases. + #[test] + fn listing_skips_non_semver_tags() { + let req = super::release_array_page( + "https://example.test/releases".to_string(), + crate::http_client::HeaderMap::new(), + None, + ); + let body = releases_json(&["nightly", "v1.2.3", "v1.0.0"]); + let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap(); + let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect(); + assert_eq!( + versions, + vec!["1.2.3", "1.0.0"], + "non-semver tags are skipped; the semver releases survive" + ); + } + + // Gitea's "latest" is the listing's first entry; a rolling tag in that slot must be + // skipped so "newest" is the first release the updater can actually compare. + #[test] + fn newest_plan_skips_non_semver_tags() { + let req = super::newest_plan("https://example.test/releases").unwrap(); + let body = releases_json(&["nightly", "v1.2.3", "v1.0.0"]); + let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap(); + let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect(); + assert_eq!(versions, vec!["1.2.3"]); + } + + // With only non-semver tags there is nothing the updater can compare: NoReleaseFound. + #[test] + fn newest_plan_with_only_non_semver_tags_is_no_release_found() { + let req = super::newest_plan("https://example.test/releases").unwrap(); + let body = releases_json(&["nightly", "latest"]); + let res = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()); + assert!( + matches!( + res, + Err(crate::errors::Error::NoReleaseFound { target: None }) + ), + "an all-rolling-tag listing must yield NoReleaseFound" + ); + } + + // The single-release endpoint cannot skip: a pinned non-semver tag errors, naming the tag. + #[test] + fn single_plan_non_semver_tag_errors_naming_the_tag() { + let req = + super::single_plan("https://example.test/releases/tags/nightly".to_string()).unwrap(); + let res = (req.parse)( + release_obj_json("nightly").as_bytes(), + &crate::http_client::HeaderMap::new(), + ); + match res { + Err(crate::errors::Error::SemVer(e)) => { + assert!( + e.to_string().contains("nightly"), + "the error must name the offending tag, got: {e}" + ); + } + Err(other) => panic!("expected Error::SemVer, got {other:?}"), + Ok(_) => panic!("a non-semver pinned tag must error"), + } + } + use std::io::{Read, Write}; use std::net::TcpListener; @@ -686,7 +771,6 @@ mod tests { /// A bare JSON release object (not wrapped in an array). Gitea's `get_release_version[_async]` /// hits `/tags/{ver}`, which returns a single release object, so this is parsed directly. - #[cfg(feature = "async")] fn release_obj_json(tag: &str) -> String { format!( r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[],"body":null}}"# diff --git a/src/backends/github.rs b/src/backends/github.rs index 0631eeb..faac956 100644 --- a/src/backends/github.rs +++ b/src/backends/github.rs @@ -67,7 +67,9 @@ impl ReleaseDto { if let Some(body) = self.body { builder.body(body); } - builder.build() + builder + .build() + .map_err(|e| crate::backends::common::name_tag_in_semver_error(&tag, e)) } } @@ -479,7 +481,17 @@ fn release_array_page( })?; let mut items = Vec::new(); for dto in dtos { - let release = dto.into_release()?; + let release = match dto.into_release() { + Ok(release) => release, + // A non-semver tag (`nightly`, `latest`, a date tag) is not a release the + // updater can compare; skip it rather than failing the whole listing, so a + // repository mixing rolling tags with semver releases stays updatable. + Err(e @ Error::SemVer(_)) => { + log::debug!("self_update: skipping listed release: {e}"); + continue; + } + Err(e) => return Err(e), + }; // Skip releases not strictly newer than the current version, but do NOT stop // pagination. A backport release (older semver, newer creation date) must not // halt the walk; a genuinely newer release on a later page must still be found. @@ -606,6 +618,126 @@ mod tests { ); } + // A rolling non-semver tag (`nightly`) in the listing must be skipped, not fail the whole + // fetch: repositories commonly mix rolling tags with semver releases. Pre-release/build + // suffixes are valid semver and must NOT be skipped. A capital-`V` prefix is not trimmed + // (only lowercase `v` is), so a `V`-tagged release is skipped like any other unparseable + // tag; this documents the boundary of the trim. + #[test] + fn listing_skips_non_semver_tags() { + let req = super::release_array_page( + "https://example.test/releases".to_string(), + crate::http_client::HeaderMap::new(), + None, + ); + let body = releases_array_json(&[ + "nightly", + "v2.0.0-rc.1+build", + "v1.2.3", + "2024-06-01", + "V1.1.0", + "v1.0.0", + ]); + let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap(); + let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect(); + assert_eq!( + versions, + vec!["2.0.0-rc.1+build", "1.2.3", "1.0.0"], + "non-semver (incl. capital-V) tags are skipped; semver incl. pre-release survives" + ); + } + + // End-to-end over the loopback stub: a first page consisting entirely of non-semver tags + // (with a Link to page 2) must not stop the walk; page 2's release is still collected. + #[test] + fn fetch_continues_past_an_all_non_semver_page() { + let (base, captured) = stub_capturing(|base| { + vec![ + Resp { + status: "200 OK", + link: Some(format!("{base}/repos/o/r/releases?page=2")), + body: releases_array_json(&["nightly", "latest"]), + }, + Resp { + status: "200 OK", + link: None, + body: releases_array_json(&["v3.0.0"]), + }, + ] + }); + let releases = fetch_all_releases( + &format!("{base}/repos/o/r/releases"), + &crate::backends::common::RequestConfig::default(), + ) + .unwrap(); + let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect(); + assert_eq!(versions, vec!["3.0.0"]); + assert_eq!( + captured.lock().unwrap().len(), + 2, + "an all-skipped page 1 must not stop pagination; page 2 must be requested" + ); + } + + // The async driver shares the same parse closures; pin that the skip behaves identically + // through `run_paginated_async`. + #[cfg(feature = "async")] + #[tokio::test] + async fn fetch_async_skips_non_semver_tags() { + let base = stub(|_| { + vec![Resp { + status: "200 OK", + link: None, + body: releases_array_json(&["nightly", "v1.2.3"]), + }] + }); + let releases = fetch_all_releases_async( + &format!("{base}/repos/o/r/releases"), + &crate::backends::common::RequestConfig::default(), + ) + .await + .unwrap(); + let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect(); + assert_eq!(versions, vec!["1.2.3"]); + } + + // The same skip applies on the filtered (`stop_at`) walk used by `get_newer_releases`, + // which feeds `update()`: a rolling tag must not abort an update check. + #[test] + fn filtered_listing_skips_non_semver_tags() { + let req = super::release_array_page( + "https://example.test/releases".to_string(), + crate::http_client::HeaderMap::new(), + Some("1.0.0".to_string()), + ); + let body = releases_array_json(&["nightly", "v1.2.3", "v0.9.0"]); + let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap(); + let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect(); + assert_eq!(versions, vec!["1.2.3"]); + } + + // The single-release endpoints cannot skip: a pinned non-semver tag errors, and the error + // must name the offending tag rather than surfacing a bare semver parse failure. + #[test] + fn single_plan_non_semver_tag_errors_naming_the_tag() { + let req = + super::single_plan("https://example.test/releases/tags/nightly".to_string()).unwrap(); + let res = (req.parse)( + release_obj_json("nightly").as_bytes(), + &crate::http_client::HeaderMap::new(), + ); + match res { + Err(crate::errors::Error::SemVer(e)) => { + assert!( + e.to_string().contains("nightly"), + "the error must name the offending tag, got: {e}" + ); + } + Err(other) => panic!("expected Error::SemVer, got {other:?}"), + Ok(_) => panic!("a non-semver pinned tag must error"), + } + } + /// Test wrapper: drive the sans-io `releases_plan` through the sync `run_paginated` driver. /// `stop_at = None` => walk all pages (the unfiltered listing behavior). fn fetch_all_releases( diff --git a/src/backends/gitlab.rs b/src/backends/gitlab.rs index 7d9dce0..3ba4ed4 100644 --- a/src/backends/gitlab.rs +++ b/src/backends/gitlab.rs @@ -76,7 +76,9 @@ impl ReleaseDto { if let Some(body) = self.description { builder.body(body); } - builder.build() + builder + .build() + .map_err(|e| crate::backends::common::name_tag_in_semver_error(&tag, e)) } } @@ -469,7 +471,17 @@ fn release_array_page( })?; let mut items = Vec::new(); for dto in dtos { - let release = dto.into_release()?; + let release = match dto.into_release() { + Ok(release) => release, + // A non-semver tag (`nightly`, `latest`, a date tag) is not a release the + // updater can compare; skip it rather than failing the whole listing, so a + // repository mixing rolling tags with semver releases stays updatable. + Err(e @ Error::SemVer(_)) => { + log::debug!("self_update: skipping listed release: {e}"); + continue; + } + Err(e) => return Err(e), + }; // Skip releases not strictly newer than the current version, but do NOT stop // pagination. A backport release (older semver, newer creation date) must not // halt the walk; a genuinely newer release on a later page must still be found. @@ -500,6 +512,8 @@ fn release_array_page( /// Transport-free plan for the newest release: GitLab has no `/releases/latest`, so the listing's /// first element (newest-first order) is "latest". Fetches just the first page (no pagination). +/// Entries with non-semver rolling tags (`nightly`, ...) are skipped like the full listing does, +/// so "newest" is the first entry the updater can actually compare. fn newest_plan(base_url: &str) -> Result> { let headers = api_headers()?; Ok(PageRequest { @@ -510,11 +524,16 @@ fn newest_plan(base_url: &str) -> Result> { serde_json::from_slice(body).map_err(|e| Error::InvalidResponse { source: Box::new(e), })?; - let first = dtos - .into_iter() - .next() - .ok_or_else(|| Error::NoReleaseFound { target: None })?; - Ok(Page::last(vec![first.into_release()?])) + for dto in dtos { + match dto.into_release() { + Ok(release) => return Ok(Page::last(vec![release])), + Err(e @ Error::SemVer(_)) => { + log::debug!("self_update: skipping listed release: {e}"); + } + Err(e) => return Err(e), + } + } + Err(Error::NoReleaseFound { target: None }) }), }) } @@ -610,6 +629,71 @@ mod tests { ); } + // A rolling non-semver tag (`nightly`) in the listing must be skipped, not fail the whole + // fetch: repositories commonly mix rolling tags with semver releases. + #[test] + fn listing_skips_non_semver_tags() { + let req = super::release_array_page( + "https://example.test/releases".to_string(), + crate::http_client::HeaderMap::new(), + None, + ); + let body = releases_json(&["nightly", "v1.2.3", "v1.0.0"]); + let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap(); + let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect(); + assert_eq!( + versions, + vec!["1.2.3", "1.0.0"], + "non-semver tags are skipped; the semver releases survive" + ); + } + + // GitLab's "latest" is the listing's first entry; a rolling tag in that slot must be + // skipped so "newest" is the first release the updater can actually compare. + #[test] + fn newest_plan_skips_non_semver_tags() { + let req = super::newest_plan("https://example.test/releases").unwrap(); + let body = releases_json(&["nightly", "v1.2.3", "v1.0.0"]); + let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap(); + let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect(); + assert_eq!(versions, vec!["1.2.3"]); + } + + // With only non-semver tags there is nothing the updater can compare: NoReleaseFound. + #[test] + fn newest_plan_with_only_non_semver_tags_is_no_release_found() { + let req = super::newest_plan("https://example.test/releases").unwrap(); + let body = releases_json(&["nightly", "latest"]); + let res = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()); + assert!( + matches!( + res, + Err(crate::errors::Error::NoReleaseFound { target: None }) + ), + "an all-rolling-tag listing must yield NoReleaseFound" + ); + } + + // The single-release endpoint cannot skip: a pinned non-semver tag errors, naming the tag. + #[test] + fn single_plan_non_semver_tag_errors_naming_the_tag() { + let req = super::single_plan("https://example.test/releases/nightly".to_string()).unwrap(); + let res = (req.parse)( + release_obj_json("nightly").as_bytes(), + &crate::http_client::HeaderMap::new(), + ); + match res { + Err(crate::errors::Error::SemVer(e)) => { + assert!( + e.to_string().contains("nightly"), + "the error must name the offending tag, got: {e}" + ); + } + Err(other) => panic!("expected Error::SemVer, got {other:?}"), + Ok(_) => panic!("a non-semver pinned tag must error"), + } + } + // ----------------------------------------------------------------------- // Shared loopback stub infrastructure (sync and async tests both use this) // ----------------------------------------------------------------------- diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 164890d..6971db2 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -26,6 +26,12 @@ pub mod s3; /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link /// header values may contain multiple values separated by commas /// `Link: ; rel="next", ; rel="next"` +// Only the forge backends walk Link-header pagination; the attribute keeps builds without any of +// them (custom/s3-only) warning-free without cfg-gating shared code out of existence. +#[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) +)] pub(crate) fn find_rel_next_link(link_str: &str) -> Option<&str> { for link in link_str.split(',') { let mut uri = None; @@ -54,12 +60,32 @@ pub(crate) fn find_rel_next_link(link_str: &str) -> Option<&str> { /// Maximum number of `Link: rel="next"` pages walked when listing releases — a safety bound /// against pathological release histories. +// The pagination machinery below is used by the listing backends (github/gitlab/gitea/s3); the +// attributes keep a backend-less build (custom only) warning-free. +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) const MAX_RELEASE_PAGES: usize = 100; /// Maximum number of bytes buffered for a single listing-page body. A per-page COUNT bound /// ([`MAX_RELEASE_PAGES`]) was already present; this per-page SIZE bound prevents a pathological /// or misconfigured endpoint from forcing unbounded memory use. Real GitHub/GitLab/Gitea release /// listing pages with 100 entries and many assets are well under 1 MiB; 4 MiB is a generous cap. +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) const MAX_LISTING_BODY_BYTES: usize = 4 * 1024 * 1024; // 4 MiB /// A sans-io description of a single page request: *what* to fetch (`url` + `headers`) and *how* @@ -69,6 +95,15 @@ pub(crate) const MAX_LISTING_BODY_BYTES: usize = 4 * 1024 * 1024; // 4 MiB /// /// `parse` is `+ Send` so [`run_paginated_async`]'s future stays `Send` (it is held across the /// await in the async driver). +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) struct PageRequest { pub url: String, pub headers: http_client::HeaderMap, @@ -80,6 +115,15 @@ pub(crate) struct PageRequest { /// The parsed result of one [`PageRequest`]: the page's `items`, the optional `next` page request, /// and an early-`stop` flag. The driver appends `items`, then stops if `stop` is set, `next` is /// `None`, or the [`MAX_RELEASE_PAGES`] bound is hit. +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) struct Page { pub items: Vec, pub next: Option>, @@ -88,6 +132,12 @@ pub(crate) struct Page { impl Page { /// A terminal single-page result: these `items`, no next page, no early stop. + // Only the forge backends' single-release plans build terminal pages; s3 follows + // continuation tokens via `next` instead. + #[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) + )] pub(crate) fn last(items: Vec) -> Self { Self { items, @@ -103,6 +153,15 @@ impl Page { /// once, call `parse`, extend the accumulator, then stop if `page.stop`, `page.next` is `None`, or /// the [`MAX_RELEASE_PAGES`] bound is reached (logging a warning if a further page was still /// advertised at the bound). +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) fn run_paginated( first: PageRequest, config: &common::RequestConfig, @@ -160,6 +219,15 @@ pub(crate) fn run_paginated( /// transport. Reuses [`send_async`]'s retry/backoff machinery; reads the body bytes via the async /// response trait, then calls the same `parse` closure. #[cfg(feature = "async")] +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) async fn run_paginated_async( first: PageRequest, config: &common::RequestConfig, @@ -216,6 +284,10 @@ pub(crate) async fn run_paginated_async( /// Build the first-page request URL, defaulting the page size to 100 — unless the base URL /// already carries query parameters (e.g. a `Link`-header "next" URL), in which case it is used /// verbatim so an existing `page`/`per_page` is not clobbered. +#[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) +)] pub(crate) fn first_page_url(base_url: &str) -> String { if base_url.contains('?') { base_url.to_owned() @@ -225,6 +297,10 @@ pub(crate) fn first_page_url(base_url: &str) -> String { } /// Extract the `rel="next"` URL from a response's `Link` header(s), if present. +#[cfg_attr( + not(any(feature = "github", feature = "gitlab", feature = "gitea")), + allow(dead_code) +)] pub(crate) fn next_link(headers: &http_client::HeaderMap) -> Option { headers .get_all(http_client::header::LINK) @@ -315,6 +391,15 @@ where /// Issue a GET request, merging the per-request transport `config` (extra headers + timeout) /// on top of the supplied `base` headers, retrying a failed request up to `config.retries` /// times with exponential backoff. +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) fn send( url: &str, mut base: http_client::HeaderMap, @@ -359,6 +444,15 @@ pub(crate) fn send( /// Async sibling of [`send`]: issue a GET, merging the per-request transport `config` on top of /// `base`, retrying up to `config.retries` times with `tokio::time::sleep` backoff. #[cfg(feature = "async")] +#[cfg_attr( + not(any( + feature = "github", + feature = "gitlab", + feature = "gitea", + feature = "s3" + )), + allow(dead_code) +)] pub(crate) async fn send_async( url: &str, mut base: http_client::HeaderMap, diff --git a/src/backends/s3.rs b/src/backends/s3.rs index 72067cc..8954702 100644 --- a/src/backends/s3.rs +++ b/src/backends/s3.rs @@ -14,6 +14,8 @@ use quick_xml::events::Event; use regex::Regex; use std::path::PathBuf; use std::sync::LazyLock; +// `Duration` only appears in the `s3-auth`-gated presigning surface (`signature_ttl`). +#[cfg(feature = "s3-auth")] use std::time::Duration; /// Filename -> `(name, version)` matcher for S3 asset keys, e.g. `myapp-v1.2.3-x86_64-linux`. diff --git a/src/http_client/mod.rs b/src/http_client/mod.rs index 85c6e8e..af0d09a 100644 --- a/src/http_client/mod.rs +++ b/src/http_client/mod.rs @@ -28,6 +28,9 @@ pub use ureq::UreqClient; /// Object safety is what makes the transport injectable: a user can hand the crate any /// `Arc` (e.g. a test double or a wrapper around a custom client). Retries are /// **not** part of this trait — they stay in `backends::send`/`retry`, wrapping `client.get(...)`. +/// +/// This trait is implemented by consumers, so new methods will only be added in minor releases +/// with a default implementation; existing implementations keep compiling. pub trait HttpClient: Send + Sync { /// Issue a GET to `url` with the given `headers` and optional per-request `timeout`, returning /// the response (already status-checked) as a boxed [`HttpResponse`]. @@ -45,6 +48,10 @@ pub trait HttpClient: Send + Sync { /// sibling [`body_buffered`](Self::body_buffered)) consume `self: Box`, so single-use is /// enforced at the type level. A custom transport implements `headers` + `body`; the crate parses /// JSON/XML from the reader itself. There are no generic methods, so the trait stays object-safe. +/// +/// This trait is implemented by consumers, so new methods will only be added in minor releases +/// with a default implementation (as `body_buffered` already is); existing implementations keep +/// compiling. pub trait HttpResponse { /// The response headers. fn headers(&self) -> &HeaderMap; @@ -66,6 +73,9 @@ pub trait HttpResponse { /// The signature types from foreign crates (`BoxFuture`, `BoxStream`, `Bytes`) are re-exported at /// the crate root (`self_update::futures_util`, `self_update::bytes`), so an implementation does /// not need them as direct dependencies. +/// +/// This trait is implemented by consumers, so new methods will only be added in minor releases +/// with a default implementation; existing implementations keep compiling. #[cfg(feature = "async")] #[cfg_attr(docsrs, doc(cfg(feature = "async")))] pub trait AsyncHttpClient: Send + Sync { @@ -80,6 +90,9 @@ pub trait AsyncHttpClient: Send + Sync { /// Async sibling of [`HttpResponse`]. Drives the streamed download (`bytes_stream`) instead of /// leaking a concrete `reqwest::Response`. +/// +/// This trait is implemented by consumers, so new methods will only be added in minor releases +/// with a default implementation; existing implementations keep compiling. #[cfg(feature = "async")] #[cfg_attr(docsrs, doc(cfg(feature = "async")))] pub trait AsyncHttpResponse: Send { diff --git a/src/lib.rs b/src/lib.rs index 13a6867..9a960b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -573,7 +573,7 @@ use http_client::header; /// The User-Agent sent on the crate's own requests (API listings and downloads) when the caller /// has not set one via `request_header`. One shared value so every backend and the standalone /// [`Download`] identify themselves the same way regardless of the compiled HTTP client. -pub(crate) const DEFAULT_USER_AGENT: &str = concat!("self_update/", env!("CARGO_PKG_VERSION")); +pub(crate) const DEFAULT_USER_AGENT: &str = concat!("self-update/", env!("CARGO_PKG_VERSION")); #[cfg(feature = "progress-bar")] pub(crate) const DEFAULT_PROGRESS_TEMPLATE: &str = diff --git a/src/macros.rs b/src/macros.rs index b9154fb..62b2231 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -481,6 +481,12 @@ macro_rules! impl_common_builder_setters { /// latest available release is used. (Note that the `{{ version }}` substitution in /// [`bin_path_in_archive`](Self::bin_path_in_archive) is still the bare semver with any /// leading `v` stripped, regardless of what is passed here.) + /// + /// The tag must resolve to a semver version after stripping a leading `v`: pinning a + /// rolling tag like `nightly` or a date tag fails at update time with an + /// [`Error::SemVer`](crate::errors::Error::SemVer) naming the tag. (In release + /// *listings* such tags are skipped instead, so a repo mixing rolling and versioned + /// releases stays updatable.) pub fn release_tag(&mut self, ver: impl Into) -> &mut Self { self.common.release_tag = Some(ver.into()); self diff --git a/src/update.rs b/src/update.rs index 68e22d5..78aaee4 100644 --- a/src/update.rs +++ b/src/update.rs @@ -262,10 +262,12 @@ impl ReleaseBuilder { /// Validate and build the [`Release`]. /// /// Errors with [`Error::MissingField`] if `version` was not set, and with [`Error::SemVer`] if - /// it is not a bare semver string (e.g. a leading `v`, or a non-semver tag). Validating here - /// gives a clear error at construction time; an unparseable version stored in a `Release` would - /// otherwise surface only later, as a silently-skipped release or an opaque comparison error - /// inside the update pipeline. + /// it is not a bare semver string (e.g. a leading `v`, or a non-semver tag). For a custom + /// [`ReleaseSource`], validating here gives a clear error at construction time; an unparseable + /// version stored in a `Release` would otherwise surface only later, as a silently-skipped + /// release or an opaque comparison error inside the update pipeline. (The built-in forge + /// backends intentionally skip non-semver tags when parsing their *listings* — that skip + /// happens at the listing layer, before each `Release` is constructed through this builder.) pub fn build(&self) -> Result { let version = self .version @@ -844,6 +846,14 @@ pub trait ReleaseUpdate: UpdateConfig + UpdateInternals { /// [`get_newer_releases`](Self::get_newer_releases), whose list is filtered to strictly-newer /// releases (there, `.latest()` is `None` when up to date and any present entry is a genuine /// update). + /// + /// How "newest" treats a non-semver rolling tag (`nightly`, `latest`, a date) differs per + /// backend: gitlab and gitea read the first page of the listing and skip unparseable tags, + /// returning the first release the updater can compare; github uses the API's dedicated + /// `/releases/latest` endpoint, which returns one designated release — if *that* release's + /// tag is not semver, this errors with [`Error::SemVer`](crate::errors::Error::SemVer) naming + /// the tag. Prefer [`get_newer_releases`](Self::get_newer_releases) for the skip-enabled path + /// that behaves identically on every backend. fn get_latest_release(&self) -> Result; /// Fetch the candidate releases from the backend as a [`Releases`] (newest-first, carrying the @@ -852,6 +862,13 @@ pub trait ReleaseUpdate: UpdateConfig + UpdateInternals { /// The list is filtered to releases strictly newer than the configured current version, so it /// is empty (`.latest()` is `None`) when already up to date, and any entry present is a genuine /// update. + /// + /// Releases whose tag is not a semver version after stripping a leading lowercase `v` (e.g. a + /// rolling `nightly` or `latest` tag) are skipped: the updater cannot compare them. Each skip + /// is logged at `log::debug!` level — hook up a logger (e.g. `env_logger` with + /// `RUST_LOG=self_update=debug`) to see which tags were dropped. A listing with *no* + /// parseable release behaves as an empty listing + /// ([`Error::NoReleaseFound`](crate::errors::Error::NoReleaseFound) on the update path). fn get_newer_releases(&self) -> Result; /// Fetch details of the release matching the specified version