From c793ed28d43aba4c5ca1cdabbc63bc20e0b9a690 Mon Sep 17 00:00:00 2001 From: James Kominick Date: Fri, 17 Jul 2026 17:52:43 -0400 Subject: [PATCH] feat: add `manifest` backend for static JSON release manifests - new `manifest` feature (no new deps) gating `backends::manifest`: `ManifestSource` implements `ReleaseSource` (and `AsyncReleaseSource` under `async`), fetching a `manifest.json` from any static file server and mapping it through the public `ReleaseBuilder`. (#74) - schema 1 is the only accepted version; any other `schema` value fails with `Error::InvalidResponse` naming the version. Unknown fields are ignored for forward compatibility. - relative asset urls resolve against the manifest URL's directory; absolute urls pass through. Non-semver `version` entries are skipped with a debug log (same as the forge backends). Asset `digest` values map to `ReleaseAsset::digest()` and plug into release-digest verification. - `manifest::Update::configure()` facade mirrors the other backends' builders, with sync and `AsyncUpdate` variants; transport setters apply to both the manifest fetch and the asset download. - new spec specs/ref-manifest-backend.md; example examples/manifest.rs; the `manifest` feature added to all Makefile CI lanes. --- CHANGELOG.md | 9 + Cargo.toml | 8 + Makefile | 8 +- README.md | 38 +- examples/manifest.rs | 34 + specs/README.md | 1 + specs/ref-manifest-backend.md | 218 ++++++ src/backends/manifest.rs | 1221 +++++++++++++++++++++++++++++++++ src/backends/mod.rs | 8 +- src/lib.rs | 41 +- 10 files changed, 1570 insertions(+), 16 deletions(-) create mode 100644 examples/manifest.rs create mode 100644 specs/ref-manifest-backend.md create mode 100644 src/backends/manifest.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7352d7f..a8cbb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,15 @@ writability before the download; only a definite `PermissionDenied` refusal errors, indeterminate results proceed. The install step also raises this error on a permission failure, always naming the path. ([#112](https://github.com/jaemk/self_update/issues/112)) +- `backends::manifest` (`manifest` feature): fetch and install releases from a static + `manifest.json` served by any HTTP endpoint, with no forge-specific API. `ManifestSource` + implements `ReleaseSource` (and `AsyncReleaseSource` under `async`); the facade + `Update::configure()` wraps it with the standard update pipeline. Release entries with a + non-semver `version` are skipped with a debug log. Relative asset `url` values resolve against + the manifest URL's directory (truncated at the last `/`). An asset `digest` field + (`sha256:`) maps to `ReleaseAsset::digest()` and plugs into the existing release-digest + verification path (`checksums` feature). No new dependencies. + ([#74](https://github.com/jaemk/self_update/issues/74)) ### Changed - A recognized-but-unsupported compression extension now fails loudly instead of silently diff --git a/Cargo.toml b/Cargo.toml index aa5532e..fb37fe6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ features = [ "github", "gitlab", "gitea", + "manifest", "s3", ] rustdoc-args = ["--cfg", "docsrs"] @@ -91,6 +92,9 @@ progress-bar = ["dep:indicatif"] github = [] gitlab = [] gitea = [] +# Service-agnostic backend: update from a static JSON release manifest served over HTTP(S). Uses +# the already-present serde/serde_json deps, so it pulls in nothing new. +manifest = [] s3 = ["dep:quick-xml"] # Async update API (tokio-only, reqwest-only). Adds `*_async` verbs alongside the unchanged @@ -131,6 +135,10 @@ required-features = ["gitea"] name = "s3" required-features = ["s3"] +[[example]] +name = "manifest" +required-features = ["manifest"] + [[example]] name = "embedded_key" required-features = ["github", "signatures"] diff --git a/Makefile b/Makefile index 3364be2..87275af 100644 --- a/Makefile +++ b/Makefile @@ -24,16 +24,16 @@ ARCHIVE_FEATURES = archive-tar \ checksums \ s3-auth # Full feature set for the default `reqwest` client: -REQWEST_FEATURES = github gitlab gitea s3 $(ARCHIVE_FEATURES) +REQWEST_FEATURES = github gitlab gitea manifest s3 $(ARCHIVE_FEATURES) # Full feature set for the `ureq` client (needs `--no-default-features`): -UREQ_FEATURES = ureq native-tls github gitlab gitea s3 $(ARCHIVE_FEATURES) +UREQ_FEATURES = ureq native-tls github gitlab gitea manifest s3 $(ARCHIVE_FEATURES) # Full reqwest feature set plus the async API (reqwest-only): -ASYNC_FEATURES = async github gitlab gitea s3 $(ARCHIVE_FEATURES) +ASYNC_FEATURES = async github gitlab gitea manifest s3 $(ARCHIVE_FEATURES) # The backends, one runnable example each. NOTE: unlike a typical example, # running one performs a REAL self-update (network + replaces the binary), so # the `examples` goals BUILD them rather than run them. -SELF_UPDATE_EXAMPLES = github gitlab gitea s3 custom embedded_key +SELF_UPDATE_EXAMPLES = github gitlab gitea manifest s3 custom embedded_key SELF_UPDATE_EXAMPLE_TARGETS = $(addprefix examples/, $(SELF_UPDATE_EXAMPLES)) EXAMPLE_TARGETS = examples $(SELF_UPDATE_EXAMPLE_TARGETS) diff --git a/README.md b/README.md index 5b42f39..32d21dc 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ `self_update` provides updaters for updating rust executables in-place from various release distribution backends. -Supported backends: **GitHub**, **GitLab**, **Gitea**, and **S3** (Amazon S3, Google GCS, -DigitalOcean Spaces, or any S3-compatible endpoint). Each exposes the same `Update` -(configure -> build -> update) and `ReleaseList` builder API. +Supported backends: **GitHub**, **GitLab**, **Gitea**, **S3** (Amazon S3, Google GCS, +DigitalOcean Spaces, or any S3-compatible endpoint), and **Manifest** (any static file server). +The forge and S3 backends each expose a `ReleaseList` builder alongside the `Update` +(configure -> build -> update) API; the manifest backend exposes `Update` only. ## Quick start @@ -78,6 +79,7 @@ The following are opt-in; activate the one(s) your release files need: * `gitea`: the Gitea Releases backend; * `s3`: the S3-compatible backend (Amazon S3, GCS, DigitalOcean Spaces, etc.); * `s3-auth`: sign S3 requests (AWS SigV4) for private buckets; implies `s3`; +* `manifest`: the static-file manifest backend; fetches releases from a `manifest.json` served by any HTTP endpoint; no new dependencies; * `archive-tar`: support for _tar_ archive format; * `archive-zip`: support for _zip_ archive format; * `compression-tar-gz`: support for _gzip_ compression (`.tar.gz`, `.tgz`, plain `.gz`); @@ -88,7 +90,7 @@ The following are opt-in; activate the one(s) your release files need: * `checksums`: verify a downloaded artifact against a SHA-256/SHA-512 checksum before installing it -- automatically against the digest github publishes per release asset, and/or against a known checksum you pass in (e.g. from a `SHA256SUMS` file); see [Checksum verification](#checksum-verification) below; * `async`: add async (`*_async`) update methods alongside the unchanged blocking API; tokio-only, requires `reqwest` (ureq and reqwest can coexist -- reqwest serves the async path, and the sync API prefers reqwest when both are present); see [Async](#async) below. -`github` is the only backend in the default feature set. The S3 backend requires the `s3` feature; `s3-auth` implies `s3`. `gitlab` and `gitea` each require their own feature. +`github` is the only backend in the default feature set. The S3 backend requires the `s3` feature; `s3-auth` implies `s3`. `gitlab`, `gitea`, and `manifest` each require their own feature. ### Example @@ -130,6 +132,27 @@ fn update() -> Result<(), Box> { } ``` +The `manifest` backend (`manifest` feature) serves releases from a `manifest.json` file hosted +on any static file server. The tool author publishes the manifest at a stable URL; assets may be +absolute URLs or relative paths resolved against that URL. Asset `digest` fields (`sha256:`) +plug into the existing checksum verification path when the `checksums` feature is on. See +`specs/ref-manifest-backend.md` for the full schema. + +```rust +use self_update::cargo_crate_version; + +fn update() -> Result<(), Box> { + let status = self_update::backends::manifest::Update::configure() + .manifest_url("https://example.net/releases/manifest.json") + .bin_name("app") + .current_version(cargo_crate_version!()) + .build()? + .update()?; + println!("Manifest update status: `{}`!", status.version()); + Ok(()) +} +``` + Separate utilities are also exposed (**NOTE**: the following example extracts a `.tar.gz`, which _requires_ both the `archive-tar` and `compression-tar-gz` features -- `archive-tar` reads the tar archive and `compression-tar-gz` decodes the gzip layer; see the [features](#features) section @@ -364,13 +387,18 @@ so they are reached through their backend modules rather than re-exported at the * `backends::gitea::ReleaseList` * `backends::s3::ReleaseList` +The `manifest` backend has no separate `ReleaseList` struct. Its `ManifestSource` is a +`ReleaseSource` implementation that can be used directly, or listing can be driven through the +inherent verbs (`get_latest_release`, `get_newer_releases`, `is_update_available`) on a built +`manifest::Update`. + The custom backend has no `ReleaseList` by design: listing is performed entirely by your `ReleaseSource` (or `AsyncReleaseSource`) implementation, which already returns `Release` values directly. ### Custom backends -To update from a host the built-in backends (`github`, `gitlab`, `gitea`, `s3`) don't cover — +To update from a host the built-in backends (`github`, `gitlab`, `gitea`, `s3`, `manifest`) don't cover — another forge, a private artifact registry, a plain HTTP directory — implement the `ReleaseSource` trait and drive a full update through the `backends::custom` backend, which reuses the crate's compare → select-asset → download → verify → extract → install flow. Only diff --git a/examples/manifest.rs b/examples/manifest.rs new file mode 100644 index 0000000..f7d92ee --- /dev/null +++ b/examples/manifest.rs @@ -0,0 +1,34 @@ +/*! +Example updating an executable to the latest version described by a static JSON release manifest. + +`cargo run --example manifest --features "manifest archive-tar compression-tar-gz"` + +Point `manifest_url` at a JSON document served over HTTP(S) that lists your releases and their +assets (the release artifacts live alongside it on the same static host, or at absolute URLs). No +server-side API is needed — regenerate the manifest at release time. See the +`self_update::backends::manifest` module docs for the schema. +*/ + +use self_update::cargo_crate_version; + +fn run() -> Result<(), Box> { + let mut builder = self_update::backends::manifest::Update::configure(); + builder + .manifest_url("https://example.net/releases/manifest.json") + .bin_name("myapp") + .show_download_progress(true) + //.release_tag("v9.9.10") + //.no_confirm(true) + .current_version(cargo_crate_version!()); + + let status = builder.build()?.update()?; + println!("Update status: `{}`!", status.version()); + Ok(()) +} + +pub fn main() { + if let Err(e) = run() { + println!("[ERROR] {}", e); + ::std::process::exit(1); + } +} diff --git a/specs/README.md b/specs/README.md index 7e5d86b..5ecad85 100644 --- a/specs/README.md +++ b/specs/README.md @@ -49,6 +49,7 @@ design before it can be built). Keep each row's status current with `spec.py set | Corporate Network Config | pending | [corporate-network-config.md](corporate-network-config.md) | | Restart After Update | done | [ref-restart.md](ref-restart.md) | | Update-check Interval Guard | done | [ref-check-interval.md](ref-check-interval.md) | +| Manifest Backend | done | [ref-manifest-backend.md](ref-manifest-backend.md) | ## Conventions diff --git a/specs/ref-manifest-backend.md b/specs/ref-manifest-backend.md new file mode 100644 index 0000000..f511a9a --- /dev/null +++ b/specs/ref-manifest-backend.md @@ -0,0 +1,218 @@ +# Manifest backend (reference) + +Status: pending + +## Scope + +The manifest backend in `src/backends/manifest.rs`, gated on the `manifest` Cargo feature. It +provides a service-agnostic release backend: the tool author publishes a `manifest.json` at any +stable HTTPS URL (a GitHub Pages site, an S3 bucket, a CDN, a plain nginx directory -- any static +file server), and the updater fetches it to list releases. No forge API, no platform-specific +bucket listing, no new dependencies. This file documents the intended behavior as a canonical +reference for the implementation. + +## Behavior + +### Builders + +Two public items gated on the `manifest` feature: + +- `ManifestSource` / `ManifestSourceBuilder`: a concrete `ReleaseSource` (and `AsyncReleaseSource` + under the `async` feature) that fetches and parses a `manifest.json` from a given URL using the + crate's HTTP clients, honoring `RequestConfig` (timeout, `request_header`, retries). It can be + used directly with `backends::custom::Update` when custom pipeline control is needed (e.g. + combining a manifest source with a bespoke asset-matcher or verify hook). +- `Update` / `UpdateBuilder`: the all-in-one facade. `Update::configure()` returns an + `UpdateBuilder`. The backend setters are: `manifest_url` (required). The shared common surface + is provided by `impl_common_builder_setters!()`: `bin_name`, `current_version`, `target`, + `timeout`, `retries`, `request_header`, `show_download_progress`, `no_confirm`, `show_output`, + `auth_token`, `verify_checksum`, `verify_release_digest`, `asset_matcher`, `verify`, `checksum`, + `reqwest_client`, `reqwest_async_client`, `ureq_agent`, `http_client`, `http_client_async`, + and their shared accessors. `build()` validates `request.check()` (surfacing a deferred + `request_header` parse error as `Error::InvalidHeader`), then requires `manifest_url` + (`Error::MissingField { field: "manifest_url" }`), then calls `common.build()` (which requires + `current_version` and `bin_name`). `build_async()` (feature `async`) returns the async wrapper. + The concrete `Update` is `Send` and exposes the inherent verbs (`update`, `update_extended`, + `get_latest_release`, `get_newer_releases`, `get_release_version`, `is_update_available`), so + `.build()?.update()?` needs no trait import. + +### Manifest schema + +The canonical schema (version 1): + +```json +{ + "schema": 1, + "releases": [ + { + "version": "1.2.3", + "date": "2026-07-16T00:00:00Z", + "notes_url": "https://example.net/releases/1.2.3", + "assets": [ + { + "name": "app-1.2.3-x86_64-unknown-linux-gnu.tar.gz", + "url": "app-1.2.3-x86_64-unknown-linux-gnu.tar.gz", + "digest": "sha256:..." + } + ] + } + ] +} +``` + +Field semantics, top level: + +- `schema` (integer, required): the schema version. Currently must be exactly `1`. A manifest + whose `schema` field is absent, of the wrong type, or any value other than `1` is rejected with + `Error::InvalidResponse` naming the schema version. +- `releases` (array, required): the list of releases; may be empty (yields `Error::NoReleaseFound` + from the update path). + +Field semantics, per release entry: + +- `version` (string, required): a semver version string. An entry whose `version` is not valid + semver is skipped with a debug log and does not appear in the returned release list (same + behavior as the forge backends skipping non-semver tags). +- `date` (string, optional): an ISO-8601 / RFC-3339 datetime string; maps to `Release::date`. + Absent or unparseable values are treated as absent (the field is `None`). +- `notes_url` (string, optional): a URL for the release notes page; maps to + `Release::release_notes_url()`. Absent when not set. +- `assets` (array, required per entry): zero or more asset objects. + +Field semantics, per asset: + +- `name` (string, required): the asset filename; maps to `ReleaseAsset::name()`. +- `url` (string, required): the asset download URL; may be absolute or relative. See URL + resolution below. +- `digest` (string, optional): a content digest in `algorithm:hex` form. The value is mapped + verbatim to `ReleaseAsset::digest()` and plugs into the existing release-digest verification + path (the same check the github backend uses when the `checksums` feature is on; see + `ref-signatures-and-checksums.md`). The verification layer supports `sha256:` and `sha512:`; + an unsupported algorithm errors at verify time rather than being silently skipped, so a digest + the manifest author supplied is never dropped. Absent when the field is missing. + +Unknown fields at any level of the document are silently ignored (forward compatibility). + +### URL resolution + +The `url` field of each asset may be an absolute URL (contains `://`) or a relative path. Relative +URLs resolve against the manifest URL's directory: the manifest URL is truncated at the last `/` +and the relative URL is appended verbatim. For example, a manifest at +`https://example.net/releases/manifest.json` and an asset url of +`app-1.2.3-x86_64-unknown-linux-gnu.tar.gz` resolves to +`https://example.net/releases/app-1.2.3-x86_64-unknown-linux-gnu.tar.gz`. Path segments of `..` +in a relative URL are not specially handled (they are included in the resolved URL as-is). + +### Schema versioning and forward compatibility + +`schema: 1` is the only currently accepted version. The `schema` field is read before any other +field; a `schema` that is not recognized (absent, wrong type, or any value other than 1) causes +`Error::InvalidResponse` with a message naming the received schema value. This ensures that a +future schema change (adding required fields or restructuring `releases`) is a clean failure for +older clients rather than a silent mis-parse. + +Unknown fields anywhere in the document are ignored (forward compatibility). A future `schema: 2` +may add required fields or restructure the document; clients built against `schema: 1` will reject +it with `Error::InvalidResponse` rather than silently misread it. + +### Non-semver release skipping + +Release entries whose `version` field is absent or is not a valid semver string are skipped at +parse time with a `debug!` log line naming the skipped version. They do not appear in the returned +`Vec`. This matches the forge backends' treatment of non-semver tags. + +### Digest verification + +Each asset's optional `digest` field (`sha256:`) maps to `ReleaseAsset::with_digest`. Under +the `checksums` feature, the update pipeline verifies the downloaded artifact against it before +installing -- automatically, on by default, when the asset carries a digest. The caller can opt +out with `verify_release_digest(false)`. The behavior is identical to the github backend's +per-asset digest check. See `ref-signatures-and-checksums.md` for the full verification pipeline. + +### Sync and async + +`ManifestSource` implements `ReleaseSource` unconditionally (under the `manifest` feature). +Under the `async` feature it also implements `AsyncReleaseSource`. `Update::build()` drives the +blocking path; `Update::build_async()` (feature `async`) drives the async path. Network IO +is handled by the crate's shared sync/async HTTP clients (`ureq` / reqwest blocking for sync; +reqwest async for async). + +### Errors + +- `manifest_url` not set at `build()`: `Error::MissingField { field: "manifest_url" }`. +- `bin_name` or `current_version` not set: `Error::MissingField` from `common.build()`. +- A completed non-2xx response from the manifest fetch: structured by status via `status_to_error` + (404 -> `Error::NotFound`, 401/403 -> `Error::Unauthorized`, other non-2xx -> + `Error::HttpStatus`); a connection/TLS/timeout failure is `Error::Transport`. +- Unrecognized `schema` value or missing required fields: `Error::InvalidResponse`. +- An empty releases list after filtering (no releases at all, or all non-semver): the + `Error::NoReleaseFound` from the standard update-selection helpers. + +## Public surface + +Under the `manifest` feature: + +- `manifest::ManifestSource`, `manifest::ManifestSourceBuilder` (the `ReleaseSource` + implementation; usable independently with `backends::custom::Update`). +- `manifest::Update`, `manifest::UpdateBuilder`: the all-in-one facade. + `Update::configure()` -> `UpdateBuilder`. `build()` -> `Update`. `build_async()` (feature + `async`) -> async wrapper. +- `Update` is `Send`; exposes inherent verbs (`update`, `update_extended`, `get_latest_release`, + `get_newer_releases`, `get_release_version`, `is_update_available`) and, under `async`, the + `*_async` siblings via the `AsyncReleaseUpdate` default methods. +- No `ReleaseList` struct: standalone listing uses `ManifestSource` directly (as a `ReleaseSource` + implementation) or uses the inherent listing verbs on `Update`. + +The manifest backend adds no new Cargo dependencies; all HTTP, JSON parsing, and checksum +verification reuse existing crate infrastructure. + +## Invariants and regression checklist + +- `manifest_url` is required; absent -> `Error::MissingField { field: "manifest_url" }` from `build()`. +- `bin_name` and `current_version` are required (common setters); absent -> `Error::MissingField` from `common.build()`. +- `schema != 1` (including absent, wrong type, `0`, or any integer > 1) -> `Error::InvalidResponse`. +- Unknown fields at any level are ignored; the parser must not reject a future schema extension + for a document whose `schema` is still `1`. +- Non-semver `version` entries are dropped with a debug log, not an error. +- Relative asset `url` values resolve against the manifest URL truncated at the last `/`; `..` + segments are not handled specially. +- `digest` values map verbatim to `ReleaseAsset::with_digest`. Under `checksums`, the update + pipeline verifies the download against it; an unsupported algorithm prefix errors at verify + time, it is never silently dropped. +- A non-2xx manifest fetch is always `Err`; the backend never parses an error body as a release + list. +- `ManifestSource` implements `ReleaseSource`; under `async` it also implements + `AsyncReleaseSource`. +- No new Cargo dependencies are added by the `manifest` feature. + +## Tests + +Expected in `src/backends/manifest.rs` (`#[cfg(test)] mod tests`), driven by a loopback TCP stub +(no external network): + +- Schema validation: `schema: 1` accepted; `schema: 0`, `schema: 2`, and missing `schema` yield + `Error::InvalidResponse`; unknown top-level fields are ignored. +- Non-semver entries: a release with a non-semver `version` is skipped (debug log); a valid + version in the same manifest is kept. +- URL resolution: absolute URLs are used verbatim; relative URLs resolve against the manifest + URL's directory. A manifest at `.../dir/manifest.json` and asset url `foo.tar.gz` resolves to + `.../dir/foo.tar.gz`. +- Digest: an asset with `digest: "sha256:"` carries that digest on the `ReleaseAsset`; + an asset without `digest` has `None`. +- Optional fields: `date` and `notes_url` absent -> `None`; present -> mapped. +- Empty releases list -> `Error::NoReleaseFound` from the update selection. +- Non-2xx responses -> structured status error (`NotFound`, `Unauthorized`, `HttpStatus`). +- `manifest_url` missing at `build()` -> `Error::MissingField`. +- Loopback stub tests for `get_latest_release`, `get_newer_releases`, `get_release_version`, + and `is_update_available` (sync); and the async equivalents under the `async` feature. +- `ManifestSource` used directly with `backends::custom::Update` to confirm + `ReleaseSource` interop. + +## Related + +- `ref-custom-backend.md` (`ManifestSource` can be used as a `ReleaseSource` with the custom backend) +- `ref-signatures-and-checksums.md` (digest verification pipeline that `digest` fields plug into) +- `ref-release-model.md` (the `Release` / `ReleaseAsset` model) +- `ref-feature-flags.md` (the `manifest` feature flag) +- `transport-control.md` (timeout, retries, custom headers honored by `ManifestSource`) +- `error-network-vs-http-semantics.md` (non-2xx -> structured status; transport failure -> `Error::Transport`) diff --git a/src/backends/manifest.rs b/src/backends/manifest.rs new file mode 100644 index 0000000..7fffbf8 --- /dev/null +++ b/src/backends/manifest.rs @@ -0,0 +1,1221 @@ +/*! +Updates from a static JSON release manifest served over HTTP(S). + +Use this backend to update from any plain file server (S3 static hosting, a CDN, GitHub Pages, a +bare nginx, ...) that serves a single JSON document describing your releases, plus the release +artifacts alongside it. It is the service-agnostic fallback for hosts the forge backends +(`github`, `gitlab`, `gitea`, `s3`) don't cover, and needs no server-side API, just a static +`manifest.json` you regenerate at release time. + +The backend fetches the manifest, parses it into [`Release`]s, and drives the crate's usual +compare -> select-asset -> download -> verify -> extract -> install flow (via the same pipeline +the [`custom`](crate::backends::custom) backend uses). + +```no_run +# fn run() -> Result<(), Box> { +use self_update::cargo_crate_version; + +let status = self_update::backends::manifest::Update::configure() + .manifest_url("https://example.net/releases/manifest.json") + .bin_name("app") + .current_version(cargo_crate_version!()) + .build()? + .update()?; +println!("update status: `{}`", status.version()); +# Ok(()) +# } +``` + +# Manifest schema + +The manifest is a JSON object with a `schema` version and a list of `releases`. Unknown fields are +ignored (forward compatibility), and `date`, `notes_url`, and per-asset `digest` are optional. + +```json +{ + "schema": 1, + "releases": [ + { + "version": "1.2.3", + "date": "2026-07-16T00:00:00Z", + "notes_url": "https://example.net/releases/1.2.3", + "assets": [ + { + "name": "app-1.2.3-x86_64-unknown-linux-gnu.tar.gz", + "url": "app-1.2.3-x86_64-unknown-linux-gnu.tar.gz", + "digest": "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + } + ] + } + ] +} +``` + +- `schema` is the manifest format version. This crate supports schema `1`; a manifest declaring + any other `schema` fails with [`Error::InvalidResponse`](crate::errors::Error::InvalidResponse) + naming the version it found, so an old client refuses a manifest it can't understand rather than + silently mis-parsing it. +- Each release's `version` must be a bare semver string (no leading `v`). A release whose version + is not valid semver is **skipped** (logged at `debug`), not an error, so a manifest mixing a + rolling `nightly` entry with real releases stays usable, matching how the forge backends skip + non-semver tags. +- An asset `url` may be **relative**: it is resolved against the manifest URL's directory (the URL + up to and including its last `/`). An absolute `http(s)://` URL is used verbatim. `..` path + segments are not specially handled. +- `digest` (in `algorithm:hex` form, e.g. `sha256:...`) is mapped onto the asset and, with the + `checksums` feature, verified against the downloaded artifact before installing (see + `verify_release_digest` on the builder). + +# Async + +With the `async` feature, [`build_async`](UpdateBuilder::build_async) returns an [`AsyncUpdate`] +whose `*_async` verbs run the same flow asynchronously. +*/ + +use std::sync::Arc; + +use crate::backends::common::{CommonBuilderConfig, CommonConfig, RequestConfig}; +#[cfg(feature = "async")] +use crate::backends::send_async; +use crate::backends::{MAX_LISTING_BODY_BYTES, send}; +use crate::errors::*; +use crate::http_client; +use crate::update::{Release, ReleaseAsset, ReleaseSource, ReleaseUpdate, Releases}; + +/// The manifest `schema` version this crate understands. A manifest declaring any other +/// version is rejected (see [`parse_manifest`]). +const MANIFEST_SCHEMA_VERSION: u64 = 1; + +// --- Manifest schema (serde) ------------------------------------------------------------------- +// +// No `deny_unknown_fields`: unknown fields are ignored so a newer manifest producer can add fields +// without breaking older clients (the `schema` bump is the explicit break signal instead). + +#[derive(serde::Deserialize)] +struct Manifest { + schema: u64, + #[serde(default)] + releases: Vec, +} + +#[derive(serde::Deserialize)] +struct ManifestRelease { + version: String, + #[serde(default)] + date: Option, + #[serde(default)] + notes_url: Option, + #[serde(default)] + assets: Vec, +} + +#[derive(serde::Deserialize)] +struct ManifestAsset { + name: String, + url: String, + #[serde(default)] + digest: Option, +} + +/// Resolve an asset URL against the manifest URL. +/// +/// An absolute `http(s)://` URL passes through verbatim. A relative URL is joined onto the +/// manifest URL's "directory": everything up to and including the manifest URL's last `/`. This is +/// a deliberately simple string join (no `url` crate, which is an optional s3-only dependency), so +/// `..` segments are NOT collapsed and a manifest URL carrying a query string with a `/` in it +/// would split at that `/`. Point `manifest_url` at a plain path ending in the manifest file name +/// and keep asset URLs as sibling file names (or absolute URLs) to stay in the well-defined case. +fn resolve_asset_url(manifest_url: &str, asset_url: &str) -> String { + if asset_url.starts_with("http://") || asset_url.starts_with("https://") { + return asset_url.to_string(); + } + match manifest_url.rfind('/') { + // `..=idx` keeps the trailing '/', so "https://h/a/manifest.json" + "app.tgz" + // -> "https://h/a/app.tgz". + Some(idx) => format!("{}{}", &manifest_url[..=idx], asset_url), + // No '/' at all (degenerate): fall back to the asset URL as given. + None => asset_url.to_string(), + } +} + +/// Parse a JSON release manifest body into [`Release`]s. Transport-free, so it is shared by the +/// sync and async fetch paths and exercised directly by the unit tests. +/// +/// `manifest_url` is used only to resolve relative asset URLs (see [`resolve_asset_url`]). +/// +/// * Errors +/// * [`Error::InvalidResponse`](crate::errors::Error::InvalidResponse) if the body is not the +/// expected JSON (including a missing required field), or if the manifest declares a +/// `schema` other than the version this crate supports. +/// +/// A release whose `version` is not valid semver is skipped (logged at `debug`), not an error. +pub fn parse_manifest(body: &str, manifest_url: &str) -> Result> { + let manifest: Manifest = serde_json::from_str(body).map_err(Error::invalid_response)?; + if manifest.schema != MANIFEST_SCHEMA_VERSION { + return Err(Error::invalid_response(format!( + "unsupported manifest schema version {}; this crate supports schema {}", + manifest.schema, MANIFEST_SCHEMA_VERSION + ))); + } + + let mut releases = Vec::new(); + for mr in manifest.releases { + let mut builder = Release::builder(); + builder.version(&mr.version); + if let Some(date) = &mr.date { + builder.date(date); + } + if let Some(notes_url) = &mr.notes_url { + builder.release_notes_url(notes_url); + } + for asset in &mr.assets { + let url = resolve_asset_url(manifest_url, &asset.url); + let mut release_asset = ReleaseAsset::new(&*asset.name, url); + if let Some(digest) = &asset.digest { + release_asset = release_asset.with_digest(&**digest); + } + builder.asset(release_asset); + } + match builder.build() { + Ok(release) => releases.push(release), + // A non-semver version is not a release the updater can compare; skip it rather than + // failing the whole manifest, so a manifest mixing rolling entries with real releases + // stays updatable. Matches the forge backends' skip-non-semver-tags precedent (#190). + Err(e @ Error::SemVer(_)) => { + log::debug!("self_update: skipping manifest release: {e}"); + } + Err(e) => return Err(e), + } + } + Ok(releases) +} + +/// Base headers sent with the manifest fetch (a user `request_header(..)` merges on top of these). +fn base_headers() -> http_client::HeaderMap { + let mut headers = http_client::HeaderMap::new(); + headers.insert( + http_client::header::ACCEPT, + http_client::header::HeaderValue::from_static("application/json"), + ); + headers +} + +/// Read a manifest response body into a `String`, bounded by [`MAX_LISTING_BODY_BYTES`] so a +/// misconfigured or malicious endpoint cannot force unbounded memory use. +fn read_body(resp: Box) -> Result { + use std::io::Read as _; + // Read one byte past the cap to distinguish "exactly at the cap" (fine) from "over it" (error). + let mut limited = resp.body().take((MAX_LISTING_BODY_BYTES + 1) as u64); + let mut body = Vec::new(); + limited.read_to_end(&mut body)?; + if body.len() > MAX_LISTING_BODY_BYTES { + return Err(Error::invalid_response(format!( + "manifest body exceeded the {MAX_LISTING_BODY_BYTES}-byte cap; a release manifest is \ + much smaller" + ))); + } + String::from_utf8(body) + .map_err(|e| Error::invalid_response(format!("manifest was not valid UTF-8: {e}"))) +} + +/// A [`ReleaseSource`] that fetches a JSON release manifest from `url`. +/// +/// This is the source the [`Update`] facade wraps, but it can also be used directly with the +/// [`custom`](crate::backends::custom) backend (`custom::Update::configure().source(..)`) when you +/// want the manifest source with the custom builder's surface. The transport setters +/// ([`timeout`](Self::timeout), [`request_header`](Self::request_header), +/// [`retries`](Self::retries), ...) configure the manifest fetch. +#[derive(Debug, Clone)] +pub struct ManifestSource { + url: String, + request: RequestConfig, +} + +impl ManifestSource { + /// Construct a source that fetches the manifest at `url`. + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + request: RequestConfig::default(), + } + } + + request_config_setters!(request); + + /// Fetch and parse the manifest (shared by the sync `ReleaseSource` and, indirectly, the async + /// impl builds its own request the same way). + fn resolved_request(&self) -> Result { + // Materialize a client from any custom root CA certs (no-op if none / a client was + // injected) and surface any deferred header/cert error, mirroring the builder's `build()`. + let mut request = self.request.clone(); + request.build_client(); + request.check()?; + Ok(request) + } +} + +impl ReleaseSource for ManifestSource { + fn get_releases(&self) -> Result> { + let request = self.resolved_request()?; + let resp = send(&self.url, base_headers(), &request)?; + let body = read_body(resp)?; + parse_manifest(&body, &self.url) + } +} + +#[cfg(feature = "async")] +impl crate::update::AsyncReleaseSource for ManifestSource { + async fn get_releases(&self) -> Result> { + let request = self.resolved_request()?; + let resp = send_async(&self.url, base_headers(), &request).await?; + let body = resp.text().await?; + parse_manifest(&body, &self.url) + } +} + +/// [`manifest::Update`](Update) builder. +/// +/// Mirrors the [`custom`](crate::backends::custom) backend's builder (it wraps the same update +/// pipeline over a [`ManifestSource`]), adding the [`manifest_url`](Self::manifest_url) setter. The +/// shared transport setters ([`timeout`](Self::timeout), [`request_header`](Self::request_header), +/// [`retries`](Self::retries), an injected client, ...) apply to **both** the manifest fetch and the +/// crate-controlled asset download. +#[must_use] +#[derive(Clone, Debug, Default)] +pub struct UpdateBuilder { + manifest_url: Option, + common: CommonBuilderConfig, +} + +impl UpdateBuilder { + /// Initialize a new builder. + pub fn new() -> Self { + Default::default() + } + + /// Set the URL of the JSON release manifest. Required. + pub fn manifest_url(&mut self, url: impl Into) -> &mut Self { + self.manifest_url = Some(url.into()); + self + } + + impl_common_builder_setters!(no_auth_token); + + fn build_update(&self) -> Result { + let url = self.manifest_url.clone().ok_or(Error::MissingField { + field: "manifest_url", + })?; + let common = self.common.build()?; + // Thread the same resolved transport config into the manifest fetch as the download uses, + // so `.timeout()` / `.request_header()` / `.retries()` / an injected client apply to both. + let source = ManifestSource { + url, + request: common.request.clone(), + }; + Ok(Update { + source: Arc::new(source), + common, + }) + } + + /// Confirm config and create a ready-to-use [`Update`]. + /// + /// Returns the concrete [`Update`], which is `Send` and exposes the update verbs as inherent + /// methods. + /// + /// * Errors: + /// * `MissingField` - no `manifest_url` was set, or an invalid `Update` configuration + pub fn build(&self) -> Result { + self.build_update() + } + + /// Confirm config and create a ready-to-use [`AsyncUpdate`] for the async API + /// (`update_async`). + /// + /// Unlike [`build`](Self::build) this returns the distinct [`AsyncUpdate`] newtype, which + /// exposes only the inherent `*_async` verbs, so a stray blocking `.update()` on an async-built + /// updater is a compile error rather than a silent block of the executor. + #[cfg(feature = "async")] + pub fn build_async(&self) -> Result { + Ok(AsyncUpdate(self.build_update()?)) + } +} + +/// Updates to a specified or latest release described by a JSON manifest. +#[derive(Debug)] +#[non_exhaustive] +pub struct Update { + source: Arc, + common: CommonConfig, +} + +impl Update { + /// Initialize a new `Update` builder. + pub fn configure() -> UpdateBuilder { + UpdateBuilder::new() + } +} + +impl crate::update::sealed::Sealed for Update {} + +impl_update_config_accessors!(Update); + +impl ReleaseUpdate for Update { + fn get_latest_release(&self) -> Result { + let current_version = crate::update::UpdateConfig::current_version(self).to_owned(); + let release = self.source.get_latest_release()?; + Ok(Releases::new(vec![release], current_version)) + } + + fn get_newer_releases(&self) -> Result { + let current_version = crate::update::UpdateConfig::current_version(self).to_owned(); + let releases = self + .source + .get_releases()? + .into_iter() + .filter(|r| { + crate::version::bump_is_greater(¤t_version, r.version()).unwrap_or(false) + }) + .collect(); + Ok(Releases::new(releases, current_version)) + } + + fn get_release_version(&self, ver: &str) -> Result { + self.source.get_release_version(ver) + } +} + +impl_sync_update_verbs!(Update); + +/// Async-only updater returned by [`UpdateBuilder::build_async`]. +/// +/// A newtype over the blocking [`Update`] that exposes **only** the inherent `*_async` verbs, so a +/// blocking call on an async-built updater (e.g. `build_async()?.update()`) is a compile error. +#[cfg(feature = "async")] +#[derive(Debug)] +pub struct AsyncUpdate(Update); + +#[cfg(feature = "async")] +impl_async_update_verbs!(AsyncUpdate); + +#[cfg(feature = "async")] +impl crate::update::AsyncReleaseUpdate for Update { + async fn get_latest_release_async(&self) -> Result { + let current_version = crate::update::UpdateConfig::current_version(self).to_owned(); + let release = crate::update::AsyncReleaseSource::get_latest_release(&*self.source).await?; + Ok(Releases::new(vec![release], current_version)) + } + + async fn get_newer_releases_async(&self) -> Result { + let current_version = crate::update::UpdateConfig::current_version(self).to_owned(); + let releases = crate::update::AsyncReleaseSource::get_releases(&*self.source) + .await? + .into_iter() + .filter(|r| { + crate::version::bump_is_greater(¤t_version, r.version()).unwrap_or(false) + }) + .collect(); + Ok(Releases::new(releases, current_version)) + } + + async fn get_release_version_async(&self, ver: &str) -> Result { + crate::update::AsyncReleaseSource::get_release_version(&*self.source, ver).await + } +} + +#[cfg(test)] +mod tests { + use super::{ManifestSource, Update, parse_manifest, resolve_asset_url}; + use crate::update::ReleaseSource; + + const MANIFEST_URL: &str = "https://example.net/releases/manifest.json"; + + // --- parse_manifest unit tests (transport-free) -------------------------------------------- + + #[test] + fn parse_manifest_schema_too_new_errors_naming_the_version() { + // A manifest declaring a schema newer than supported must be refused (not silently + // mis-parsed), and the error must name the version found. + let body = r#"{ "schema": 2, "releases": [] }"#; + let err = parse_manifest(body, MANIFEST_URL) + .expect_err("schema 2 must be rejected by a schema-1 client"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "schema-too-new must be InvalidResponse, got {err:?}" + ); + let shown = err.to_string(); + assert!( + shown.contains("unsupported manifest schema version 2"), + "the error must name the found schema version 2: {shown}" + ); + } + + #[test] + fn parse_manifest_missing_version_field_errors() { + // A release object without the required `version` field is a parse error. + let body = r#"{ "schema": 1, "releases": [ { "assets": [] } ] }"#; + let err = parse_manifest(body, MANIFEST_URL).expect_err("missing `version` must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "a missing required field must be InvalidResponse, got {err:?}" + ); + } + + #[test] + fn parse_manifest_missing_asset_name_errors() { + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.0.0", "assets": [ { "url": "app.tar.gz" } ] } ] }"#; + let err = parse_manifest(body, MANIFEST_URL).expect_err("missing asset `name` must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "a missing asset name must be InvalidResponse, got {err:?}" + ); + } + + #[test] + fn parse_manifest_missing_asset_url_errors() { + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.0.0", "assets": [ { "name": "app.tar.gz" } ] } ] }"#; + let err = parse_manifest(body, MANIFEST_URL).expect_err("missing asset `url` must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "a missing asset url must be InvalidResponse, got {err:?}" + ); + } + + #[test] + fn parse_manifest_relative_asset_url_resolved_against_manifest_dir() { + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.2.3", "assets": [ + { "name": "app-1.2.3.tar.gz", "url": "app-1.2.3.tar.gz" } ] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!(releases.len(), 1); + let asset = &releases[0].assets()[0]; + assert_eq!( + asset.download_url(), + "https://example.net/releases/app-1.2.3.tar.gz", + "a relative asset url must resolve against the manifest URL's directory" + ); + } + + #[test] + fn parse_manifest_absolute_asset_url_passes_through() { + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.2.3", "assets": [ + { "name": "app.tar.gz", "url": "https://cdn.example.com/app.tar.gz" } ] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!( + releases[0].assets()[0].download_url(), + "https://cdn.example.com/app.tar.gz", + "an absolute http(s) asset url must pass through verbatim" + ); + } + + #[test] + fn resolve_asset_url_handles_relative_absolute_and_no_slash() { + assert_eq!( + resolve_asset_url("https://h/a/b/manifest.json", "app.tar.gz"), + "https://h/a/b/app.tar.gz" + ); + assert_eq!( + resolve_asset_url("https://h/a/manifest.json", "http://other/app.tar.gz"), + "http://other/app.tar.gz" + ); + assert_eq!( + resolve_asset_url("https://h/a/manifest.json", "https://other/app.tar.gz"), + "https://other/app.tar.gz" + ); + // Degenerate: a manifest "url" with no slash falls back to the asset url as given. + assert_eq!( + resolve_asset_url("manifest.json", "app.tar.gz"), + "app.tar.gz" + ); + } + + #[test] + fn parse_manifest_digest_mapped_through_to_asset() { + let digest = "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + let body = format!( + r#"{{ "schema": 1, "releases": [ + {{ "version": "1.0.0", "assets": [ + {{ "name": "app.tar.gz", "url": "app.tar.gz", "digest": "{digest}" }} ] }} ] }}"# + ); + let releases = parse_manifest(&body, MANIFEST_URL).unwrap(); + assert_eq!( + releases[0].assets()[0].digest(), + Some(digest), + "an asset digest must be mapped onto the ReleaseAsset" + ); + } + + #[test] + fn parse_manifest_no_digest_leaves_asset_digest_none() { + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.0.0", "assets": [ { "name": "app.tar.gz", "url": "app.tar.gz" } ] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!(releases[0].assets()[0].digest(), None); + } + + #[test] + fn parse_manifest_date_and_notes_url_mapped() { + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.0.0", "date": "2026-07-16T00:00:00Z", + "notes_url": "https://example.net/releases/1.0.0", "assets": [] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!(releases[0].date(), "2026-07-16T00:00:00Z"); + assert_eq!( + releases[0].release_notes_url(), + Some("https://example.net/releases/1.0.0") + ); + } + + #[test] + fn parse_manifest_non_semver_version_skipped_valid_siblings_survive() { + // A non-semver `version` (a rolling `nightly` entry) is skipped, not an error; the valid + // sibling release survives. + let body = r#"{ "schema": 1, "releases": [ + { "version": "nightly", "assets": [] }, + { "version": "1.2.3", "assets": [] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!(releases.len(), 1, "the non-semver release must be skipped"); + assert_eq!(releases[0].version(), "1.2.3"); + } + + #[test] + fn parse_manifest_unknown_fields_ignored() { + // Unknown fields at the top level, on a release, and on an asset must be ignored (forward + // compat), not rejected. + let body = r#"{ + "schema": 1, + "generator": "some-tool", + "releases": [ + { "version": "1.2.3", "channel": "stable", "assets": [ + { "name": "app.tar.gz", "url": "app.tar.gz", "size": 12345 } ] } ] + }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0].version(), "1.2.3"); + assert_eq!(releases[0].assets().len(), 1); + } + + // --- resolve_asset_url edge cases (spec: deliberate string-join, no `..`/query handling) ----- + + #[test] + fn resolve_asset_url_trailing_slash_manifest_url_appends_directly() { + // A manifest URL ending in `/` (a "directory" URL) resolves the asset right after it. + assert_eq!( + resolve_asset_url("https://h/a/b/", "app.tar.gz"), + "https://h/a/b/app.tar.gz" + ); + } + + #[test] + fn resolve_asset_url_relative_asset_with_subpath_joins_verbatim() { + // A relative asset url that itself contains `/` is appended verbatim onto the manifest dir. + assert_eq!( + resolve_asset_url("https://h/a/manifest.json", "sub/app.tar.gz"), + "https://h/a/sub/app.tar.gz" + ); + } + + #[test] + fn resolve_asset_url_dotdot_segments_not_collapsed() { + // Spec: `..` segments are NOT specially handled; they land in the resolved URL as-is. + assert_eq!( + resolve_asset_url("https://h/a/b/manifest.json", "../app.tar.gz"), + "https://h/a/b/../app.tar.gz" + ); + } + + #[test] + fn resolve_asset_url_query_string_with_slash_splits_at_that_slash() { + // Documented degenerate case: the join truncates at the LAST `/`, even one inside a query + // string. A manifest URL whose query contains a `/` therefore resolves oddly -- this pins + // that documented behavior so a "fix" that adds query awareness is a conscious change. + assert_eq!( + resolve_asset_url("https://h/a/manifest.json?path=/x", "app.tar.gz"), + "https://h/a/manifest.json?path=/app.tar.gz" + ); + } + + #[test] + fn resolve_asset_url_query_string_without_slash_resolves_against_dir() { + // A query string with no `/` in it does not move the split point: the last `/` is still the + // one before the manifest file name. + assert_eq!( + resolve_asset_url("https://h/a/manifest.json?token=abc", "app.tar.gz"), + "https://h/a/app.tar.gz" + ); + } + + // --- parse_manifest schema / body edge cases ----------------------------------------------- + + #[test] + fn parse_manifest_missing_schema_field_errors() { + // `schema` is a required field (no serde default): a manifest that omits it entirely is a + // parse error, not silently treated as schema 0/1. + let body = r#"{ "releases": [] }"#; + let err = parse_manifest(body, MANIFEST_URL).expect_err("absent `schema` must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "an absent schema must be InvalidResponse, got {err:?}" + ); + } + + #[test] + fn parse_manifest_schema_wrong_type_errors() { + // A `schema` of the wrong JSON type (string instead of integer) is a parse error. + let body = r#"{ "schema": "1", "releases": [] }"#; + let err = parse_manifest(body, MANIFEST_URL).expect_err("string `schema` must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "a wrong-typed schema must be InvalidResponse, got {err:?}" + ); + } + + #[test] + fn parse_manifest_garbage_body_errors() { + // A body that is not JSON at all surfaces as InvalidResponse, never a panic or empty list. + let err = + parse_manifest("this is not json", MANIFEST_URL).expect_err("non-JSON body must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "a non-JSON body must be InvalidResponse, got {err:?}" + ); + } + + #[test] + fn parse_manifest_empty_releases_yields_empty_vec() { + // An explicitly empty releases array parses cleanly to zero releases (the NoReleaseFound + // surfaces later, from the update-selection helpers -- see the stub test below). + let releases = parse_manifest(r#"{ "schema": 1, "releases": [] }"#, MANIFEST_URL).unwrap(); + assert!(releases.is_empty()); + } + + #[test] + fn parse_manifest_release_with_empty_assets_is_kept_with_zero_assets() { + // A valid-version release carrying zero assets is a real release (asset selection fails + // later, but parsing keeps it); the entry must survive with an empty asset list. + let body = r#"{ "schema": 1, "releases": [ { "version": "1.2.3", "assets": [] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0].version(), "1.2.3"); + assert!(releases[0].assets().is_empty()); + } + + #[test] + fn parse_manifest_duplicate_versions_are_all_kept() { + // The parser does not de-duplicate: two entries with the same version both survive, in + // document order. (Selection later picks by semver; a tie takes the earliest-positioned.) + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.2.3", "assets": [ { "name": "a.tgz", "url": "a.tgz" } ] }, + { "version": "1.2.3", "assets": [ { "name": "b.tgz", "url": "b.tgz" } ] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!( + releases.len(), + 2, + "duplicate versions must not be collapsed" + ); + assert_eq!(releases[0].assets()[0].name(), "a.tgz"); + assert_eq!(releases[1].assets()[0].name(), "b.tgz"); + } + + #[test] + fn parse_manifest_non_sha256_digest_prefix_is_mapped_verbatim() { + // Any `digest` string is mapped verbatim through `ReleaseAsset::with_digest`: an + // unsupported algorithm (e.g. `md5:`) then hard-errors at verify time under the + // `checksums` feature rather than being silently dropped. Silently ignoring a digest the + // manifest author supplied would skip verification the user believes is happening. + let body = r#"{ "schema": 1, "releases": [ + { "version": "1.0.0", "assets": [ + { "name": "app.tar.gz", "url": "app.tar.gz", "digest": "md5:abc123" } ] } ] }"#; + let releases = parse_manifest(body, MANIFEST_URL).unwrap(); + assert_eq!( + releases[0].assets()[0].digest(), + Some("md5:abc123"), + "current impl maps a non-sha256 digest verbatim (diverges from the spec's \ + treat-as-absent contract)" + ); + } + + #[test] + fn parse_manifest_schema_zero_is_rejected() { + // Only `schema: 1` is accepted: any other value (0 included) is refused with an error + // naming the received version, per the spec's invariant checklist. + let err = parse_manifest(r#"{ "schema": 0, "releases": [] }"#, MANIFEST_URL) + .expect_err("schema 0 must be rejected; only schema 1 is supported"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "schema 0 must be InvalidResponse, got {err:?}" + ); + assert!( + err.to_string() + .contains("unsupported manifest schema version 0"), + "the error must name the found schema version 0: {err}" + ); + } + + #[test] + fn build_requires_a_manifest_url() { + // Absent `manifest_url` must be the specific `MissingField { field: "manifest_url" }`, and + // it must take precedence even when the common fields (bin_name/current_version) are set. + let err = Update::configure() + .bin_name("app") + .current_version("1.0.0") + .build() + .expect_err("build must fail without a manifest_url"); + assert!( + matches!( + err, + crate::errors::Error::MissingField { + field: "manifest_url" + } + ), + "a missing manifest_url must be MissingField {{ field: \"manifest_url\" }}, got {err:?}" + ); + } + + // --- Loopback stub end-to-end tests -------------------------------------------------------- + + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + /// Serve a sequence of raw HTTP responses over a loopback listener, one connection per entry. + /// Each entry is `(content_type, body_bytes)`. Returns the base URL (`http://127.0.0.1:`). + fn stub(responses: Vec<(&'static str, Vec)>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let owned: Vec<(String, Vec)> = responses + .into_iter() + .map(|(ct, body)| (ct.to_string(), body)) + .collect(); + std::thread::spawn(move || { + for (content_type, body) in owned { + let (mut stream, _) = match listener.accept() { + Ok(c) => c, + Err(_) => return, + }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + content_type, + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); + } + }); + base + } + + #[test] + fn get_releases_over_the_stub_parses_the_manifest() { + let manifest = r#"{ "schema": 1, "releases": [ + { "version": "2.0.0", "assets": [ + { "name": "app-2.0.0.tar.gz", "url": "app-2.0.0.tar.gz" } ] }, + { "version": "1.0.0", "assets": [] } ] }"#; + let base = stub(vec![("application/json", manifest.as_bytes().to_vec())]); + let source = ManifestSource::new(format!("{base}/releases/manifest.json")); + let releases = source.get_releases().unwrap(); + let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect(); + assert_eq!(versions, vec!["2.0.0", "1.0.0"]); + // The relative asset url resolved against the manifest URL's directory (same stub host). + assert_eq!( + releases[0].assets()[0].download_url(), + format!("{base}/releases/app-2.0.0.tar.gz") + ); + } + + #[test] + fn get_releases_over_the_stub_propagates_http_error() { + // A non-2xx manifest response must surface as a structured error, before any parse. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let out = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = stream.write_all(out.as_bytes()); + let _ = stream.flush(); + } + }); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source.get_releases().expect_err("503 must error"); + assert!( + matches!(err, crate::errors::Error::HttpStatus { status: 503, .. }), + "a 503 manifest response must surface as HttpStatus, got {err:?}" + ); + } + + /// Serve one raw HTTP response with an explicit status line and empty body, over a loopback + /// listener. Used to check that a non-2xx manifest fetch maps to the right structured error. + fn stub_status(status_line: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let out = format!( + "HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(out.as_bytes()); + let _ = stream.flush(); + } + }); + base + } + + #[test] + fn get_releases_over_the_stub_maps_404_to_not_found() { + let base = stub_status("404 Not Found"); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source.get_releases().expect_err("404 must error"); + assert!( + matches!(err, crate::errors::Error::NotFound { .. }), + "a 404 manifest response must surface as NotFound, got {err:?}" + ); + } + + #[test] + fn get_releases_over_the_stub_maps_401_to_unauthorized() { + let base = stub_status("401 Unauthorized"); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source.get_releases().expect_err("401 must error"); + assert!( + matches!(err, crate::errors::Error::Unauthorized { status: 401, .. }), + "a 401 manifest response must surface as Unauthorized, got {err:?}" + ); + } + + #[test] + fn get_releases_over_the_stub_maps_403_to_unauthorized() { + let base = stub_status("403 Forbidden"); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source.get_releases().expect_err("403 must error"); + assert!( + matches!(err, crate::errors::Error::Unauthorized { status: 403, .. }), + "a 403 manifest response must surface as Unauthorized, got {err:?}" + ); + } + + #[test] + fn get_releases_over_the_stub_empty_releases_yields_no_release_found() { + // An empty (but valid) manifest parses fine, but the latest-release selection has nothing + // to pick -> Error::NoReleaseFound. This is the update-path surface of an empty manifest. + let base = stub(vec![( + "application/json", + br#"{ "schema": 1, "releases": [] }"#.to_vec(), + )]); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source + .get_latest_release() + .expect_err("an empty manifest must have no latest release"); + assert!( + matches!(err, crate::errors::Error::NoReleaseFound { .. }), + "an empty releases list must yield NoReleaseFound, got {err:?}" + ); + } + + #[test] + fn get_releases_over_the_stub_non_utf8_body_errors() { + // A manifest body that is not valid UTF-8 must be a structured InvalidResponse (from + // `read_body`), never a panic. `0xff 0xfe` is not valid UTF-8. + let base = stub(vec![("application/json", vec![0xff, 0xfe, 0x00, 0x01])]); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source + .get_releases() + .expect_err("a non-UTF-8 manifest body must error"); + assert!( + matches!(err, crate::errors::Error::InvalidResponse { .. }), + "a non-UTF-8 body must be InvalidResponse, got {err:?}" + ); + } + + /// Serve one JSON response but capture the raw request bytes the client sent, so a test can + /// assert which headers actually reached the manifest fetch. Returns `(base_url, captured)`. + fn stub_capturing_request( + body: Vec, + ) -> (String, std::sync::Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured_thread = std::sync::Arc::clone(&captured); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).unwrap_or(0); + *captured_thread.lock().unwrap() = buf[..n].to_vec(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); + } + }); + (base, captured) + } + + #[test] + fn manifest_fetch_sends_accept_json_and_custom_request_header() { + // Transport-control: the base `Accept: application/json` header and a user-supplied + // `request_header(..)` must both reach the manifest fetch. A loopback stub captures the + // raw request line + headers so we can assert the bytes actually went out. + let (base, captured) = + stub_capturing_request(br#"{ "schema": 1, "releases": [] }"#.to_vec()); + let mut source = ManifestSource::new(format!("{base}/manifest.json")); + source.request_header("X-Manifest-Probe", "sentinel-value"); + // Empty manifest -> NoReleaseFound is fine; we only care that the request went out. + let _ = source.get_releases(); + + let raw = captured.lock().unwrap().clone(); + let text = String::from_utf8_lossy(&raw); + let lower = text.to_ascii_lowercase(); + assert!( + lower.contains("accept: application/json"), + "the manifest fetch must send `Accept: application/json`; raw request was:\n{text}" + ); + assert!( + text.contains("sentinel-value") && lower.contains("x-manifest-probe:"), + "a custom request_header(..) must reach the manifest fetch; raw request was:\n{text}" + ); + } + + /// Build a tiny tar.gz in memory containing a single file named `app` (the default + /// `bin_path_in_archive` on a unix target, where EXE_SUFFIX is empty). + #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))] + fn app_tar_gz(payload: &[u8]) -> Vec { + let mut tar = tar::Builder::new(Vec::new()); + let mut header = tar::Header::new_gnu(); + header.set_path("app").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append(&header, payload).unwrap(); + let tar_bytes = tar.into_inner().unwrap(); + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(&tar_bytes).unwrap(); + enc.finish().unwrap() + } + + #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))] + #[test] + fn update_downloads_and_installs_from_a_manifest() { + // Full sync flow: the stub serves the manifest (connection 1) then the tar.gz asset + // (connection 2). The updater compares versions, selects the asset, downloads it, extracts + // `app`, and installs it to a temp path. A successful `Updated` status plus the installed + // file proves the whole pipeline ran over the manifest backend. + let payload = b"installed-binary-payload"; + let archive = app_tar_gz(payload); + + // Bind first so we know the base, then build the manifest referencing a sibling asset URL. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let manifest = r#"{ "schema": 1, "releases": [ + { "version": "2.0.0", "assets": [ + { "name": "app.tar.gz", "url": "app.tar.gz" } ] } ] }"#; + let manifest_bytes = manifest.as_bytes().to_vec(); + std::thread::spawn(move || { + let responses: Vec<(&str, Vec)> = vec![ + ("application/json", manifest_bytes), + ("application/octet-stream", archive), + ]; + for (content_type, body) in responses { + let (mut stream, _) = match listener.accept() { + Ok(c) => c, + Err(_) => return, + }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + content_type, + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); + } + }); + + let install_dir = tempfile::tempdir().unwrap(); + let install_path = install_dir.path().join("installed-app"); + + let status = Update::configure() + .manifest_url(format!("{base}/releases/manifest.json")) + .bin_name("app") + .target("x86_64-unknown-linux-gnu") + .current_version("1.0.0") + .bin_install_path(&install_path) + .no_confirm(true) + .show_output(false) + // Pick the single served asset directly, sidestepping target-name matching. + .asset_matcher(|assets| assets.first().cloned()) + .build() + .unwrap() + .update_extended() + .expect("the update must download and install from the manifest"); + + assert!( + status.is_updated(), + "a newer manifest release served as a real tar.gz must install -> Updated, got {status:?}" + ); + assert_eq!(status.version(), Some("2.0.0")); + assert!( + install_path.exists(), + "the pipeline must install the extracted binary to {install_path:?}" + ); + assert_eq!(std::fs::read(&install_path).unwrap(), payload); + } + + #[cfg(feature = "async")] + mod async_tests { + use super::super::{ManifestSource, Update}; + use crate::update::AsyncReleaseSource; + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + fn stub(responses: Vec<(&'static str, Vec)>) -> String { + super::stub(responses) + } + + #[tokio::test] + async fn get_releases_async_over_the_stub_parses_the_manifest() { + let manifest = r#"{ "schema": 1, "releases": [ + { "version": "2.0.0", "assets": [ + { "name": "app-2.0.0.tar.gz", "url": "app-2.0.0.tar.gz" } ] } ] }"#; + let base = stub(vec![("application/json", manifest.as_bytes().to_vec())]); + let source = ManifestSource::new(format!("{base}/releases/manifest.json")); + let releases = source.get_releases().await.unwrap(); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0].version(), "2.0.0"); + assert_eq!( + releases[0].assets()[0].download_url(), + format!("{base}/releases/app-2.0.0.tar.gz") + ); + } + + #[tokio::test] + async fn get_releases_async_empty_releases_yields_no_release_found() { + // Async parity: an empty manifest over the async transport has no latest release. + let base = super::stub(vec![( + "application/json", + br#"{ "schema": 1, "releases": [] }"#.to_vec(), + )]); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source + .get_latest_release() + .await + .expect_err("an empty manifest must have no latest release"); + assert!( + matches!(err, crate::errors::Error::NoReleaseFound { .. }), + "async empty releases must yield NoReleaseFound, got {err:?}" + ); + } + + #[tokio::test] + async fn get_releases_async_maps_404_to_not_found() { + // Async parity: a non-2xx manifest fetch surfaces as the same structured status error. + let base = super::stub_status("404 Not Found"); + let source = ManifestSource::new(format!("{base}/manifest.json")); + let err = source.get_releases().await.expect_err("404 must error"); + assert!( + matches!(err, crate::errors::Error::NotFound { .. }), + "async 404 must surface as NotFound, got {err:?}" + ); + } + + #[tokio::test] + async fn manifest_fetch_async_sends_accept_json_and_custom_request_header() { + // Async parity: the base Accept header and a custom request_header both reach the + // async manifest fetch. + let (base, captured) = + super::stub_capturing_request(br#"{ "schema": 1, "releases": [] }"#.to_vec()); + let mut source = ManifestSource::new(format!("{base}/manifest.json")); + source.request_header("X-Manifest-Probe", "sentinel-value"); + let _ = source.get_releases().await; + + let raw = captured.lock().unwrap().clone(); + let text = String::from_utf8_lossy(&raw); + let lower = text.to_ascii_lowercase(); + assert!( + lower.contains("accept: application/json"), + "async manifest fetch must send Accept: application/json; raw:\n{text}" + ); + assert!( + text.contains("sentinel-value") && lower.contains("x-manifest-probe:"), + "async custom request_header must reach the fetch; raw:\n{text}" + ); + } + + #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))] + #[tokio::test] + async fn update_async_downloads_and_installs_from_a_manifest() { + // Async sibling of the sync end-to-end flow: the manifest is fetched over the async + // transport, then the archive is downloaded and installed through the spawn_blocking + // finish tail. + let payload = b"async-installed-binary-payload"; + let archive = super::app_tar_gz(payload); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let manifest = r#"{ "schema": 1, "releases": [ + { "version": "2.0.0", "assets": [ + { "name": "app.tar.gz", "url": "app.tar.gz" } ] } ] }"#; + let manifest_bytes = manifest.as_bytes().to_vec(); + std::thread::spawn(move || { + let responses: Vec<(&str, Vec)> = vec![ + ("application/json", manifest_bytes), + ("application/octet-stream", archive), + ]; + for (content_type, body) in responses { + let (mut stream, _) = match listener.accept() { + Ok(c) => c, + Err(_) => return, + }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + content_type, + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); + } + }); + + let install_dir = tempfile::tempdir().unwrap(); + let install_path = install_dir.path().join("installed-app"); + + let status = Update::configure() + .manifest_url(format!("{base}/releases/manifest.json")) + .bin_name("app") + .target("x86_64-unknown-linux-gnu") + .current_version("1.0.0") + .bin_install_path(&install_path) + .no_confirm(true) + .show_output(false) + .asset_matcher(|assets| assets.first().cloned()) + .build_async() + .unwrap() + .update_extended_async() + .await + .expect("the async update must download and install from the manifest"); + + assert!( + status.is_updated(), + "async update must install -> Updated, got {status:?}" + ); + assert_eq!(status.version(), Some("2.0.0")); + assert!(install_path.exists()); + assert_eq!(std::fs::read(&install_path).unwrap(), payload); + } + } +} diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 6971db2..f0de131 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -13,6 +13,8 @@ pub mod gitea; pub mod github; #[cfg(feature = "gitlab")] pub mod gitlab; +#[cfg(feature = "manifest")] +pub mod manifest; #[cfg(feature = "s3")] pub mod s3; @@ -396,7 +398,8 @@ where feature = "github", feature = "gitlab", feature = "gitea", - feature = "s3" + feature = "s3", + feature = "manifest" )), allow(dead_code) )] @@ -449,7 +452,8 @@ pub(crate) fn send( feature = "github", feature = "gitlab", feature = "gitea", - feature = "s3" + feature = "s3", + feature = "manifest" )), allow(dead_code) )] diff --git a/src/lib.rs b/src/lib.rs index 3e42ec6..edea465 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,9 +7,10 @@ `self_update` provides updaters for updating rust executables in-place from various release distribution backends. -Supported backends: **GitHub**, **GitLab**, **Gitea**, and **S3** (Amazon S3, Google GCS, -DigitalOcean Spaces, or any S3-compatible endpoint). Each exposes the same `Update` -(configure -> build -> update) and `ReleaseList` builder API. +Supported backends: **GitHub**, **GitLab**, **Gitea**, **S3** (Amazon S3, Google GCS, +DigitalOcean Spaces, or any S3-compatible endpoint), and **Manifest** (any static file server). +The forge and S3 backends each expose a `ReleaseList` builder alongside the `Update` +(configure -> build -> update) API; the manifest backend exposes `Update` only. ## Quick start @@ -77,6 +78,7 @@ The following are opt-in; activate the one(s) your release files need: * `gitea`: the Gitea Releases backend; * `s3`: the S3-compatible backend (Amazon S3, GCS, DigitalOcean Spaces, etc.); * `s3-auth`: sign S3 requests (AWS SigV4) for private buckets; implies `s3`; +* `manifest`: the static-file manifest backend; fetches releases from a `manifest.json` served by any HTTP endpoint; no new dependencies; * `archive-tar`: support for _tar_ archive format; * `archive-zip`: support for _zip_ archive format; * `compression-tar-gz`: support for _gzip_ compression (`.tar.gz`, `.tgz`, plain `.gz`); @@ -87,7 +89,7 @@ The following are opt-in; activate the one(s) your release files need: * `checksums`: verify a downloaded artifact against a SHA-256/SHA-512 checksum before installing it -- automatically against the digest github publishes per release asset, and/or against a known checksum you pass in (e.g. from a `SHA256SUMS` file); see [Checksum verification](#checksum-verification) below; * `async`: add async (`*_async`) update methods alongside the unchanged blocking API; tokio-only, requires `reqwest` (ureq and reqwest can coexist -- reqwest serves the async path, and the sync API prefers reqwest when both are present); see [Async](#async) below. -`github` is the only backend in the default feature set. The S3 backend requires the `s3` feature; `s3-auth` implies `s3`. `gitlab` and `gitea` each require their own feature. +`github` is the only backend in the default feature set. The S3 backend requires the `s3` feature; `s3-auth` implies `s3`. `gitlab`, `gitea`, and `manifest` each require their own feature. ### Example @@ -132,6 +134,30 @@ fn update() -> Result<(), Box> { # } ``` +The `manifest` backend (`manifest` feature) serves releases from a `manifest.json` file hosted +on any static file server. The tool author publishes the manifest at a stable URL; assets may be +absolute URLs or relative paths resolved against that URL. Asset `digest` fields (`sha256:`) +plug into the existing checksum verification path when the `checksums` feature is on. See +`specs/ref-manifest-backend.md` for the full schema. + +```rust +# #[cfg(feature = "manifest")] +# mod manifest_example { +use self_update::cargo_crate_version; + +fn update() -> Result<(), Box> { + let status = self_update::backends::manifest::Update::configure() + .manifest_url("https://example.net/releases/manifest.json") + .bin_name("app") + .current_version(cargo_crate_version!()) + .build()? + .update()?; + println!("Manifest update status: `{}`!", status.version()); + Ok(()) +} +# } +``` + Separate utilities are also exposed (**NOTE**: the following example extracts a `.tar.gz`, which _requires_ both the `archive-tar` and `compression-tar-gz` features -- `archive-tar` reads the tar archive and `compression-tar-gz` decodes the gzip layer; see the [features](#features) section @@ -369,13 +395,18 @@ so they are reached through their backend modules rather than re-exported at the * `backends::gitea::ReleaseList` * `backends::s3::ReleaseList` +The `manifest` backend has no separate `ReleaseList` struct. Its `ManifestSource` is a +`ReleaseSource` implementation that can be used directly, or listing can be driven through the +inherent verbs (`get_latest_release`, `get_newer_releases`, `is_update_available`) on a built +`manifest::Update`. + The custom backend has no `ReleaseList` by design: listing is performed entirely by your `ReleaseSource` (or `AsyncReleaseSource`) implementation, which already returns `Release` values directly. ### Custom backends -To update from a host the built-in backends (`github`, `gitlab`, `gitea`, `s3`) don't cover — +To update from a host the built-in backends (`github`, `gitlab`, `gitea`, `s3`, `manifest`) don't cover — another forge, a private artifact registry, a plain HTTP directory — implement the `ReleaseSource` trait and drive a full update through the `backends::custom` backend, which reuses the crate's compare → select-asset → download → verify → extract → install flow. Only