Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` 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/<version>` 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)`
Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions src/backends/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error + Send + Sync>,
}

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<String> {
url.parse::<http::Uri>().ok()?.host().map(|h| {
h.trim_start_matches('[')
Expand Down Expand Up @@ -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::<semver::Version>().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()`
Expand Down
100 changes: 92 additions & 8 deletions src/backends/gitea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<PageRequest<Release>> {
let headers = api_headers()?;
Ok(PageRequest {
Expand All @@ -517,11 +531,16 @@ fn newest_plan(base_url: &str) -> Result<PageRequest<Release>> {
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 })
}),
})
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}}"#
Expand Down
Loading
Loading