Skip to main content

self_update/
lib.rs

1/*!
2
3[![crates.io:clin](https://img.shields.io/crates/v/self_update.svg?label=self_update)](https://crates.io/crates/self_update)
4[![docs](https://docs.rs/self_update/badge.svg)](https://docs.rs/self_update)
5
6
7`self_update` provides updaters for updating rust executables in-place from various release
8distribution backends.
9
10Supported backends: **GitHub**, **GitLab**, **Gitea**, **Gitee**, **S3** (Amazon S3, Google GCS,
11DigitalOcean Spaces, or any S3-compatible endpoint), and **Manifest** (any static file server).
12The forge and S3 backends each expose a `ReleaseList` builder alongside the `Update`
13(configure -> build -> update) API; the manifest backend exposes `Update` only.
14
15## Quick start
16
17```rust
18use self_update::cargo_crate_version;
19
20fn update() -> Result<(), Box<dyn std::error::Error>> {
21    let status = self_update::backends::github::Update::configure()
22        .repo_owner("jaemk")
23        .repo_name("self_update")
24        .bin_name("github")
25        .show_download_progress(true)
26        .current_version(cargo_crate_version!())
27        .build()?
28        .update()?;
29    println!("Update status: `{}`!", status.version());
30    Ok(())
31}
32```
33
34> **Upgrading from 0.x?** 1.0 makes a focused set of breaking changes to clean up the public
35> API. See the [1.0 migration guide](https://github.com/jaemk/self_update/blob/master/docs/migrations/0.x-to-1.0-human.md)
36> for a step-by-step walkthrough, or the
37> [agent-oriented guide](https://github.com/jaemk/self_update/blob/master/docs/migrations/0.x-to-1.0.md)
38> for automated migration tooling.
39
40> **Running unattended (daemon / CI / service)?** The defaults are interactive: `show_output`
41> is `true` and `no_confirm` is `false`, so `update()` prints a release-status block to stdout
42> and then **blocks on an interactive `yes/no` prompt** waiting on stdin. With no terminal
43> attached this stalls (or aborts). For any non-interactive caller set `.no_confirm(true)` to
44> skip the prompt, and usually `.show_output(false)` to silence the status block. These are
45> settings only -- the defaults are unchanged. Note the status block is printed *before* the
46> confirmation prompt, so suppressing one does not suppress the other.
47
48## Usage
49
50### Features
51
52At least one HTTP client must be selected. A build with **no** client -- for example
53`default-features = false` with only a TLS feature such as `features = ["rustls"]` -- fails to
54compile with `no HTTP client selected - enable at least one of the reqwest (default) or ureq
55features`. Add a client explicitly, e.g. `default-features = false, features = ["ureq", "rustls",
56"github"]`. Multiple clients and multiple TLS backends may coexist (reqwest is preferred when both
57are present):
58
59* `reqwest` (default): use the [`reqwest`](https://docs.rs/reqwest) HTTP client;
60* `ureq`: use the [`ureq`](https://docs.rs/ureq) HTTP client, either alongside reqwest or as a drop-in replacement (set `default-features = false` to drop reqwest);
61* `rustls` (default): [pure-Rust TLS](https://github.com/rustls/rustls); does _not_ support 32-bit macOS;
62* `native-tls`: opt-in native/OpenSSL TLS for the selected client;
63* `native-tls-vendored`: build OpenSSL from source and link it statically (for targets where a usable system OpenSSL is awkward, e.g. musl or some cross-compiles); implies `native-tls`, applies to the reqwest client;
64
65Note that enabling a client with neither TLS feature compiles (plain-`http` release hosts remain
66reachable) but any `https` URL then fails at request time with a transport error; enable `rustls`
67or `native-tls` for `https`.
68
69The following [cargo features](https://doc.rust-lang.org/cargo/reference/manifest.html#the-features-section)
70are enabled by default:
71
72* `github`: the GitHub Releases backend;
73* `progress-bar`: terminal download progress bar;
74
75The following are opt-in; activate the one(s) your release files need:
76
77* `gitlab`: the GitLab Releases backend;
78* `gitea`: the Gitea Releases backend;
79* `gitee`: the Gitee Releases backend;
80* `s3`: the S3-compatible backend (Amazon S3, GCS, DigitalOcean Spaces, etc.);
81* `s3-auth`: sign S3 requests (AWS SigV4) for private buckets; implies `s3`;
82* `manifest`: the static-file manifest backend; fetches releases from a `manifest.json` served by any HTTP endpoint; no new dependencies;
83* `archive-tar`: support for _tar_ archive format;
84* `archive-zip`: support for _zip_ archive format;
85* `compression-tar-gz`: support for _gzip_ compression (`.tar.gz`, `.tgz`, plain `.gz`);
86* `compression-tar-xz`: support for _xz_ compression (`.tar.xz`, `.txz`, plain `.xz`); pure-Rust, no C `liblzma` dependency;
87* `compression-zip-deflate`: support for _zip_'s _deflate_ compression format;
88* `compression-zip-bzip2`: support for _zip_'s _bzip2_ compression format;
89* `signatures`: use [zipsign](https://github.com/Kijewski/zipsign) to verify `.zip` and `.tar.gz` artifacts. Artifacts are assumed to have been signed using zipsign;
90* `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;
91* `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.
92
93`github` is the only backend in the default feature set. The S3 backend requires the `s3` feature; `s3-auth` implies `s3`. `gitlab`, `gitea`, `gitee`, and `manifest` each require their own feature.
94
95### Example
96
97Run the following example to see `self_update` in action:
98
99`cargo run --example github --features "signatures archive-tar compression-tar-gz"`.
100
101There are equivalent examples for the other backends (`gitlab`, `gitea`, `gitee`, `s3`), e.g.:
102
103`cargo run --example gitlab --features "gitlab archive-tar compression-tar-gz"`.
104
105Amazon S3, Google GCS, and DigitalOcean Spaces, as well as any S3 compatible server are also supported
106through the `S3` backend to check for new releases.  Provided a `bucket_name`
107and `asset_prefix` string, `self_update` will look up all matching files using the following format
108as a convention for the filenames: `[directory/]<asset name>-<semver>-<platform/target>.<extension>`.
109Leading directories will be stripped from the file name allowing the use of subdirectories in the S3 bucket,
110and any file not matching the format, or not matching the provided prefix string, will be ignored.
111
112```rust
113# #[cfg(feature = "s3")]
114# mod s3_example {
115use self_update::cargo_crate_version;
116
117fn update() -> Result<(), Box<dyn ::std::error::Error>> {
118    let status = self_update::backends::s3::Update::configure()
119        // .endpoint(self_update::backends::s3::Endpoint::GCS)
120        // .endpoint("https://s3.example.com")
121        .bucket_name("self_update_releases")
122        .asset_prefix("something/self_update")
123        .region("eu-west-2")
124        .bin_name("self_update_example")
125        // To authenticate (requires the `s3-auth` feature), read the credentials at
126        // runtime rather than baking them into the binary with `env!`:
127        // .access_key((std::env::var("AWS_ACCESS_KEY_ID")?, std::env::var("AWS_SECRET_ACCESS_KEY")?))
128        .show_download_progress(true)
129        .current_version(cargo_crate_version!())
130        .build()?
131        .update()?;
132    println!("S3 Update status: `{}`!", status.version());
133    Ok(())
134}
135# }
136```
137
138The `manifest` backend (`manifest` feature) serves releases from a `manifest.json` file hosted
139on any static file server. The tool author publishes the manifest at a stable URL; assets may be
140absolute URLs or relative paths resolved against that URL. Asset `digest` fields (`sha256:<hex>`)
141plug into the existing checksum verification path when the `checksums` feature is on. See
142`specs/ref-manifest-backend.md` for the full schema.
143
144```rust
145# #[cfg(feature = "manifest")]
146# mod manifest_example {
147use self_update::cargo_crate_version;
148
149fn update() -> Result<(), Box<dyn std::error::Error>> {
150    let status = self_update::backends::manifest::Update::configure()
151        .manifest_url("https://example.net/releases/manifest.json")
152        .bin_name("app")
153        .current_version(cargo_crate_version!())
154        .build()?
155        .update()?;
156    println!("Manifest update status: `{}`!", status.version());
157    Ok(())
158}
159# }
160```
161
162Separate utilities are also exposed (**NOTE**: the following example extracts a `.tar.gz`, which
163_requires_ both the `archive-tar` and `compression-tar-gz` features -- `archive-tar` reads the tar
164archive and `compression-tar-gz` decodes the gzip layer; see the [features](#features) section
165above). It downloads, extracts, and replaces the running binary
166by hand; the staging directory and the in-place replacement use the [`tempfile`](https://crates.io/crates/tempfile)
167and [`self_replace`](https://crates.io/crates/self-replace) crates, which you add as your own dependencies
168(they are no longer re-exported from `self_update`):
169
170```rust
171# #[cfg(feature = "archive-tar")]
172fn update() -> Result<(), Box<dyn std::error::Error>> {
173    let releases = self_update::backends::github::ReleaseList::configure()
174        .repo_owner("jaemk")
175        .repo_name("self_update")
176        .build()?
177        .fetch()?;
178    println!("found releases:");
179    println!("{:#?}\n", releases);
180
181    // get the first available release (`fetch` returns a `Releases`; `latest()` is the first entry)
182    let latest = releases.latest().unwrap();
183    let asset = latest
184        .asset_for(&self_update::get_target(), None)
185        .unwrap();
186
187    let tmp_dir = tempfile::Builder::new()
188            .prefix("self_update")
189            .tempdir_in(::std::env::current_dir()?)?;
190    let tmp_tarball_path = tmp_dir.path().join(asset.name());
191    let tmp_tarball = ::std::fs::File::create(&tmp_tarball_path)?;
192
193    self_update::Download::from_url(asset.download_url())
194        .request_header(self_update::http::header::ACCEPT, "application/octet-stream")
195        .download_to(&tmp_tarball)?;
196
197    let bin_name = std::path::PathBuf::from("self_update_bin");
198    self_update::Extract::from_source(&tmp_tarball_path)
199        .archive(self_update::ArchiveKind::Tar(Some(self_update::Compression::Gz)))
200        .extract_file(&tmp_dir.path(), &bin_name)?;
201
202    let new_exe = tmp_dir.path().join(bin_name);
203    self_replace::self_replace(new_exe)?;
204
205    Ok(())
206}
207```
208
209### Multi-file / non-executable install
210
211The high-level `update()` flow replaces a single executable. To update a tool that ships **more
212than one file** (a binary plus sidecar libraries/resources), or to install files that aren't the
213running executable, download and extract the whole archive yourself and then install the files
214with `MoveAll`, which applies a set of `(source -> dest)` moves **transactionally**: either every
215move succeeds, or — on the first failure — all already-applied moves are rolled back, so a failed
216update can't leave a half-installed tool. Because it uses `rename` (which can't cross
217filesystems), the source files, every destination, and the temp dir must all be on the same
218filesystem.
219
220**NOTE**: this example extracts a `.tar.gz`, which requires both the `archive-tar` and
221`compression-tar-gz` features.
222
223```rust
224# #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
225fn update() -> Result<(), Box<dyn std::error::Error>> {
226    let tmp_dir = tempfile::TempDir::new()?;
227    let tarball_path = tmp_dir.path().join("release.tar.gz");
228    // ... download the archive to `tarball_path` (see the example above) ...
229
230    // The extracted files are renamed into place, so the staging dir (the move sources) and the
231    // stash dir must be on the same filesystem as the destinations — create both next to them
232    // rather than in $TMPDIR. The `/usr/local` paths below are illustrative; use destinations
233    // and temp dirs you have write access to (these may require elevated privileges).
234    let staging = tempfile::TempDir::new_in("/usr/local")?;
235    self_update::Extract::from_source(&tarball_path)
236        .archive(self_update::ArchiveKind::Tar(Some(self_update::Compression::Gz)))
237        .extract_into(staging.path())?;
238
239    // Install several files atomically (all-or-nothing).
240    let stash = tempfile::TempDir::new_in("/usr/local")?;
241    self_update::MoveAll::from_temp(stash.path())
242        .add(staging.path().join("app"), "/usr/local/bin/app")
243        .add(staging.path().join("libapp.so"), "/usr/local/lib/libapp.so")
244        .commit()?;
245    Ok(())
246}
247```
248
249### Bundle installs (macOS `.app`)
250
251A macOS application is a *directory* bundle, so replacing only the executable inside
252`MyApp.app/Contents/MacOS/` leaves stale resources behind and breaks the bundle's code signature.
253Set `bundle_path_in_archive` to name the bundle directory inside the release archive and the whole
254tree is installed as one unit:
255
256```rust
257# #[cfg(feature = "github")]
258fn update() -> Result<(), Box<dyn std::error::Error>> {
259    self_update::backends::github::Update::configure()
260        .repo_owner("me")
261        .repo_name("myapp")
262        .bin_name("myapp")
263        .current_version(self_update::cargo_crate_version!())
264        // The bundle directory inside the archive; `{{ bin }}` / `{{ target }}` / `{{ version }}`
265        // substitutions work here exactly as in `bin_path_in_archive`.
266        .bundle_path_in_archive("MyApp.app")
267        // Optional on macOS: defaults to the nearest `.app` ancestor of the running executable.
268        .bundle_install_path("/Applications/MyApp.app")
269        .build()?
270        .update()?;
271    Ok(())
272}
273```
274
275How the swap works, and what it guarantees:
276
277- The archive is extracted in full into a temporary directory **inside the install path's parent**,
278  so every rename is on one filesystem (there is no cross-device fallback, and the parent needs
279  room for one more copy of the bundle). A symlinked `bundle_install_path` is resolved first, so the
280  tree behind the link is replaced, the link survives, and staging still lands beside the real tree.
281- The installed tree is stashed, then the staged tree is renamed into place. A failure at any step
282  restores the original bundle, and the error names the bundle path. Once the final rename lands the
283  update is committed.
284- When the running executable lives inside the bundle it is renamed aside first, so the old tree
285  holds no running image. After a successful update the running executable's path holds the new
286  bundle's executable, and the process can relaunch itself with `restart()` (see
287  [Restarting after an update](#restarting-after-an-update)).
288- Bundle mode replaces a directory, so combining it with an explicit `bin_install_path` or
289  `bin_path_in_archive` is rejected by `build()` (`Error::ConflictingConfig`), and setting
290  `bundle_install_path` without `bundle_path_in_archive` is an `Error::MissingField` rather than a
291  silently discarded path. `bin_name` is still required: it selects the asset and feeds `{{ bin }}`.
292- The `verify_binary` hook receives the **staged bundle root**, which is what
293  `codesign --verify --deep` wants; a rejection aborts before anything is replaced.
294- The crate never signs, notarizes, or staples: ship an already-signed (and, for Gatekeeper,
295  notarized) `.app` and the swap preserves exactly what you shipped. A quarantined app running from
296  a read-only App Translocation mount cannot update itself in place; that is detected up front as
297  `Error::AppTranslocated`, and the fix is to move the app (which clears the quarantine) and
298  relaunch it.
299
300Directory bundles on linux and windows go through the same code path. On windows the swap fails,
301and rolls back, if the process holds files inside the bundle open beyond its own executable (a DLL
302loaded from the bundle, for example). `.deb` / `.msi` packages are a different shape entirely --
303hand the downloaded file to `dpkg -i` / `msiexec /i` yourself; the crate's replace-and-verify
304semantics do not apply to a system installer.
305
306### Checksum verification
307
308With the `checksums` feature, the crate verifies the downloaded artifact against a digest
309**before** installing — a mismatch aborts the update. Two sources of digests, independently
310applied (when both apply, both must pass):
311
312- **Release-published digests, automatic.** GitHub publishes a `sha256:<hex>` digest per release
313  asset; the updater verifies the download against it whenever the selected asset carries one.
314  This is on by default with the `checksums` feature — no configuration needed — and can be
315  disabled with `verify_release_digest(false)`. The other backends' APIs publish no digest, so
316  the check is a no-op there (a custom `ReleaseSource` can supply one via
317  `ReleaseAsset::with_digest`). Note this is an *integrity* check only — the forge recomputes
318  the digest if an asset is replaced — so it is not a substitute for the `signatures` feature.
319- **A known digest you pass explicitly** (e.g. one published in a `SHA256SUMS` file alongside
320  the release) via `verify_checksum`. The algorithm is chosen by the `Checksum` variant
321  (`Sha256` / `Sha512`).
322
323Both complement the `signatures` feature (zipsign), which verifies authenticity rather than a
324published digest.
325
326```rust
327# #[cfg(feature = "checksums")]
328fn update() -> Result<(), Box<dyn std::error::Error>> {
329    self_update::backends::github::Update::configure()
330        .repo_owner("jaemk")
331        .repo_name("self_update")
332        .bin_name("github")
333        .current_version(self_update::cargo_crate_version!())
334        // hex digest, obtained out of band (e.g. parsed from the release's SHA256SUMS)
335        .verify_checksum(self_update::Checksum::Sha256("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".into()))
336        .build()?
337        .update()?;
338    Ok(())
339}
340```
341
342### Checking for an update without installing
343
344To check whether a newer release exists without downloading or installing anything, call
345`is_update_available()` on the built updater. It fetches the release listing and returns the newest
346strictly-newer `Release` (or `None` when up to date):
347
348```rust
349fn check() -> Result<(), Box<dyn std::error::Error>> {
350    let update = self_update::backends::github::Update::configure()
351        .repo_owner("jaemk")
352        .repo_name("self_update")
353        .bin_name("github")
354        .current_version(self_update::cargo_crate_version!())
355        .build()?;
356
357    match update.is_update_available()? {
358        Some(release) => println!("update available: {}", release.version()),
359        None => println!("already up to date"),
360    }
361    Ok(())
362}
363```
364
365### Restarting after an update
366
367After `update()` returns [`VersionStatus::Updated`](crate::VersionStatus::Updated) the on-disk
368executable has been replaced, but the running process keeps executing the old code until it exits.
369To relaunch into the new binary immediately, use the [`restart`](crate::restart) module:
370`restart::restart()` re-runs with the current arguments, and `restart::restart_with(args)` re-runs
371with a fresh argument list (e.g. to drop an `--upgrade` flag so the new process does not update
372again). On unix the process image is replaced with `exec` (the PID is preserved); on windows the new
373binary is spawned and the current process exits. See the module docs for the platform details.
374
375### Permissions
376
377The crate never escalates privileges. There is no sudo re-exec, no polkit interaction, and no UAC
378prompt. Privilege escalation is always the caller's choice.
379
380An install into an unwritable location fails with
381[`Error::InstallPathNotWritable`](crate::errors::Error::InstallPathNotWritable) naming the path
382(the configured `bin_install_path`). Any other IO failure at the install step surfaces as
383[`Error::Io`](crate::errors::Error::Io) with a message naming the install path, so the path is
384visible in the error regardless of the kind.
385
386Setting `check_install_path_writable(true)` on the builder opts into a preflight probe that runs
387immediately before the download. Only a definite `PermissionDenied` refusal errors early;
388indeterminate results (a missing parent directory, an unusual filesystem) are treated as "proceed"
389and let the real install step surface the outcome. The default is `false`.
390
391```rust,no_run
392fn update() -> Result<(), Box<dyn std::error::Error>> {
393    match self_update::backends::github::Update::configure()
394        .repo_owner("owner")
395        .repo_name("repo")
396        .bin_name("app")
397        .current_version(self_update::cargo_crate_version!())
398        .check_install_path_writable(true)
399        .build()?
400        .update()
401    {
402        Ok(status) => println!("updated: {}", status.version()),
403        Err(self_update::Error::InstallPathNotWritable { .. }) => {
404            // The install path is not writable by this process. Elevation is the
405            // application's choice: re-run under sudo, spawn a UAC-elevated child, etc.
406            // Use the `restart` module for the exec/spawn mechanics when relaunching
407            // with a modified argument list.
408            eprintln!("install path not writable; re-run with elevated privileges");
409        }
410        Err(e) => return Err(e.into()),
411    }
412    Ok(())
413}
414```
415
416### Periodic update checks
417
418Every `update()` / `is_update_available()` call makes a network request. To avoid checking on every
419run, gate the check behind [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard), a small
420stamp-file guard: `should_check()` reports whether the configured interval has elapsed since the
421last recorded check, and `record_check()` stamps the current time. The caller owns the stamp-file
422path. It is a guard, not a scheduler -- no threads or timers, and no extra dependencies. See the
423[`check_interval`](crate::check_interval) module for the semantics.
424
425### Authentication
426
427Every forge backend's `Update` **and** `ReleaseList` builder -- github, gitlab, gitea, gitee, eight
428builders in all -- takes an authorization token. A token is what reaches a private repository at
429all, and what lifts the host's anonymous request budget (see
430[Rate limits and `Error::RateLimited`](#rate-limits-and-errorratelimited) below). There are two
431setters:
432
433* `auth_token(t)` -- a token your application already holds.
434* `auth_token_from_env()` -- take it from the backend's conventional environment variables, using
435  the first that is set and non-empty (surrounding whitespace is trimmed); a variable that *is* set
436  but is not valid UTF-8 is treated the same as unset, since it could not become an HTTP header
437  value either way:
438  * **github**: `GH_TOKEN`, then `GITHUB_TOKEN` (matching the `gh` CLI's documented precedence).
439  * **gitlab**: `GITLAB_TOKEN`.
440  * **gitea**: `GITEA_TOKEN`.
441  * **gitee**: `GITEE_TOKEN`.
442
443The lookup happens when you call `auth_token_from_env()`, not at request time: it reads the process
444environment exactly once, at that call. A `std::env::set_var` made afterward -- before `build()`,
445before `update()` -- has no effect on an already-built value; call the setter again (or set the
446variable earlier) if that ordering matters to you.
447
448```rust
449# #[cfg(feature = "github")]
450# fn run() -> Result<(), Box<dyn std::error::Error>> {
451let status = self_update::backends::github::Update::configure()
452    .repo_owner("jaemk")
453    .repo_name("self_update")
454    .bin_name("self_update_example")
455    .current_version(self_update::cargo_crate_version!())
456    // Uses a token when the environment supplies one; unauthenticated when it does not.
457    .auth_token_from_env()
458    .build()?
459    .update()?;
460# let _ = status;
461# Ok(())
462# }
463```
464
465**Precedence: an explicit `auth_token(..)` always wins, in either call order.** The environment is a
466*fallback* that only fills an unset token, so `auth_token(t).auth_token_from_env()` and
467`auth_token_from_env().auth_token(t)` both end up with `t`, and an ambient `*_TOKEN` can never
468displace the credential your application provisioned. When no variable is set the call is a no-op --
469the token is left as it was and the request goes out exactly as before -- so it is safe to place
470unconditionally in an application that also runs outside CI or a corporate network.
471
472`has_auth_token()` (on the same eight builders) reports whether an authorization token is
473*configured* on this builder, from either setter. This is configuration, not a prediction: at
474request time the token is withheld unless the URL's host matches the configured API host or an
475`allow_auth_host` entry over https (loopback is allowed over plain http, for a local mirror or a
476test stub), and a user-supplied `Authorization` header via `request_header` takes precedence over
477it, silently. On gitea an env-sourced token is additionally withheld unless the configured host was
478acknowledged (below). None of that is reflected by `has_auth_token()` -- it reports presence only,
479never validity and never the value -- the builders' `Debug` renders the token as `"<token>"`, so
480logging a builder does not leak an ambient CI credential.
481
482Reading the environment is opt-in: the crate never does it on its own, since the configured API base
483can be a self-hosted host and sending a user's token there should be your decision. Two caveats to
484"safe to call unconditionally":
485
486- A variable that is *set* but stale, expired, revoked, or scoped to a different resource makes the
487  request **fail** where an anonymous request against a public repository would have succeeded --
488  typically a generic `Error::Unauthorized`, with nothing in the error naming the environment as the
489  cause. If a working update check starts failing right after you add `auth_token_from_env()`,
490  check the variable's value first.
491- A token that *is* picked up but cannot be encoded as an HTTP header value (a stray newline, for
492  example) is not caught by `build()` -- it surfaces as
493  [`Error::InvalidAuthToken`](crate::errors::Error::InvalidAuthToken) at **request** time, and that
494  error's message does not mention the environment either.
495
496Both of the crate's own diagnostics about the token it picked up -- the "using the auth token from
497$X" pickup and the off-host warning below -- are emitted via `log::debug!` / `log::warn!` only.
498Neither prints anything on its own; they are invisible unless your application has installed a
499`log` implementation (`env_logger`, `tracing-log`, etc.).
500
501**The variable set does not change with the host.** A custom `api_base_url` / `host` -- GitHub
502Enterprise, a self-hosted GitLab -- is still served by exactly the variables above, so an ambient
503`GITHUB_TOKEN` is sent to whatever host the builder points at. When an env-sourced token is about to
504be bound to a host other than the backend's canonical one (`api.github.com`, `gitlab.com`,
505`gitee.com`), `build()` emits a `log::warn!` naming the host, and still sends the token -- on
506github/gitlab/gitee this is a warning, not a block. If the off-canonical host is a deliberate GitHub
507Enterprise / self-hosted GitLab target, either silence the warning by acknowledging the host with
508`allow_auth_host(..)`, or skip the environment lookup and set the token explicitly instead:
509`auth_token(std::env::var("GITLAB_TOKEN")?)`. Note also that `gh` reads `GH_ENTERPRISE_TOKEN` /
510`GITHUB_ENTERPRISE_TOKEN` for a GitHub Enterprise host and this crate does not, so an enterprise
511`api_base_url` still needs one of the variables above (or an explicit `auth_token(..)`).
512
513**gitea is the exception to warn-and-send.** It is always self-hosted, so it has no canonical host
514to compare an env-sourced token's destination against. Rather than send `GITEA_TOKEN` to whatever
515host the application happens to be pointed at with no signal at all, gitea *withholds* the token
516instead: the request goes out anonymous, `build()` still returns `Ok`, and a `log::warn!` names the
517host and the same two remedies as above. Get it sent anyway by acknowledging the host, either with
518`allow_auth_host(host)` or by setting the token explicitly with `auth_token(..)` (which always takes
519precedence, on every backend).
520
521GitHub answers **404**, not 401 or 403, when a token cannot see a private repository -- it hides the
522repository's existence rather than distinguishing "forbidden" from "not found". That 404 surfaces as
523[`Error::NotFound`](crate::errors::Error::NotFound), so a repository you can normally read looks
524like it does not exist rather than like a permission problem; check the token's scope before
525assuming a typo in the repo name. Reading a private repository's releases needs the classic `repo`
526scope (a fine-grained token needs `Contents: Read-only`) -- the "no scopes needed" note below is for
527lifting a *public* repository's rate limit only.
528
529`CI_JOB_TOKEN` is deliberately **not** read on gitlab, even though every GitLab CI job exports it:
530this backend sends `Authorization: Bearer`, which is not GitLab's job-token mechanism (the
531`JOB-TOKEN` header / `job_token` parameter), and job tokens are project-scoped -- reading it would
532turn a working anonymous fetch of a public project into a 401/403 inside CI. Pass it explicitly with
533`auth_token(..)` if you want it.
534
535### Rate limits and `Error::RateLimited`
536
537A rate-limited response surfaces as [`Error::RateLimited`](crate::errors::Error::RateLimited),
538distinct from the `Error::Unauthorized` a genuine credential failure produces -- the rule below is
539the same on **every** backend, not just github (the numbers in [GitHub rate
540limits](#github-rate-limits) below are github-specific; the classification is not). A response with
541headers in hand is classified as `RateLimited` when it is a **429** (RFC 6585 defines that status as
542rate limiting, so it always lands here, with or without quota headers), or a **403** carrying either
543a zero remaining-quota header (`x-ratelimit-remaining: 0`, or gitlab's `RateLimit-Remaining: 0`) or a
544usable `Retry-After` -- that last case is GitHub's *secondary* rate limit, which answers 403 +
545`Retry-After` while `x-ratelimit-remaining` is still nonzero. A bare 403 with no such header stays
546`Unauthorized`.
547
548Back off by [`Error::rate_limit_delay()`](crate::errors::Error::rate_limit_delay), which resolves
549the wait to an `Option<Duration>`: the server's `Retry-After` when it sent one, otherwise
550`reset_at` minus now, and `None` when the window has already elapsed or nothing is known. Reading
551the raw fields instead is the footgun -- on GitHub's *primary* limit only `x-ratelimit-reset` is
552sent, so `retry_after.unwrap_or_default()` sleeps zero and burns more quota. Both server-supplied
553values are clamped to a 24h ceiling; beyond it they resolve to `None`, so a hostile `Retry-After`
554cannot park an update channel indefinitely -- but the wait can legitimately be *up to* that 24h
555ceiling, so blocking a thread on it is rarely the right call for an interactive application (see the
556example below).
557
558The retry/backoff setters do **not** apply to a `RateLimited` response. `Error::RateLimited` is
559never retried: the wait is the server's to dictate (`Retry-After`, or the reset header), and it can
560be far longer than any backoff this crate would apply, so the error is returned immediately and the
561decision to sleep, reschedule, or give up stays with the caller instead of being spent inside the
562loop.
563
564```rust
565# #[cfg(feature = "github")]
566fn check() -> Result<(), Box<dyn std::error::Error>> {
567    let update = self_update::backends::github::Update::configure()
568        .repo_owner("jaemk")
569        .repo_name("self_update")
570        .bin_name("self_update_example")
571        .current_version(self_update::cargo_crate_version!())
572        .auth_token_from_env()
573        .build()?;
574
575    match update.update() {
576        Ok(status) => println!("update status: `{}`", status.version()),
577        Err(err @ self_update::Error::RateLimited { .. }) => {
578            // rate_limit_delay() can resolve to a wait as long as 24h, so blocking this thread on
579            // it is rarely the right call for an interactive app. Skip this run and let the next
580            // scheduled check (e.g. through `UpdateCheckGuard` above) try again, rather than
581            // sleeping here -- if you do want to block instead, sleep on `err.rate_limit_delay()`
582            // and retry `update.update()` yourself.
583            let _ = err.rate_limit_delay();
584            println!("rate limited; retrying on the next scheduled check");
585        }
586        Err(err) => return Err(err.into()),
587    }
588    Ok(())
589}
590```
591
592### GitHub rate limits
593
594Requests to the GitHub REST API are rate limited by GitHub itself, not by this crate:
595
596- **Unauthenticated** requests are limited to **60 per hour per source IP**; **authenticated**
597  requests (a token via `auth_token` / `auth_token_from_env`, see
598  [Authentication](#authentication)) get **5000 per hour**. A token needs no scopes to raise the
599  limit for a public repository (a private repository needs the scope noted above regardless of the
600  limit).
601- That budget is counted **per source IP, not per application**. Behind a shared egress IP -- a
602  NAT'd corporate network, a CI runner pool, a VPN exit -- it is pooled across everyone on that IP
603  and can be spent entirely by other people, so a lightly-used application still sees 403s there.
604- An update check costs **one** API request (the latest-release lookup, or one request per page of a
605  paginated listing). The asset **download** itself is a CDN redirect and does not count against the
606  core API limit.
607- To avoid it: set a token, and check less often -- the
608  [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard) above throttles how often you check.
609
610### Listing releases (`ReleaseList`)
611
612Each built-in backend exposes a `ReleaseList` builder for fetching the list of available releases
613without performing an update. There is **no single unifying `self_update::ReleaseList` type**:
614every backend has its own, distinct `ReleaseList` (the fields and request shape differ per host),
615so they are reached through their backend modules rather than re-exported at the crate root:
616
617* `backends::github::ReleaseList`
618* `backends::gitlab::ReleaseList`
619* `backends::gitea::ReleaseList`
620* `backends::gitee::ReleaseList`
621* `backends::s3::ReleaseList`
622
623The `manifest` backend has no separate `ReleaseList` struct. Its `ManifestSource` is a
624`ReleaseSource` implementation that can be used directly, or listing can be driven through the
625inherent verbs (`get_latest_release`, `get_newer_releases`, `is_update_available`) on a built
626`manifest::Update`.
627
628The custom backend has no `ReleaseList` by design: listing is performed entirely by your
629`ReleaseSource` (or `AsyncReleaseSource`) implementation, which already returns
630`Release` values directly.
631
632### Custom backends
633
634To update from a host the built-in backends (`github`, `gitlab`, `gitea`, `gitee`, `s3`, `manifest`) don't cover —
635another forge, a private artifact registry, a plain HTTP directory — implement the
636`ReleaseSource` trait and drive a full update through the `backends::custom` backend, which reuses
637the crate's compare → select-asset → download → verify → extract → install flow. Only
638`get_releases` (the fetch that says *where releases come from*) is required;
639`get_latest_release` / `get_release_version` are derived from it by default and can be overridden
640when the host has cheaper dedicated endpoints. You build `Release`s with `Release::builder` and
641`ReleaseAsset::new`; the `ReleaseUpdate` trait stays sealed.
642
643`ReleaseSource` is **synchronous**. For a natively-async source, implement `AsyncReleaseSource`
644(the same fetches as `async fn`) and drive it through
645`backends::custom::AsyncUpdate` + `build_async()`; to reuse a
646`Clone` sync source from the async API, wrap it in
647`backends::custom::Blocking`.
648
649```rust
650use self_update::{Release, ReleaseAsset, ReleaseSource, cargo_crate_version};
651
652struct MyHost;
653impl ReleaseSource for MyHost {
654    fn get_releases(&self) -> self_update::Result<Vec<Release>> {
655        Ok(vec![Release::builder()
656            .version("1.2.3")
657            .asset(ReleaseAsset::new("app-x86_64-unknown-linux-gnu.tar.gz", "https://host/app.tar.gz"))
658            .build()?])
659    }
660}
661
662fn update() -> Result<(), Box<dyn std::error::Error>> {
663    let status = self_update::backends::custom::Update::configure()
664        .source(MyHost)
665        .bin_name("app")
666        .current_version(cargo_crate_version!())
667        .build()?
668        .update()?;
669    println!("custom backend update status: `{}`!", status.version());
670    Ok(())
671}
672```
673
674### Async
675
676With the `async` feature, every built-in backend's `Update` builder gains a `build_async()` that
677returns a distinct `AsyncUpdate` wrapper (one per backend). Its async (`*_async`) verbs —
678`update_async()`, `update_extended_async()`, `get_latest_release_async()`,
679`get_newer_releases_async()`, `get_release_version_async()`, and `is_update_available_async()` — are
680**inherent methods** on that wrapper, so a `tokio` application can update without wrapping the
681blocking calls in `spawn_blocking` and without importing any trait. Crucially, the `AsyncUpdate`
682wrapper does **not** expose the blocking verbs: calling `.update()` on an async-built updater is a
683compile error, so the old footgun of accidentally running a blocking update from an async context
684is gone. The blocking API is unchanged; the async path is purely additive. It is **tokio-only and
685requires `reqwest`** -- ureq and reqwest can coexist (reqwest serves the async path, and the sync
686API prefers reqwest when both are present); the only invalid configuration is `async` without
687`reqwest`. Network IO becomes async, and the extract/replace tail runs on
688`tokio::task::spawn_blocking` so it does not block the executor.
689
690```rust
691# #[cfg(feature = "async")]
692async fn update() -> Result<(), Box<dyn std::error::Error>> {
693    let status = self_update::backends::github::Update::configure()
694        .repo_owner("jaemk")
695        .repo_name("self_update")
696        .bin_name("github")
697        .current_version(self_update::cargo_crate_version!())
698        .build_async()?
699        .update_async()
700        .await?;
701    println!("Update status: `{}`!", status.version());
702    Ok(())
703}
704```
705
706The `AsyncUpdate` wrapper exposes only the `*_async` verbs; the blocking `update()` is not a method
707on it, so accidentally calling it from async code does not compile. The following block is
708`compile_fail` for exactly that reason — `update` is not a method on the async wrapper (this block
709is intentionally not feature-gated: gating it behind `cfg(feature = "async")` would make it an empty,
710successfully-compiling doctest in the crate's no-`async` test lanes, which a `compile_fail` block
711must never do):
712
713```rust,compile_fail
714fn wont_compile() -> Result<(), Box<dyn std::error::Error>> {
715    let updater = self_update::backends::github::Update::configure()
716        .repo_owner("jaemk")
717        .repo_name("self_update")
718        .bin_name("github")
719        .current_version(self_update::cargo_crate_version!())
720        .build_async()?;
721    // `update()` is the BLOCKING verb; it is not exposed on the async `AsyncUpdate` wrapper.
722    updater.update()?;
723    Ok(())
724}
725```
726
727### Custom HTTP client
728
729The `.timeout()` / `.request_header()` / `.retries()` builder knobs cover most transport needs, but
730for full control — custom TLS roots / mTLS, connection pooling, redirect policy, proxy-with-auth, or
731simply reusing your application's existing client — you can hand the crate a **pre-built client**.
732It is used for both the release listing and the download. The client-specific convenience setters
733are `reqwest_client` (a blocking `reqwest::blocking::Client`, used by the blocking API),
734`reqwest_async_client` (an async `reqwest::Client`, used by the `*_async` verbs), and `ureq_agent`
735(a `ureq::Agent`); each wraps your client behind the crate's object-safe HTTP transport trait. The
736compiled client crate(s) are re-exported (`self_update::reqwest` / `self_update::ureq`) so you don't
737need a separate dependency to name the type. (Since the transport is a runtime trait seam, `reqwest`
738and `ureq` are no longer mutually exclusive — both can be enabled, and the sync API prefers reqwest
739when both are present.) For test doubles or fully custom transport, inject any type that implements
740the object-safe trait directly via `.http_client(Arc<dyn HttpClient>)` (sync) or
741`.http_client_async(Arc<dyn AsyncHttpClient>)` (async); see the [`http_client`](crate::http_client)
742module for the trait definitions.
743
744When you inject a client, `.request_header()` still applies, and `.retries()` still applies to the
745release-listing requests and to the download's request-establishment phase (a mid-stream failure
746is not retried, as that would corrupt the partially-written destination), and for `reqwest` the per-request
747`.timeout()` is layered on too; but `HTTP(S)_PROXY` env and the crate's TLS feature are left entirely
748to your client (and a `ureq::Agent` owns its own timeout, so `.timeout()` does not apply to an
749injected agent — configure it on the agent). `reqwest_client` feeds the sync verbs and
750`reqwest_async_client` the async ones — injecting only one and calling the other half just uses the
751crate's per-call client for that half.
752
753A fully custom transport also owns the job of **classifying** a non-2xx response. Prefer
754[`Error::http_status_error_with_headers(status, url, &headers)`](crate::errors::Error::http_status_error_with_headers)
755over the header-blind [`Error::http_status_error`](crate::errors::Error::http_status_error): the
756header-blind form still maps a **429** to
757[`Error::RateLimited`](crate::errors::Error::RateLimited) -- the status alone is the signal. What it
758cannot do is promote a **403** (with no headers in hand a 403 stays `Unauthorized`) or recover the
759`reset_at` / `retry_after` fields, so `rate_limit_delay()` on one of its errors is always `None`.
760See [Rate limits and `Error::RateLimited`](#rate-limits-and-errorratelimited) above for the full
761classification rule. The built-in reqwest and ureq clients (including an injected `ureq::Agent`)
762all use the header-aware form, so they classify identically.
763
764```rust
765# #[cfg(feature = "reqwest")]
766fn update() -> Result<(), Box<dyn std::error::Error>> {
767    let client = self_update::reqwest::blocking::Client::builder()
768        // .add_root_certificate(...) / .proxy(...) / .danger_accept_invalid_certs(...) etc.
769        .build()?;
770    self_update::backends::github::Update::configure()
771        .repo_owner("jaemk")
772        .repo_name("self_update")
773        .bin_name("github")
774        .current_version(self_update::cargo_crate_version!())
775        .reqwest_client(client)
776        .build()?
777        .update()?;
778    Ok(())
779}
780```
781
782### Troubleshooting
783
784**Cross-compilation (`cross` / `cargo-cross`).** `rustls` is the default TLS backend, so
785no additional configuration is needed for cross-compilation: a build on default features
786already uses rustls. If you have explicitly switched to `native-tls` and want to revert,
787remove the `native-tls` feature; `rustls` is active by default.
788
789**TLS certificate errors on Linux (`native-tls` / OpenSSL).** With the native-TLS backend,
790OpenSSL finds the system CA bundle on its own on most distributions. In a minimal environment where
791it can't (some containers, `musl` static builds, or a non-standard cert layout) a request may fail
792with a certificate-verification error. Point OpenSSL at the bundle by exporting `SSL_CERT_FILE`
793(and, if needed, `SSL_CERT_DIR`) before running your program — the paths vary by distribution, e.g.
794on a Debian/Ubuntu base:
795
796```bash
797export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
798export SSL_CERT_DIR=/etc/ssl/certs
799```
800
801Alternatively build with the `rustls` feature, which uses a bundled root store and does not depend
802on the system OpenSSL cert layout.
803
804*/
805
806// Enable the `doc_cfg` feature on docs.rs (nightly-only, guarded by the `docsrs` cfg set via
807// `rustdoc-args = ["--cfg", "docsrs"]` in Cargo.toml). Stable builds are unaffected because
808// the cfg is never set outside of the docs.rs environment.
809#![cfg_attr(docsrs, feature(doc_cfg))]
810// Keep the crate's rustdoc intra-doc links honest: an unresolved `[link]` in any doc comment is a
811// hard error, not a silent warning. The full-crate check is the final barrier during a doc build.
812#![deny(rustdoc::broken_intra_doc_links)]
813
814// The HTTP transport is now an object-safe trait seam (`http_client::HttpClient`), so `reqwest` and
815// `ureq` are no longer mutually exclusive — both client impls can be compiled and one is selected at
816// runtime via `default_client()` (reqwest preferred when both are on). The genuine no-client case is
817// a `compile_error!` in `http_client/mod.rs`. TLS features can also coexist: when both `native-tls`
818// and `rustls` are enabled the per-call builders prefer rustls.
819
820// The async API is reqwest-only — ureq has no async story. With the trait seam the two clients are
821// no longer mutually exclusive, so `async` + `ureq` together is fine (async uses reqwest for the
822// async path, ureq serves the sync path). The genuine bad case is `async` without the `reqwest`
823// client at all; the `async` feature already implies `reqwest` (see Cargo.toml), so this guard only
824// fires if that implication is ever broken.
825#[cfg(all(feature = "async", not(feature = "reqwest")))]
826compile_error!("feature `async` requires the `reqwest` client - `ureq` has no async API");
827
828pub use http;
829// Re-export the crates whose types appear in the async transport-trait signatures
830// (`AsyncHttpClient` / `AsyncHttpResponse` name `BoxFuture`, `BoxStream`, and `Bytes`), so a
831// custom async transport can be implemented without adding `futures-util`/`bytes` as direct
832// dependencies (and without a version-skew risk against the ones this crate links).
833#[cfg(feature = "async")]
834#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
835pub use bytes;
836#[cfg(feature = "async")]
837#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
838pub use futures_util;
839// Re-export the selected HTTP client so callers can name the types accepted by the client-injection
840// setters (`reqwest_client` / `reqwest_async_client` / `ureq_agent`) without a separate dependency.
841#[cfg(feature = "reqwest")]
842#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
843pub use reqwest;
844#[cfg(feature = "signatures")]
845#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
846pub use update::verify_signature;
847#[cfg(feature = "async")]
848#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
849pub use update::{AsyncReleaseSource, AsyncReleaseUpdate};
850pub use update::{
851    Release, ReleaseAsset, ReleaseBuilder, ReleaseSource, ReleaseStatus, ReleaseUpdate, Releases,
852    UpdateConfig, UpdateStrategy,
853};
854#[cfg(feature = "ureq")]
855#[cfg_attr(docsrs, doc(cfg(feature = "ureq")))]
856pub use ureq;
857
858/// Re-export of the [`zipsign_api`] crate, whose [`PUBLIC_KEY_LENGTH`] constant defines the
859/// size of the ed25519 verifying keys accepted by the `verifying_keys` builder methods.
860///
861/// [`PUBLIC_KEY_LENGTH`]: zipsign_api::PUBLIC_KEY_LENGTH
862#[cfg(feature = "signatures")]
863#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
864pub use zipsign_api;
865
866/// An ed25519ph verifying key used to validate a signed download (see the `signatures` feature).
867///
868/// This is a convenience alias for the fixed-size key array accepted by the `verifying_keys`
869/// builder methods, so consumers don't need to depend on `zipsign-api` directly.
870///
871/// # Compile-time embedding
872///
873/// The typical way to supply a key is to embed it at compile time:
874///
875/// ```rust,ignore
876/// const VERIFYING_KEY: self_update::VerifyingKey =
877///     *include_bytes!("path/to/key.pub");
878/// ```
879///
880/// The file must be exactly 32 raw bytes (the ed25519 public key in wire format).
881/// zipsign key files are raw 32-byte ed25519 public keys, not PEM.
882/// If the file length does not match, Rust will emit a compile error because
883/// the array size is fixed at `PUBLIC_KEY_LENGTH` (32).
884///
885/// # Key rotation
886///
887/// When rotating signing keys, sign new releases with both the old key and the
888/// new key.  Old binaries, which embed only the old key, can still verify and
889/// update because the archive carries both signatures.  zipsign uses any-of
890/// semantics: verification passes as soon as any (key, signature) pair matches.
891/// New binaries embed only the new key.  Once the transition window has passed
892/// and no old binaries remain in the field, releases only need the new key's
893/// signature.
894#[cfg(feature = "signatures")]
895#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
896pub type VerifyingKey = [u8; zipsign_api::PUBLIC_KEY_LENGTH];
897
898#[cfg(feature = "progress-bar")]
899use indicatif::{ProgressBar, ProgressStyle as IndicatifProgressStyle};
900use log::debug;
901#[cfg(feature = "progress-bar")]
902use std::cmp::min;
903use std::fs;
904use std::io;
905use std::path;
906
907#[macro_use]
908mod macros;
909pub mod backends;
910pub mod check_interval;
911#[cfg(feature = "checksums")]
912mod checksum;
913pub mod errors;
914pub mod http_client;
915pub mod restart;
916mod tls;
917pub mod update;
918pub mod version;
919
920/// An opaque TLS root CA certificate, supplied to a backend builder or a [`Download`] via the
921/// `add_root_certificate` setter so the crate-built HTTP client trusts a private/internal CA.
922/// Construct with [`Certificate::from_pem`](crate::Certificate::from_pem) or
923/// [`Certificate::from_der`](crate::Certificate::from_der); the bytes are validated when the client
924/// is built, not at construction.
925pub use tls::Certificate;
926
927/// Re-export the crate's [`Error`] and [`Result`] at the crate root,
928/// so consumers (and `ReleaseSource` implementors) can write `self_update::Result<T>` /
929/// `self_update::Error` without naming the `errors` module.
930pub use errors::{Error, Result};
931
932/// A checksum variant (`Sha256` / `Sha512`) used with `verify_checksum` to validate a downloaded
933/// artifact against a known digest before installation. Requires the `checksums` feature.
934#[cfg(feature = "checksums")]
935#[cfg_attr(docsrs, doc(cfg(feature = "checksums")))]
936pub use checksum::Checksum;
937
938use http_client::header;
939
940/// The User-Agent sent on the crate's own requests (API listings and downloads) when the caller
941/// has not set one via `request_header`. One shared value so every backend and the standalone
942/// [`Download`] identify themselves the same way regardless of the compiled HTTP client.
943pub(crate) const DEFAULT_USER_AGENT: &str = concat!("self-update/", env!("CARGO_PKG_VERSION"));
944
945#[cfg(feature = "progress-bar")]
946pub(crate) const DEFAULT_PROGRESS_TEMPLATE: &str =
947    "[{elapsed_precise}] [{bar:40}] {bytes}/{total_bytes} ({eta}) {msg}";
948#[cfg(feature = "progress-bar")]
949pub(crate) const DEFAULT_PROGRESS_CHARS: &str = "=>-";
950
951/// The download progress-bar style: an `indicatif` `template` plus the `chars` it renders the bar
952/// with. Requires the `progress-bar` feature.
953///
954/// This is a typed pair so the two strings can't be transposed at a call site (the previous setter
955/// took two `impl Into<String>` args in template-then-chars order, which were easy to swap). Build
956/// one with [`ProgressStyle::new`] and pass it to the `Update` builder's `progress_style` or
957/// [`Download::progress_style`].
958///
959/// ```
960/// # #[cfg(feature = "progress-bar")] {
961/// let style = self_update::ProgressStyle::new(
962///     "[{bar:40}] {bytes}/{total_bytes}",
963///     "=>-",
964/// );
965/// # let _ = style;
966/// # }
967/// ```
968#[cfg(feature = "progress-bar")]
969#[cfg_attr(docsrs, doc(cfg(feature = "progress-bar")))]
970#[derive(Clone, Debug)]
971#[non_exhaustive]
972pub struct ProgressStyle {
973    /// The `indicatif` progress-bar template (see `indicatif::ProgressStyle::template`).
974    pub template: String,
975    /// The characters used to render the bar (see `indicatif::ProgressStyle::progress_chars`).
976    pub chars: String,
977}
978
979#[cfg(feature = "progress-bar")]
980impl ProgressStyle {
981    /// Construct a `ProgressStyle` from a `template` and its progress `chars`.
982    pub fn new(template: impl Into<String>, chars: impl Into<String>) -> Self {
983        Self {
984            template: template.into(),
985            chars: chars.into(),
986        }
987    }
988}
989
990/// Get the current target triple.
991///
992/// Returns a target triple (e.g. `x86_64-unknown-linux-gnu` or `i686-pc-windows-msvc`)
993pub fn get_target() -> &'static str {
994    env!("TARGET")
995}
996
997/// Flush a message to stdout and check if they respond `yes`.
998/// Interprets a blank response as yes.
999///
1000/// * Errors:
1001///     * Io flushing
1002///     * User entered anything other than enter/Y/y
1003fn confirm(msg: &str) -> Result<()> {
1004    print_flush!("{}", msg);
1005
1006    let mut s = String::new();
1007    // EOF (closed stdin: a daemon, `</dev/null`, CI) reads zero bytes. Treat it as a decline, not a
1008    // blank-line "yes", so an unattended caller that forgot `no_confirm` aborts rather than silently
1009    // proceeding with a self-replacement.
1010    if io::stdin().read_line(&mut s)? == 0 {
1011        return Err(Error::Aborted);
1012    }
1013    let s = s.trim().to_lowercase();
1014    if !s.is_empty() && s != "y" {
1015        return Err(Error::Aborted);
1016    }
1017    Ok(())
1018}
1019
1020/// The lightweight result of [`update`](update::ReleaseUpdate::update) (and its async sibling
1021/// `update_async`): it carries only the version tag of the latest release.
1022///
1023/// Wrapped `String`s are version tags.
1024///
1025/// This is the lightweight counterpart of [`ReleaseStatus`], the richer
1026/// result of [`update_extended`](update::ReleaseUpdate::update_extended) which carries the full
1027/// [`Release`] (name, date, body, assets). Reach for `VersionStatus` when the
1028/// version string is all you need; reach for `ReleaseStatus` when you need the installed release's
1029/// details.
1030#[derive(Debug, Clone)]
1031#[non_exhaustive]
1032pub enum VersionStatus {
1033    UpToDate(String),
1034    Updated(String),
1035}
1036impl VersionStatus {
1037    /// Return the version tag
1038    pub fn version(&self) -> &str {
1039        use VersionStatus::*;
1040        match *self {
1041            UpToDate(ref s) => s,
1042            Updated(ref s) => s,
1043        }
1044    }
1045
1046    /// Returns `true` if `VersionStatus::UpToDate`
1047    pub fn is_up_to_date(&self) -> bool {
1048        matches!(*self, VersionStatus::UpToDate(_))
1049    }
1050
1051    /// Returns `true` if `VersionStatus::Updated`
1052    pub fn is_updated(&self) -> bool {
1053        matches!(*self, VersionStatus::Updated(_))
1054    }
1055}
1056
1057impl std::fmt::Display for VersionStatus {
1058    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1059        use VersionStatus::*;
1060        match *self {
1061            UpToDate(ref s) => write!(f, "UpToDate({})", s),
1062            Updated(ref s) => write!(f, "Updated({})", s),
1063        }
1064    }
1065}
1066
1067/// The archive format of a release asset, as detected from its file extension.
1068///
1069/// `#[non_exhaustive]`, and the `Tar`/`Zip` variants are gated on the `archive-tar` / `archive-zip`
1070/// features: if the matching feature is off the variant does not exist and `detect_archive` for
1071/// that extension returns [`Error::ArchiveNotEnabled`] instead.
1072#[derive(Debug, Clone, Copy, PartialEq)]
1073#[non_exhaustive]
1074pub enum ArchiveKind {
1075    /// A tarball, optionally compressed (e.g. `.tar`, `.tar.gz`, `.tar.xz`). Requires `archive-tar`.
1076    #[cfg(feature = "archive-tar")]
1077    #[cfg_attr(docsrs, doc(cfg(feature = "archive-tar")))]
1078    Tar(Option<Compression>),
1079    /// A bare file, optionally compressed (e.g. a plain binary, or a `.gz` / `.xz` of one).
1080    Plain(Option<Compression>),
1081    /// A zip archive (`.zip`). Requires `archive-zip`.
1082    #[cfg(feature = "archive-zip")]
1083    #[cfg_attr(docsrs, doc(cfg(feature = "archive-zip")))]
1084    Zip,
1085}
1086
1087impl std::fmt::Display for ArchiveKind {
1088    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1089        match self {
1090            #[cfg(feature = "archive-tar")]
1091            ArchiveKind::Tar(Some(Compression::Gz)) => write!(f, "tar.gz"),
1092            #[cfg(feature = "archive-tar")]
1093            ArchiveKind::Tar(Some(Compression::Xz)) => write!(f, "tar.xz"),
1094            #[cfg(feature = "archive-tar")]
1095            ArchiveKind::Tar(None) => write!(f, "tar"),
1096            ArchiveKind::Plain(Some(Compression::Gz)) => write!(f, "gz"),
1097            ArchiveKind::Plain(Some(Compression::Xz)) => write!(f, "xz"),
1098            ArchiveKind::Plain(None) => write!(f, "plain"),
1099            #[cfg(feature = "archive-zip")]
1100            ArchiveKind::Zip => write!(f, "zip"),
1101        }
1102    }
1103}
1104
1105/// A compression codec applied to an [`ArchiveKind`]. `#[non_exhaustive]`.
1106#[derive(Debug, Clone, Copy, PartialEq)]
1107#[non_exhaustive]
1108pub enum Compression {
1109    /// gzip (`.gz`); decoding the stream requires the `compression-tar-gz` feature.
1110    Gz,
1111    /// xz / LZMA2 (`.xz`); decoding the stream requires the `compression-tar-xz` feature.
1112    Xz,
1113}
1114
1115fn detect_archive(path: &path::Path) -> Result<ArchiveKind> {
1116    let ext = path.extension();
1117
1118    debug!("Detecting archive type using extension: {:?}", ext);
1119
1120    let res = match ext {
1121        Some(extension) if extension == std::ffi::OsStr::new("zip") => {
1122            #[cfg(feature = "archive-zip")]
1123            {
1124                debug!("Detected .zip archive");
1125                Ok(ArchiveKind::Zip)
1126            }
1127            #[cfg(not(feature = "archive-zip"))]
1128            {
1129                Err(Error::ArchiveNotEnabled("zip".to_string()))
1130            }
1131        }
1132        Some(extension) if extension == std::ffi::OsStr::new("tar") => {
1133            #[cfg(feature = "archive-tar")]
1134            {
1135                debug!("Detected .tar archive");
1136                Ok(ArchiveKind::Tar(None))
1137            }
1138            #[cfg(not(feature = "archive-tar"))]
1139            {
1140                Err(Error::ArchiveNotEnabled("tar".to_string()))
1141            }
1142        }
1143        Some(extension) if extension == std::ffi::OsStr::new("tgz") => {
1144            #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
1145            {
1146                debug!("Detected .tgz archive");
1147                Ok(ArchiveKind::Tar(Some(Compression::Gz)))
1148            }
1149            #[cfg(all(feature = "archive-tar", not(feature = "compression-tar-gz")))]
1150            {
1151                Err(Error::CompressionNotEnabled("gz".to_string()))
1152            }
1153            #[cfg(not(feature = "archive-tar"))]
1154            {
1155                Err(Error::ArchiveNotEnabled("tar".to_string()))
1156            }
1157        }
1158        Some(extension) if extension == std::ffi::OsStr::new("gz") => match path
1159            .file_stem()
1160            .map(path::Path::new)
1161            .and_then(|f| f.extension())
1162        {
1163            Some(extension) if extension == std::ffi::OsStr::new("tar") => {
1164                #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
1165                {
1166                    debug!("Detected .tar.gz archive");
1167                    Ok(ArchiveKind::Tar(Some(Compression::Gz)))
1168                }
1169                #[cfg(all(feature = "archive-tar", not(feature = "compression-tar-gz")))]
1170                {
1171                    Err(Error::CompressionNotEnabled("gz".to_string()))
1172                }
1173                #[cfg(not(feature = "archive-tar"))]
1174                {
1175                    Err(Error::ArchiveNotEnabled("tar".to_string()))
1176                }
1177            }
1178            // A plain `.gz` single-file asset: decoding the gzip layer requires the
1179            // `compression-tar-gz` feature. Without it, refuse rather than installing the still
1180            // compressed bytes as the binary.
1181            _ => {
1182                #[cfg(feature = "compression-tar-gz")]
1183                {
1184                    Ok(ArchiveKind::Plain(Some(Compression::Gz)))
1185                }
1186                #[cfg(not(feature = "compression-tar-gz"))]
1187                {
1188                    Err(Error::CompressionNotEnabled("gz".to_string()))
1189                }
1190            }
1191        },
1192        Some(extension) if extension == std::ffi::OsStr::new("txz") => {
1193            #[cfg(all(feature = "archive-tar", feature = "compression-tar-xz"))]
1194            {
1195                debug!("Detected .txz archive");
1196                Ok(ArchiveKind::Tar(Some(Compression::Xz)))
1197            }
1198            #[cfg(all(feature = "archive-tar", not(feature = "compression-tar-xz")))]
1199            {
1200                Err(Error::CompressionNotEnabled("xz".to_string()))
1201            }
1202            #[cfg(not(feature = "archive-tar"))]
1203            {
1204                Err(Error::ArchiveNotEnabled("tar".to_string()))
1205            }
1206        }
1207        Some(extension) if extension == std::ffi::OsStr::new("xz") => match path
1208            .file_stem()
1209            .map(path::Path::new)
1210            .and_then(|f| f.extension())
1211        {
1212            Some(extension) if extension == std::ffi::OsStr::new("tar") => {
1213                #[cfg(all(feature = "archive-tar", feature = "compression-tar-xz"))]
1214                {
1215                    debug!("Detected .tar.xz archive");
1216                    Ok(ArchiveKind::Tar(Some(Compression::Xz)))
1217                }
1218                #[cfg(all(feature = "archive-tar", not(feature = "compression-tar-xz")))]
1219                {
1220                    Err(Error::CompressionNotEnabled("xz".to_string()))
1221                }
1222                #[cfg(not(feature = "archive-tar"))]
1223                {
1224                    Err(Error::ArchiveNotEnabled("tar".to_string()))
1225                }
1226            }
1227            // A plain `.xz` single-file asset: decoding the xz layer requires the
1228            // `compression-tar-xz` feature. Without it, refuse rather than installing the still
1229            // compressed bytes as the binary.
1230            _ => {
1231                #[cfg(feature = "compression-tar-xz")]
1232                {
1233                    Ok(ArchiveKind::Plain(Some(Compression::Xz)))
1234                }
1235                #[cfg(not(feature = "compression-tar-xz"))]
1236                {
1237                    Err(Error::CompressionNotEnabled("xz".to_string()))
1238                }
1239            }
1240        },
1241        _ => Ok(ArchiveKind::Plain(None)),
1242    };
1243
1244    debug!("Detected archive type: {:?}", res);
1245
1246    res
1247}
1248
1249/// Extract contents of an encoded archive (e.g. tar.gz) file to a specified directory
1250///
1251/// * Errors:
1252///     * Io - opening files
1253///     * Io - gzip decoding
1254///     * Io - archive unpacking
1255#[derive(Debug)]
1256#[non_exhaustive]
1257pub struct Extract {
1258    source: path::PathBuf,
1259    archive: Option<ArchiveKind>,
1260}
1261/// A [`Read`](io::Read) over an archive's bytes with any single compression layer (`.gz`, `.xz`)
1262/// transparently decoded, so the tar/plain readers above it see the decompressed stream. `Plain`
1263/// is the undecoded passthrough. Each compressed variant exists only when its `compression-tar-*`
1264/// feature is enabled; [`detect_archive`] rejects a compression whose feature is off before this
1265/// is ever built. The gzip layer decodes as a stream; the xz layer is decoded up front into memory
1266/// (the `lzma-rs` decoder is one-shot), which is fine for the modestly sized release artifacts this
1267/// crate downloads to a temp file.
1268enum ArchiveReader {
1269    Plain(fs::File),
1270    // Boxed: a `GzDecoder` is far larger than the other variants, so an unboxed variant would bloat
1271    // every `ArchiveReader` to its size (clippy::large_enum_variant).
1272    #[cfg(feature = "compression-tar-gz")]
1273    Gz(Box<flate2::read::GzDecoder<fs::File>>),
1274    #[cfg(feature = "compression-tar-xz")]
1275    Xz(io::Cursor<Vec<u8>>),
1276}
1277
1278impl io::Read for ArchiveReader {
1279    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1280        match self {
1281            ArchiveReader::Plain(r) => r.read(buf),
1282            #[cfg(feature = "compression-tar-gz")]
1283            ArchiveReader::Gz(r) => r.read(buf),
1284            #[cfg(feature = "compression-tar-xz")]
1285            ArchiveReader::Xz(r) => r.read(buf),
1286        }
1287    }
1288}
1289
1290impl Extract {
1291    /// Create an `Extract`or from a source path. Accepts anything path-like (`&Path`, `PathBuf`,
1292    /// `&str`, …), storing an owned [`PathBuf`](std::path::PathBuf).
1293    pub fn from_source(source: impl AsRef<path::Path>) -> Extract {
1294        Self {
1295            source: source.as_ref().to_path_buf(),
1296            archive: None,
1297        }
1298    }
1299
1300    /// Specify an archive format of the source being extracted. If not specified, the
1301    /// archive format will determined from the file extension.
1302    pub fn archive(&mut self, kind: ArchiveKind) -> &mut Self {
1303        self.archive = Some(kind);
1304        self
1305    }
1306
1307    #[allow(unused_variables)]
1308    fn get_archive_reader(
1309        source: fs::File,
1310        compression: Option<Compression>,
1311    ) -> Result<ArchiveReader> {
1312        match compression {
1313            None => Ok(ArchiveReader::Plain(source)),
1314            #[cfg(feature = "compression-tar-gz")]
1315            Some(Compression::Gz) => Ok(ArchiveReader::Gz(Box::new(flate2::read::GzDecoder::new(
1316                source,
1317            )))),
1318            #[cfg(feature = "compression-tar-xz")]
1319            Some(Compression::Xz) => {
1320                // `lzma-rs` is a one-shot decoder (no streaming `Read` adapter), so decode the
1321                // whole `.xz` stream into memory and hand the tar/plain layer a cursor over it.
1322                let mut input = io::BufReader::new(source);
1323                let mut decoded = Vec::new();
1324                lzma_rs::xz_decompress(&mut input, &mut decoded).map_err(|e| Error::Internal {
1325                    message: format!("failed to decode xz stream: {e}"),
1326                    source: None,
1327                })?;
1328                Ok(ArchiveReader::Xz(io::Cursor::new(decoded)))
1329            }
1330            // A compression whose decoder feature is disabled is rejected by `detect_archive`
1331            // before extraction, so this is unreachable in practice.
1332            #[allow(unreachable_patterns)]
1333            Some(_) => Err(Error::CompressionNotEnabled("unsupported".to_string())),
1334        }
1335    }
1336
1337    /// Extract an entire source archive into a specified path. If the source is a single compressed
1338    /// file and not an archive, it will be extracted into a file with the same name inside of
1339    /// `into_dir`.
1340    ///
1341    /// # Symlink handling
1342    ///
1343    /// Zip entries that are symbolic links (their unix mode carries `S_IFLNK`) are restored as
1344    /// real symlinks on unix, with the link target read from the entry contents. This preserves
1345    /// directory trees that rely on symlinks (for example a macOS `.app` bundle whose
1346    /// `Frameworks/*/Versions/Current` links are load-bearing for code signatures) instead of
1347    /// materializing the target string as a regular file. A symlink target that would escape the
1348    /// extraction root -- an absolute target, or a relative target whose `..` components resolve
1349    /// above `into_dir` -- is rejected with an error, mirroring the zip-slip defense applied to
1350    /// entry names.
1351    ///
1352    /// That per-entry target check is purely lexical, so it cannot see a symlinked intermediate
1353    /// directory that aliases an entry's parent to a shallower physical path (the classic
1354    /// symlinked-parent traversal: an entry `d/sl -> ..` followed by `d/sl/evil -> ../../x`, where
1355    /// the second link is lexically in-bounds yet physically lands above the root). As a backstop,
1356    /// for every zip entry -- symlink or regular file -- after its parent directories are
1357    /// materialized the physical parent is canonicalized and must equal the canonical extraction
1358    /// root joined with the entry's lexical parent; any descent through a symlinked ancestor (or a
1359    /// canonicalize failure) is rejected with an `Error::Internal`, while descent through real
1360    /// directories is unaffected.
1361    ///
1362    /// On non-unix platforms (creating symlinks requires elevated privileges on Windows) symlink
1363    /// entries are written as regular files containing the target path. Tar archives restore
1364    /// symlinks via `tar`'s own unpack logic.
1365    pub fn extract_into(&self, into_dir: impl AsRef<path::Path>) -> Result<()> {
1366        let into_dir = into_dir.as_ref();
1367        let source = fs::File::open(&self.source)?;
1368        let archive = match self.archive {
1369            Some(archive) => archive,
1370            None => detect_archive(&self.source)?,
1371        };
1372
1373        // We cannot use a feature flag in a match arm. To bypass this the code block is
1374        // isolated in a closure and called accordingly.
1375        let extract_into_plain_or_tar = |source: fs::File, compression: Option<Compression>| {
1376            let mut reader = Self::get_archive_reader(source, compression)?;
1377
1378            match archive {
1379                ArchiveKind::Plain(_) => {
1380                    match fs::create_dir_all(into_dir) {
1381                        Ok(_) => (),
1382                        Err(e) => {
1383                            if e.kind() != io::ErrorKind::AlreadyExists {
1384                                return Err(Error::Io(e));
1385                            }
1386                        }
1387                    }
1388                    let file_name = self.source.file_name().ok_or_else(|| Error::Internal {
1389                        message: "Extractor source has no file-name".to_string(),
1390                        source: None,
1391                    })?;
1392                    let mut out_path = into_dir.join(file_name);
1393                    out_path.set_extension("");
1394                    let mut out_file = fs::File::create(&out_path)?;
1395                    io::copy(&mut reader, &mut out_file)?;
1396                }
1397                #[cfg(feature = "archive-tar")]
1398                ArchiveKind::Tar(_) => {
1399                    let mut archive = tar::Archive::new(reader);
1400                    archive.unpack(into_dir)?;
1401                }
1402                #[allow(unreachable_patterns)]
1403                _ => unreachable!(
1404                    "detect_archive() returns in case the proper feature flag is not enabled"
1405                ),
1406            };
1407
1408            Ok(())
1409        };
1410
1411        match archive {
1412            #[cfg(feature = "archive-tar")]
1413            ArchiveKind::Plain(compression) | ArchiveKind::Tar(compression) => {
1414                extract_into_plain_or_tar(source, compression)?;
1415            }
1416            #[cfg(not(feature = "archive-tar"))]
1417            ArchiveKind::Plain(compression) => {
1418                extract_into_plain_or_tar(source, compression)?;
1419            }
1420            #[cfg(feature = "archive-zip")]
1421            ArchiveKind::Zip => {
1422                let mut archive = zip::ZipArchive::new(source)?;
1423
1424                // The destination must exist so its canonical (symlink-free) form can be
1425                // captured once up front. Each entry's physical parent is later checked against
1426                // this root to reject any descent through a symlinked directory (the
1427                // symlinked-parent traversal that a per-entry lexical check cannot catch).
1428                fs::create_dir_all(into_dir)?;
1429                let canonical_root = fs::canonicalize(into_dir)?;
1430
1431                for i in 0..archive.len() {
1432                    let mut file = archive.by_index(i)?;
1433
1434                    // Reject entries whose name would escape `into_dir` (zip-slip). `enclosed_name`
1435                    // returns `None` for an absolute path or one containing `..`.
1436                    let Some(rel_path) = file.enclosed_name() else {
1437                        return Err(Error::Internal {
1438                            message: format!("zip entry has an unsafe path: {:?}", file.name()),
1439                            source: None,
1440                        });
1441                    };
1442                    let output_path = into_dir.join(&rel_path);
1443
1444                    if file.is_dir() {
1445                        fs::create_dir_all(&output_path)?;
1446                        continue;
1447                    }
1448                    if let Some(parent_dir) = output_path.parent() {
1449                        match fs::create_dir_all(parent_dir) {
1450                            Ok(()) => {}
1451                            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
1452                            Err(e) => return Err(Error::Io(e)),
1453                        }
1454
1455                        // Physical-parent verification (symlinked-parent traversal defense).
1456                        // The per-entry lexical target check cannot catch an intermediate
1457                        // symlinked directory (e.g. an earlier `d/sl -> ..` entry) that aliases
1458                        // this entry's parent to a shallower real path, which would let a later
1459                        // entry be created outside the root through the alias. After the parents
1460                        // are materialized, require the parent to canonicalize to exactly its
1461                        // expected location: the canonical root joined with the entry's lexical
1462                        // (only-`Normal`) parent. A prefix/`start_with` check alone is
1463                        // insufficient -- `d/sl` canonicalizes to the root, which trivially lies
1464                        // within the root -- so compare for equality: real directories match
1465                        // their lexical path, while any symlinked ancestor resolves elsewhere and
1466                        // is rejected. A canonicalize failure is treated as a rejection too,
1467                        // matching the zip-slip/escape error style. This guards both the
1468                        // symlink-creation path and the regular-file path below.
1469                        let lexical_parent =
1470                            rel_path.parent().unwrap_or_else(|| path::Path::new(""));
1471                        let expected_parent = canonical_root.join(lexical_parent);
1472                        let physical_parent =
1473                            fs::canonicalize(parent_dir).map_err(|e| Error::Internal {
1474                                message: format!(
1475                                    "could not resolve the parent directory of zip entry {:?}: {}",
1476                                    file.name(),
1477                                    e
1478                                ),
1479                                source: Some(Box::new(e)),
1480                            })?;
1481                        if physical_parent != expected_parent {
1482                            return Err(Error::Internal {
1483                                message: format!(
1484                                    "zip entry {:?} descends through a symlinked directory that escapes the extraction dir",
1485                                    file.name()
1486                                ),
1487                                source: None,
1488                            });
1489                        }
1490                    }
1491
1492                    // On unix, restore a symlink entry as a real symlink instead of writing its
1493                    // target string out as a regular file. The escaping-target check mirrors the
1494                    // `enclosed_name` zip-slip defense above. On non-unix targets this block is
1495                    // compiled out and the entry falls through to the regular-file path.
1496                    #[cfg(unix)]
1497                    if file.is_symlink() {
1498                        use std::ffi::OsStr;
1499                        use std::io::Read;
1500                        use std::os::unix::ffi::OsStrExt;
1501
1502                        let entry_name = file.name().to_string();
1503                        let mut target_bytes = Vec::new();
1504                        file.read_to_end(&mut target_bytes)?;
1505                        let target = path::Path::new(OsStr::from_bytes(&target_bytes));
1506
1507                        // The link lives at `into_dir/rel_path`; a relative target resolves against
1508                        // the link's parent directory. Reject any target (absolute, or `..`
1509                        // climbing above `into_dir`) whose lexical resolution escapes the root.
1510                        let link_parent = rel_path.parent().unwrap_or_else(|| path::Path::new(""));
1511                        if symlink_target_escapes(link_parent, target) {
1512                            return Err(Error::Internal {
1513                                message: format!(
1514                                    "zip symlink entry {:?} points outside the extraction dir: {:?}",
1515                                    entry_name, target
1516                                ),
1517                                source: None,
1518                            });
1519                        }
1520
1521                        // A duplicate entry may already have created a file/link here; `symlink`
1522                        // fails on an existing path, so remove it first (regular-file entries
1523                        // truncate via `File::create`, so match that "last entry wins" behavior).
1524                        match fs::remove_file(&output_path) {
1525                            Ok(()) => {}
1526                            Err(e) if e.kind() == io::ErrorKind::NotFound => {}
1527                            Err(e) => return Err(Error::Io(e)),
1528                        }
1529                        std::os::unix::fs::symlink(target, &output_path)?;
1530                        // A symlink carries no meaningful permission bits of its own; do not call
1531                        // `set_permissions` (it would follow the link and alter the target).
1532                        continue;
1533                    }
1534
1535                    let mut output = fs::File::create(&output_path)?;
1536                    io::copy(&mut file, &mut output)?;
1537                    // Preserve the archived unix permission mode (notably the executable bit) so a
1538                    // binary extracted from a zip is runnable when installed to a custom path.
1539                    // Mask off the setuid/setgid/sticky bits (`0o7000`): a crafted archive must not
1540                    // be able to install a setuid binary, so only the standard `rwx` permission
1541                    // bits (`0o777`) are honored.
1542                    #[cfg(unix)]
1543                    if let Some(mode) = file.unix_mode() {
1544                        use std::os::unix::fs::PermissionsExt;
1545                        fs::set_permissions(
1546                            &output_path,
1547                            fs::Permissions::from_mode(mode & 0o777),
1548                        )?;
1549                    }
1550                }
1551            }
1552        };
1553        Ok(())
1554    }
1555
1556    /// Extract a single file from a source and save to a file of the same name in `into_dir`.
1557    /// If the source is a single compressed file, it will be saved with the name `file_to_extract`
1558    /// in the specified `into_dir`.
1559    ///
1560    /// If the named zip entry is a symbolic link (its unix mode carries `S_IFLNK`), extraction
1561    /// fails with an error rather than writing the link's target string out as the requested file:
1562    /// this API returns a single concrete file, and silently substituting the target path text for
1563    /// the payload would be surprising. Callers who need symlinks preserved should use
1564    /// [`extract_into`](Self::extract_into), which restores them as real links on unix.
1565    pub fn extract_file<T: AsRef<path::Path>>(
1566        &self,
1567        into_dir: impl AsRef<path::Path>,
1568        file_to_extract: T,
1569    ) -> Result<()> {
1570        let into_dir = into_dir.as_ref();
1571        let file_to_extract = file_to_extract.as_ref();
1572        let source = fs::File::open(&self.source)?;
1573        let archive = match self.archive {
1574            Some(archive) => archive,
1575            None => detect_archive(&self.source)?,
1576        };
1577
1578        debug!(
1579            "Attempting to extract {:?} file from {:?}",
1580            file_to_extract, self.source
1581        );
1582
1583        // We cannot use a feature flag in a match arm. To bypass this the code block is
1584        // isolated in a closure and called accordingly.
1585        let extract_file_plain_or_tar = |source: fs::File, compression: Option<Compression>| {
1586            let mut reader = Self::get_archive_reader(source, compression)?;
1587
1588            match archive {
1589                ArchiveKind::Plain(_) => {
1590                    debug!("Copying file directly");
1591                    match fs::create_dir_all(into_dir) {
1592                        Ok(_) => (),
1593                        Err(e) => {
1594                            if e.kind() != io::ErrorKind::AlreadyExists {
1595                                return Err(Error::Io(e));
1596                            }
1597                        }
1598                    }
1599                    let file_name = file_to_extract.file_name().ok_or_else(|| Error::Internal {
1600                        message: "Extractor source has no file-name".to_string(),
1601                        source: None,
1602                    })?;
1603                    let out_path = into_dir.join(file_name);
1604                    let mut out_file = fs::File::create(out_path)?;
1605                    io::copy(&mut reader, &mut out_file)?;
1606                }
1607                #[cfg(feature = "archive-tar")]
1608                ArchiveKind::Tar(_) => {
1609                    debug!("Extracting from tar");
1610
1611                    let mut archive = tar::Archive::new(reader);
1612                    let mut entry = archive
1613                        .entries()?
1614                        .filter_map(|e| e.ok())
1615                        .find(|e| {
1616                            let p = e.path();
1617                            debug!("Archive path: {:?}", p);
1618                            p.ok().filter(|p| p == file_to_extract).is_some()
1619                        })
1620                        .ok_or_else(|| Error::Internal {
1621                            message: format!(
1622                                "Could not find the required path in the archive: {:?}",
1623                                file_to_extract
1624                            ),
1625                            source: None,
1626                        })?;
1627                    entry.unpack_in(into_dir)?;
1628                }
1629                #[allow(unreachable_patterns)]
1630                _ => unreachable!(
1631                    "detect_archive() returns in case the proper feature flag is not enabled"
1632                ),
1633            };
1634
1635            Ok(())
1636        };
1637
1638        match archive {
1639            #[cfg(feature = "archive-tar")]
1640            ArchiveKind::Plain(compression) | ArchiveKind::Tar(compression) => {
1641                extract_file_plain_or_tar(source, compression)?;
1642            }
1643            #[cfg(not(feature = "archive-tar"))]
1644            ArchiveKind::Plain(compression) => {
1645                extract_file_plain_or_tar(source, compression)?;
1646            }
1647            #[cfg(feature = "archive-zip")]
1648            ArchiveKind::Zip => {
1649                let mut archive = zip::ZipArchive::new(source)?;
1650                let file_name = file_to_extract.to_str().ok_or_else(|| Error::Internal {
1651                    message: format!(
1652                        "cannot extract file with a non-UTF-8 path: {:?}",
1653                        file_to_extract
1654                    ),
1655                    source: None,
1656                })?;
1657                let mut file = archive.by_name(file_name)?;
1658
1659                let Some(rel_path) = file.enclosed_name() else {
1660                    return Err(Error::Internal {
1661                        message: format!("zip entry has an unsafe path: {:?}", file.name()),
1662                        source: None,
1663                    });
1664                };
1665                // A symlink entry has no regular-file payload; its "contents" are the link target
1666                // path. Rather than write that target string out as `file_to_extract`, reject it
1667                // (see the rustdoc): use `extract_into` to restore symlinks. Rejecting on every
1668                // platform keeps the single-file API's behavior uniform.
1669                if file.is_symlink() {
1670                    return Err(Error::Internal {
1671                        message: format!(
1672                            "zip entry {:?} is a symlink; use extract_into to restore symlinks",
1673                            file.name()
1674                        ),
1675                        source: None,
1676                    });
1677                }
1678
1679                let output_path = into_dir.join(rel_path);
1680                if let Some(parent_dir) = output_path.parent()
1681                    && let Err(e) = fs::create_dir_all(parent_dir)
1682                    && e.kind() != io::ErrorKind::AlreadyExists
1683                {
1684                    return Err(Error::Io(e));
1685                }
1686
1687                let mut output = fs::File::create(&output_path)?;
1688                io::copy(&mut file, &mut output)?;
1689                // Preserve the archived unix permission mode so the extracted binary is runnable,
1690                // but mask off the setuid/setgid/sticky bits (`0o7000`) so a crafted archive cannot
1691                // install a setuid binary; only the standard `rwx` bits (`0o777`) are honored.
1692                #[cfg(unix)]
1693                if let Some(mode) = file.unix_mode() {
1694                    use std::os::unix::fs::PermissionsExt;
1695                    fs::set_permissions(&output_path, fs::Permissions::from_mode(mode & 0o777))?;
1696                }
1697            }
1698        };
1699        Ok(())
1700    }
1701}
1702
1703/// Lexically decide whether a zip symlink target escapes the extraction root.
1704///
1705/// `link_parent` is the link entry's parent directory expressed relative to the extraction root
1706/// (derived from the already zip-slip-checked entry name, so it contains only normal components).
1707/// `target` is the raw link target read from the entry contents. Returns `true` if the target is
1708/// absolute or if resolving its `..`/`.` components against `link_parent` climbs above the root.
1709/// This is a purely lexical check (no filesystem access), matching the intent of the
1710/// `enclosed_name` defense used for entry names.
1711#[cfg(all(feature = "archive-zip", unix))]
1712fn symlink_target_escapes(link_parent: &path::Path, target: &path::Path) -> bool {
1713    use std::path::Component;
1714
1715    // Seed the virtual stack with the link's parent directory (only normal components).
1716    let mut depth: usize = 0;
1717    for comp in link_parent.components() {
1718        match comp {
1719            Component::Normal(_) => depth += 1,
1720            Component::CurDir => {}
1721            // `link_parent` comes from an enclosed (relative, `..`-free) name, so other
1722            // components should not occur; treat any as unsafe to be conservative.
1723            _ => return true,
1724        }
1725    }
1726
1727    for comp in target.components() {
1728        match comp {
1729            Component::Normal(_) => depth += 1,
1730            Component::CurDir => {}
1731            Component::ParentDir => {
1732                // Climbing above the root escapes the extraction dir.
1733                let Some(next) = depth.checked_sub(1) else {
1734                    return true;
1735                };
1736                depth = next;
1737            }
1738            // An absolute target (root dir or a drive prefix) always escapes.
1739            Component::RootDir | Component::Prefix(_) => return true,
1740        }
1741    }
1742
1743    false
1744}
1745
1746/// Moves a file from the given path to the specified destination.
1747///
1748/// `source` and `dest` must be on the same filesystem.
1749/// If `replace_using_temp` is specified, the destination file will be
1750/// replaced using the given temporary path.
1751/// If the existing `dest` file is a currently running long running program,
1752/// `replace_using_temp` may run into errors cleaning up the temp dir.
1753/// If that's the case for your use-case, consider not specifying a temp dir to use.
1754///
1755/// * Errors:
1756///     * Io - copying / renaming
1757#[derive(Debug)]
1758#[non_exhaustive]
1759pub struct Move {
1760    source: path::PathBuf,
1761    temp: Option<path::PathBuf>,
1762}
1763impl Move {
1764    /// Specify source file. Accepts anything path-like, storing an owned
1765    /// [`PathBuf`](std::path::PathBuf).
1766    pub fn from_source(source: impl AsRef<path::Path>) -> Move {
1767        Self {
1768            source: source.as_ref().to_path_buf(),
1769            temp: None,
1770        }
1771    }
1772
1773    /// If specified and the destination file already exists, the "destination"
1774    /// file will be moved to the given temporary location before the "source"
1775    /// file is moved to the "destination" file.
1776    ///
1777    /// In the event of an `io` error while renaming "source" to "destination",
1778    /// the temporary file will be moved back to "destination".
1779    ///
1780    /// The `temp` dir must be explicitly provided since `rename` operations require
1781    /// files to live on the same filesystem.
1782    pub fn replace_using_temp(&mut self, temp: impl AsRef<path::Path>) -> &mut Self {
1783        self.temp = Some(temp.as_ref().to_path_buf());
1784        self
1785    }
1786
1787    /// Move source file to specified destination
1788    pub fn to_dest(&self, dest: impl AsRef<path::Path>) -> Result<()> {
1789        let dest = dest.as_ref();
1790        match self.temp.as_deref() {
1791            // Move the existing dest to a temp location so we can move it back if
1792            // there's an error. If the existing `dest` file is a long running program,
1793            // this may prevent the temp dir from being cleaned up.
1794            Some(temp) if dest.exists() => {
1795                fs::rename(dest, temp)?;
1796                if let Err(e) = fs::rename(&self.source, dest) {
1797                    fs::rename(temp, dest)?;
1798                    return Err(Error::from(e));
1799                }
1800            }
1801            // No temp set, or nothing to preserve at `dest`: just move source into place.
1802            _ => {
1803                rename_or_copy(&self.source, dest)?;
1804            }
1805        };
1806        Ok(())
1807    }
1808}
1809
1810/// Rename `source` onto `dest`, falling back to copy when the two are on different filesystems.
1811///
1812/// The extraction temp dir is often a tmpfs on Linux while `bin_install_path` lives on the root
1813/// filesystem, so a plain `fs::rename` returns `CrossesDevices` (EXDEV). On that error the source is
1814/// copied to a temporary file beside `dest` (same filesystem, so the following rename is atomic),
1815/// renamed over `dest`, and the original source removed. `fs::copy` preserves the source's
1816/// permission mode.
1817fn rename_or_copy(source: &path::Path, dest: &path::Path) -> Result<()> {
1818    match fs::rename(source, dest) {
1819        Ok(()) => Ok(()),
1820        Err(e) if e.kind() == io::ErrorKind::CrossesDevices => {
1821            let tmp = match dest.file_name() {
1822                Some(name) => {
1823                    let mut n = name.to_os_string();
1824                    n.push(".self_update.tmp");
1825                    dest.with_file_name(n)
1826                }
1827                None => return Err(Error::from(e)),
1828            };
1829            fs::copy(source, &tmp)?;
1830            if let Err(rename_err) = fs::rename(&tmp, dest) {
1831                let _ = fs::remove_file(&tmp);
1832                return Err(Error::from(rename_err));
1833            }
1834            let _ = fs::remove_file(source);
1835            Ok(())
1836        }
1837        Err(e) => Err(Error::from(e)),
1838    }
1839}
1840
1841/// Transactionally install a *set* of files: either every `(source -> dest)` move is applied, or
1842/// — on the first failure — all already-applied moves are rolled back, restoring every
1843/// destination to its prior contents. Use it to update a tool that ships more than one file (a
1844/// binary plus sidecar libraries/resources) without risking a half-applied update.
1845///
1846/// This is the multi-file analogue of [`Move`]. It relies on `rename`, so **every source, every
1847/// destination, and the `temp` directory must live on the same filesystem** (the same constraint
1848/// [`Move::replace_using_temp`] has) — in particular the staging dir holding the files you `add`
1849/// must be co-located with the destinations, not in `$TMPDIR`. The `temp` directory is used to
1850/// stash each displaced destination so it can be restored on rollback; a [`tempfile::TempDir`] is a
1851/// convenient choice.
1852///
1853/// [`commit`](Self::commit) drains the queued moves as it applies them, so a `MoveAll` is
1854/// single-use: a second `commit` has nothing left to do and is a no-op returning `Ok(())`. Rollback
1855/// is best-effort — if a rollback step itself fails it is logged via `log::error!` rather than
1856/// surfaced, and the error returned to the caller is always the original one that triggered the
1857/// rollback.
1858///
1859/// ```no_run
1860/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
1861/// // The stash dir must be on the same filesystem as the destinations (rename can't cross
1862/// // filesystems), so create it next to them rather than in $TMPDIR.
1863/// let tmp = tempfile::TempDir::new_in("/usr/local")?;
1864/// // `new_bin` / `new_lib` are files you already extracted into a staging dir, which must
1865/// // also be on the destination filesystem (the sources are renamed into place too).
1866/// let staging = tempfile::TempDir::new_in("/usr/local")?;
1867/// let new_bin = staging.path().join("app");
1868/// let new_lib = staging.path().join("libapp.so");
1869/// self_update::MoveAll::from_temp(tmp.path())
1870///     .add(new_bin, "/usr/local/bin/app")
1871///     .add(new_lib, "/usr/local/lib/libapp.so")
1872///     .commit()?; // all-or-nothing
1873/// # Ok(())
1874/// # }
1875/// ```
1876///
1877/// * Errors:
1878///     * Io - renaming a source into place or stashing an existing destination
1879#[derive(Debug)]
1880#[must_use = "queued moves are only applied when `.commit()` is called"]
1881#[non_exhaustive]
1882pub struct MoveAll {
1883    temp: path::PathBuf,
1884    moves: Vec<(path::PathBuf, path::PathBuf)>,
1885}
1886
1887impl MoveAll {
1888    /// Start a transactional install, stashing displaced destinations under `temp` so they can be
1889    /// restored if a later move fails. `temp` must be on the same filesystem as every destination.
1890    /// Accepts anything path-like, storing an owned [`PathBuf`](std::path::PathBuf).
1891    pub fn from_temp(temp: impl AsRef<path::Path>) -> Self {
1892        Self {
1893            temp: temp.as_ref().to_path_buf(),
1894            moves: Vec::new(),
1895        }
1896    }
1897
1898    /// Queue a `source -> dest` move. Moves are applied by [`commit`](Self::commit) in the order
1899    /// added.
1900    pub fn add(
1901        &mut self,
1902        source: impl AsRef<path::Path>,
1903        dest: impl AsRef<path::Path>,
1904    ) -> &mut Self {
1905        self.moves
1906            .push((source.as_ref().to_path_buf(), dest.as_ref().to_path_buf()));
1907        self
1908    }
1909
1910    /// Apply every queued move. On success all destinations have been replaced. On the first
1911    /// failure, every already-applied move (and the failing one's partial state) is rolled back so
1912    /// each destination is left with its original contents, and the underlying error is returned.
1913    ///
1914    /// The queued moves are drained as they are applied, so calling `commit` again is a no-op that
1915    /// returns `Ok(())`.
1916    pub fn commit(&mut self) -> Result<()> {
1917        // Drain the queue so a second `commit` is a no-op rather than re-running already-applied
1918        // moves against now-missing sources.
1919        let moves = std::mem::take(&mut self.moves);
1920
1921        // For each applied move we remember the destination and where its previous contents (if
1922        // any) were stashed, so a later failure can restore them in reverse order.
1923        let mut applied: Vec<Applied> = Vec::with_capacity(moves.len());
1924
1925        for (i, (source, dest)) in moves.iter().enumerate() {
1926            // Stash an existing destination so we can move it back on rollback.
1927            let stash = if dest.exists() {
1928                let stash = self.temp.join(format!("self_update-stash-{i}"));
1929                if let Err(e) = fs::rename(dest, &stash) {
1930                    rollback(&applied);
1931                    return Err(Error::from(e));
1932                }
1933                Some(stash)
1934            } else {
1935                None
1936            };
1937
1938            // Move the new file into place.
1939            if let Err(e) = fs::rename(source, dest) {
1940                // Undo this step's stash before rolling back the earlier ones.
1941                if let Some(stash) = &stash
1942                    && let Err(restore_err) = fs::rename(stash, dest)
1943                {
1944                    log::error!(
1945                        "failed to restore {} from stash {} during rollback: {}",
1946                        dest.display(),
1947                        stash.display(),
1948                        restore_err
1949                    );
1950                }
1951                rollback(&applied);
1952                return Err(Error::from(e));
1953            }
1954
1955            applied.push(Applied {
1956                dest: dest.clone(),
1957                stash,
1958            });
1959        }
1960
1961        Ok(())
1962    }
1963}
1964
1965/// A move that [`MoveAll::commit`] has applied, retained so it can be undone on a later failure.
1966#[derive(Debug)]
1967struct Applied {
1968    dest: path::PathBuf,
1969    stash: Option<path::PathBuf>,
1970}
1971
1972/// Best-effort rollback of already-applied moves, in reverse order. For a destination that
1973/// previously existed, the stashed original is `rename`d back over the newly installed file — a
1974/// single atomic replace (the same technique [`Move::replace_using_temp`] uses), so the original
1975/// is never deleted before its restore can fail. For a destination that didn't previously exist
1976/// (a fresh install), the newly installed file is simply removed. Rollback failures are logged
1977/// rather than propagated — the original error that triggered the rollback is what callers see.
1978fn rollback(applied: &[Applied]) {
1979    for entry in applied.iter().rev() {
1980        match &entry.stash {
1981            // Previously existed: atomically restore the original over the new file.
1982            Some(stash) => {
1983                if let Err(e) = fs::rename(stash, &entry.dest) {
1984                    log::error!(
1985                        "failed to restore {} from stash {} during rollback: {}",
1986                        entry.dest.display(),
1987                        stash.display(),
1988                        e
1989                    );
1990                }
1991            }
1992            // Fresh install (nothing to restore): remove the file we added.
1993            None => {
1994                if let Err(e) = fs::remove_file(&entry.dest) {
1995                    log::error!(
1996                        "failed to remove {} during rollback: {}",
1997                        entry.dest.display(),
1998                        e
1999                    );
2000                }
2001            }
2002        }
2003    }
2004}
2005
2006/// A download-progress callback: `(bytes_downloaded_so_far, total_bytes_if_known)`.
2007pub(crate) type DynProgressFn = dyn Fn(u64, Option<u64>) + Send + Sync;
2008
2009/// Wrapper around a [`DynProgressFn`] so structs holding one can still derive `Clone`/`Debug`.
2010#[derive(Clone)]
2011pub(crate) struct ProgressCallback(pub(crate) std::sync::Arc<DynProgressFn>);
2012
2013impl std::fmt::Debug for ProgressCallback {
2014    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2015        f.write_str("ProgressCallback(..)")
2016    }
2017}
2018
2019/// A post-update verification callback: given the path to the freshly-extracted binary (before it
2020/// is installed), returns `Ok(())` to accept it or `Err(..)` to reject it (aborting the update). A
2021/// returned error's message is carried as the reason of the resulting
2022/// [`Error::VerificationRejected`](errors::Error::VerificationRejected).
2023pub(crate) type DynVerifyFn = dyn Fn(&std::path::Path) -> Result<()> + Send + Sync;
2024
2025/// Wrapper around a [`DynVerifyFn`] so structs holding one can still derive `Clone`/`Debug`.
2026#[derive(Clone)]
2027pub(crate) struct VerifyCallback(pub(crate) std::sync::Arc<DynVerifyFn>);
2028
2029impl std::fmt::Debug for VerifyCallback {
2030    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2031        f.write_str("VerifyCallback(..)")
2032    }
2033}
2034
2035/// A custom asset-selection callback: given the release's assets, returns the asset to download
2036/// (or `None` to fail the update). Overrides the built-in target/identifier substring matching.
2037pub(crate) type DynAssetMatcher = dyn Fn(&[ReleaseAsset]) -> Option<ReleaseAsset> + Send + Sync;
2038
2039/// Wrapper around a [`DynAssetMatcher`] so structs holding one can still derive `Clone`/`Debug`.
2040#[derive(Clone)]
2041pub(crate) struct AssetMatcher(pub(crate) std::sync::Arc<DynAssetMatcher>);
2042
2043impl std::fmt::Debug for AssetMatcher {
2044    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2045        f.write_str("AssetMatcher(..)")
2046    }
2047}
2048
2049/// Download things into files
2050///
2051/// With optional progress bar
2052#[non_exhaustive]
2053pub struct Download {
2054    show_progress: bool,
2055    url: String,
2056    headers: http_client::header::HeaderMap,
2057    #[cfg(feature = "progress-bar")]
2058    progress_template: String,
2059    #[cfg(feature = "progress-bar")]
2060    progress_chars: String,
2061    timeout: Option<std::time::Duration>,
2062    on_progress: Option<ProgressCallback>,
2063    /// Optional cap on the number of bytes streamed into `dest`. `None` (the default) means no cap,
2064    /// preserving prior unbounded behavior. When set, the streaming download aborts with an error as
2065    /// soon as the total bytes written would exceed this many bytes.
2066    max_download_size: Option<u64>,
2067    /// Number of times to retry establishing the download request (before any bytes are streamed)
2068    /// with exponential backoff. `0` (the default) means a single attempt, preserving the prior
2069    /// no-retry behavior. A failure that occurs *after* streaming has begun is not retried (it would
2070    /// corrupt the partially-written destination).
2071    retries: u32,
2072    retry_base_delay: std::time::Duration,
2073    retry_max_delay: std::time::Duration,
2074    /// Optional user-supplied sync HTTP client (used through the trait); `None` => crate default.
2075    client: Option<std::sync::Arc<dyn http_client::HttpClient>>,
2076    /// Optional user-supplied async HTTP client; `None` => crate default. Async is reqwest-only.
2077    #[cfg(feature = "async")]
2078    async_client: Option<std::sync::Arc<dyn http_client::AsyncHttpClient>>,
2079    /// Custom TLS root CA certificates to bake into the crate-built client when no client was
2080    /// injected (see [`add_root_certificate`](Self::add_root_certificate)).
2081    root_certificates: Vec<Certificate>,
2082    /// First error from a `request_header(name, value)` argument that wasn't a valid HTTP header.
2083    /// Deferred like the builders' `request_header` so the setter stays infallible; surfaced from
2084    /// [`download_to`](Self::download_to) as an `Error::InvalidHeader`.
2085    header_error: Option<String>,
2086}
2087
2088impl std::fmt::Debug for Download {
2089    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2090        let mut s = f.debug_struct("Download");
2091        s.field("show_progress", &self.show_progress)
2092            .field("url", &self.url)
2093            .field("headers", &self.headers);
2094        #[cfg(feature = "progress-bar")]
2095        s.field("progress_template", &self.progress_template)
2096            .field("progress_chars", &self.progress_chars);
2097        s.field("timeout", &self.timeout)
2098            .field(
2099                "on_progress",
2100                &self.on_progress.as_ref().map(|_| "<callback>"),
2101            )
2102            .field("max_download_size", &self.max_download_size)
2103            .field("client", &self.client.as_ref().map(|_| "<http_client>"));
2104        #[cfg(feature = "async")]
2105        s.field(
2106            "async_client",
2107            &self.async_client.as_ref().map(|_| "<async_http_client>"),
2108        );
2109        s.field(
2110            "root_certificates",
2111            &format_args!("<{} root_certificates>", self.root_certificates.len()),
2112        );
2113        s.finish()
2114    }
2115}
2116
2117/// Build the error returned when a streaming download exceeds its configured
2118/// [`max_download_size`](Download::max_download_size) cap. Reported as an [`Error::Io`] with a
2119/// message naming the cap so the failure mode is unambiguous.
2120fn max_download_size_exceeded(cap: u64) -> Error {
2121    Error::Io(io::Error::other(format!(
2122        "download exceeded the configured max_download_size cap of {cap} bytes"
2123    )))
2124}
2125
2126impl Download {
2127    /// Specify download url. Accepts anything string-like (`&str`, `String`, …).
2128    pub fn from_url(url: impl Into<String>) -> Self {
2129        Self {
2130            show_progress: false,
2131            url: url.into(),
2132            headers: http_client::header::HeaderMap::new(),
2133            #[cfg(feature = "progress-bar")]
2134            progress_template: DEFAULT_PROGRESS_TEMPLATE.to_string(),
2135            #[cfg(feature = "progress-bar")]
2136            progress_chars: DEFAULT_PROGRESS_CHARS.to_string(),
2137            timeout: None,
2138            on_progress: None,
2139            max_download_size: None,
2140            retries: 0,
2141            retry_base_delay: std::time::Duration::from_millis(100),
2142            retry_max_delay: std::time::Duration::from_millis(3200),
2143            client: None,
2144            #[cfg(feature = "async")]
2145            async_client: None,
2146            root_certificates: vec![],
2147            header_error: None,
2148        }
2149    }
2150
2151    /// Toggle the download progress bar. Named to match the `Update` builder's setter of the same
2152    /// name.
2153    pub fn show_download_progress(&mut self, b: bool) -> &mut Self {
2154        self.show_progress = b;
2155        self
2156    }
2157
2158    /// Set a timeout for the download request. Defaults to no timeout.
2159    pub fn timeout(&mut self, timeout: std::time::Duration) -> &mut Self {
2160        self.timeout = Some(timeout);
2161        self
2162    }
2163
2164    /// Cap the number of bytes this download will stream into `dest`. Once the running total of
2165    /// written bytes exceeds `max_bytes`, the download aborts with an [`Error`] instead of writing
2166    /// an unbounded amount (useful to defend against a server that streams far more than the
2167    /// advertised `Content-Length`). Defaults to no cap, so existing behavior is unchanged.
2168    pub fn max_download_size(&mut self, max_bytes: u64) -> &mut Self {
2169        self.max_download_size = Some(max_bytes);
2170        self
2171    }
2172
2173    /// Register a callback invoked as the download streams, with
2174    /// `(bytes_downloaded_so_far, total_bytes)` — `total_bytes` is `None` when the server does
2175    /// not send a `Content-Length`. Independent of the terminal progress bar
2176    /// ([`show_download_progress`](Self::show_download_progress)); use it to drive a GUI, structured logging, or
2177    /// any non-terminal progress display. The callback is `Fn`, so track state via interior
2178    /// mutability (e.g. an `AtomicU64` or a channel).
2179    pub fn progress_callback(
2180        &mut self,
2181        callback: impl Fn(u64, Option<u64>) + Send + Sync + 'static,
2182    ) -> &mut Self {
2183        self.on_progress = Some(ProgressCallback(std::sync::Arc::new(callback)));
2184        self
2185    }
2186
2187    /// Internal: set the progress callback from an already-wrapped `Arc` (used by the update
2188    /// flow to forward an `Update`'s callback to its download).
2189    pub(crate) fn set_progress_callback_arc(
2190        &mut self,
2191        callback: std::sync::Arc<DynProgressFn>,
2192    ) -> &mut Self {
2193        self.on_progress = Some(ProgressCallback(callback));
2194        self
2195    }
2196
2197    /// Set the progress style, as a typed [`ProgressStyle`] (template + chars) so the two strings
2198    /// can't be transposed.
2199    #[cfg(feature = "progress-bar")]
2200    pub fn progress_style(&mut self, style: ProgressStyle) -> &mut Self {
2201        self.progress_template = style.template;
2202        self.progress_chars = style.chars;
2203        self
2204    }
2205
2206    /// Replace the entire download request `HeaderMap`. To add a single header without discarding
2207    /// the others, use [`request_header`](Self::request_header) instead.
2208    pub fn replace_headers(&mut self, headers: http_client::header::HeaderMap) -> &mut Self {
2209        self.headers = headers;
2210        self
2211    }
2212
2213    /// Internal: set the download request-establishment retry budget and backoff (used by the
2214    /// update flow to forward an `Update`'s configured `retries`/`retry_backoff` to the download).
2215    pub(crate) fn set_retries(
2216        &mut self,
2217        retries: u32,
2218        base: std::time::Duration,
2219        max: std::time::Duration,
2220    ) -> &mut Self {
2221        self.retries = retries;
2222        self.retry_base_delay = base;
2223        self.retry_max_delay = max;
2224        self
2225    }
2226
2227    /// Internal: set the injected HTTP clients from already-built `Arc`s (used by the update flow to
2228    /// forward an `Update`'s injected client to its download).
2229    pub(crate) fn set_http_client(
2230        &mut self,
2231        client: Option<std::sync::Arc<dyn http_client::HttpClient>>,
2232        #[cfg(feature = "async")] async_client: Option<
2233            std::sync::Arc<dyn http_client::AsyncHttpClient>,
2234        >,
2235    ) -> &mut Self {
2236        self.client = client;
2237        #[cfg(feature = "async")]
2238        {
2239            self.async_client = async_client;
2240        }
2241        self
2242    }
2243
2244    /// Add a custom TLS root CA certificate the crate-built HTTP client will trust. Call multiple
2245    /// times to add more than one. Ignored when an HTTP client is injected via `set_http_client`
2246    /// (the injected client owns its own TLS config). A malformed certificate surfaces as an
2247    /// [`Error::InvalidCertificate`] from [`download_to`](Self::download_to).
2248    ///
2249    /// **ureq-only builds**: when the `reqwest` feature is disabled, the crate-built ureq client
2250    /// trusts *only* the supplied certificates (replacing the default Mozilla root set). Supply all
2251    /// CA certificates you need, including any public roots, or inject a `ureq::Agent` via
2252    /// `set_http_client` with a merged root set instead.
2253    pub fn add_root_certificate(&mut self, cert: Certificate) -> &mut Self {
2254        self.root_certificates.push(cert);
2255        self
2256    }
2257
2258    /// Internal: the configured custom root CA certificates (used by tests to confirm cert
2259    /// forwarding). Empty unless [`add_root_certificate`](Self::add_root_certificate) was called.
2260    #[cfg(test)]
2261    pub(crate) fn root_certificates(&self) -> &[Certificate] {
2262        &self.root_certificates
2263    }
2264
2265    /// Set a download request header, inserting into the existing `HeaderMap`. To add a single
2266    /// header without discarding the others; to replace the whole map use
2267    /// [`replace_headers`](Self::replace_headers).
2268    ///
2269    /// Accepts anything that converts into a header name/value, so both typed values and plain
2270    /// strings work: `.request_header("X-Foo", "bar")` or
2271    /// `.request_header(self_update::http::header::ACCEPT, "application/octet-stream")`. The setter
2272    /// is infallible; a name or value that is not a valid HTTP header is deferred and surfaced from
2273    /// [`download_to`](Self::download_to) as an
2274    /// [`Error::InvalidHeader`], matching the builders'
2275    /// `request_header` verb.
2276    pub fn request_header<N, V>(&mut self, name: N, value: V) -> &mut Self
2277    where
2278        N: ::core::convert::TryInto<http_client::header::HeaderName>,
2279        V: ::core::convert::TryInto<http_client::header::HeaderValue>,
2280    {
2281        match (name.try_into(), value.try_into()) {
2282            (Ok(name), Ok(value)) => {
2283                self.headers.insert(name, value);
2284            }
2285            _ => {
2286                if self.header_error.is_none() {
2287                    self.header_error =
2288                        Some("invalid HTTP header passed to `request_header`".to_string());
2289                }
2290            }
2291        }
2292        self
2293    }
2294
2295    /// Surface a deferred `request_header` conversion failure as an `Error::InvalidHeader`.
2296    fn check_header_error(&self) -> Result<()> {
2297        if let Some(msg) = &self.header_error {
2298            return Err(Error::InvalidHeader {
2299                source: Box::new(errors::MessageError(msg.clone())),
2300            });
2301        }
2302        Ok(())
2303    }
2304
2305    /// Download the file behind the given `url` into the specified `dest`.
2306    /// Show a sliding progress bar if specified.
2307    /// If the resource doesn't specify a content-length, the progress bar will not be shown
2308    ///
2309    /// * Errors:
2310    ///     * HTTP client network errors
2311    ///     * Unsuccessful response status
2312    ///     * Progress-bar errors
2313    ///     * Reading from response to `BufReader`-buffer
2314    ///     * Writing from `BufReader`-buffer to `File`
2315    pub fn download_to<T: io::Write>(&self, mut dest: T) -> Result<()> {
2316        use io::BufRead;
2317        self.check_header_error()?;
2318        let mut headers = self.headers.clone();
2319        if !headers.contains_key(header::USER_AGENT) {
2320            headers.insert(
2321                header::USER_AGENT,
2322                DEFAULT_USER_AGENT.parse().expect("invalid user-agent"),
2323            );
2324        }
2325
2326        let default;
2327        let built;
2328        let client: &dyn http_client::HttpClient = match self.client.as_deref() {
2329            Some(c) => c,
2330            None if !self.root_certificates.is_empty() => {
2331                // No injected client but custom root CAs were supplied: build a client that trusts
2332                // them. A malformed cert / build failure surfaces here as `Error::InvalidCertificate`.
2333                built = http_client::client_with_root_certs(&self.root_certificates)
2334                    .map_err(|source| Error::InvalidCertificate { source })?;
2335                &*built
2336            }
2337            None => {
2338                default = http_client::default_client();
2339                &*default
2340            }
2341        };
2342        // Retry only the request-establishment phase (before any bytes are streamed): a failure
2343        // after streaming begins would corrupt the partially-written destination. With the default
2344        // `retries == 0` this is a single attempt.
2345        let resp = backends::retry(
2346            self.retries,
2347            self.retry_base_delay,
2348            self.retry_max_delay,
2349            || client.get(&self.url, &headers, self.timeout),
2350            |e, backoff| {
2351                log::warn!(
2352                    "self_update: download request to {} failed ({e}); retrying in {backoff}ms",
2353                    crate::errors::redact_url(&self.url)
2354                );
2355                std::thread::sleep(std::time::Duration::from_millis(backoff));
2356            },
2357        )?;
2358        let size = resp
2359            .headers()
2360            .get(http_client::header::CONTENT_LENGTH)
2361            .map(|val| {
2362                val.to_str()
2363                    .map(|s| s.parse::<u64>().unwrap_or(0))
2364                    .unwrap_or(0)
2365            })
2366            .unwrap_or(0);
2367        // `http_client::get` already errored on a non-success status (see `download_to_async`).
2368        let total = if size == 0 { None } else { Some(size) };
2369        #[cfg(feature = "progress-bar")]
2370        let show_progress = if size == 0 { false } else { self.show_progress };
2371
2372        let mut src = io::BufReader::new(resp.body());
2373        let mut downloaded: u64 = 0;
2374        #[cfg(feature = "progress-bar")]
2375        let mut bar = if show_progress {
2376            let style = IndicatifProgressStyle::default_bar()
2377                .template(&self.progress_template)
2378                .map_err(|e| Error::InvalidProgressStyle {
2379                    source: Box::new(e),
2380                })?
2381                .progress_chars(&self.progress_chars);
2382            let pb = ProgressBar::new(size);
2383            pb.set_style(style);
2384            Some(pb)
2385        } else {
2386            None
2387        };
2388        loop {
2389            let n = {
2390                let buf = src.fill_buf()?;
2391                dest.write_all(buf)?;
2392                buf.len()
2393            };
2394            if n == 0 {
2395                break;
2396            }
2397            src.consume(n);
2398            downloaded += n as u64;
2399            if let Some(cap) = self.max_download_size
2400                && downloaded > cap
2401            {
2402                return Err(max_download_size_exceeded(cap));
2403            }
2404
2405            #[cfg(feature = "progress-bar")]
2406            if let Some(ref mut bar) = bar {
2407                bar.set_position(min(downloaded, size));
2408            }
2409            if let Some(ref cb) = self.on_progress {
2410                (cb.0)(downloaded, total);
2411            }
2412        }
2413        #[cfg(feature = "progress-bar")]
2414        if let Some(ref mut bar) = bar {
2415            bar.finish_with_message("Done");
2416        }
2417        Ok(())
2418    }
2419
2420    /// Async sibling of [`download_to`](Self::download_to): stream the download into `dest` using
2421    /// the async (reqwest) transport, driving the same progress bar / callback. `dest` is a
2422    /// synchronous writer (chunks are written as they arrive); file IO is not offloaded.
2423    #[cfg(feature = "async")]
2424    pub async fn download_to_async<T: io::Write>(&self, mut dest: T) -> Result<()> {
2425        use futures_util::StreamExt;
2426
2427        self.check_header_error()?;
2428        let mut headers = self.headers.clone();
2429        if !headers.contains_key(header::USER_AGENT) {
2430            headers.insert(
2431                header::USER_AGENT,
2432                DEFAULT_USER_AGENT.parse().expect("invalid user-agent"),
2433            );
2434        }
2435
2436        let default;
2437        let built;
2438        let client: &dyn http_client::AsyncHttpClient = match self.async_client.as_deref() {
2439            Some(c) => c,
2440            None if !self.root_certificates.is_empty() => {
2441                // No injected async client but custom root CAs were supplied: build one that trusts
2442                // them. A malformed cert / build failure surfaces here as `Error::InvalidCertificate`.
2443                built = http_client::async_client_with_root_certs(&self.root_certificates)
2444                    .map_err(|source| Error::InvalidCertificate { source })?;
2445                &*built
2446            }
2447            None => {
2448                default = http_client::default_async_client();
2449                &*default
2450            }
2451        };
2452        // Retry only the request-establishment phase (see `download_to`).
2453        let resp = backends::retry_async(
2454            self.retries,
2455            self.retry_base_delay,
2456            self.retry_max_delay,
2457            || client.get(&self.url, &headers, self.timeout),
2458            |e, backoff| {
2459                log::warn!(
2460                    "self_update: download request to {} failed ({e}); retrying in {backoff}ms",
2461                    crate::errors::redact_url(&self.url)
2462                );
2463            },
2464            |backoff| tokio::time::sleep(std::time::Duration::from_millis(backoff)),
2465        )
2466        .await?;
2467        let size = resp
2468            .headers()
2469            .get(http_client::header::CONTENT_LENGTH)
2470            .map(|val| {
2471                val.to_str()
2472                    .map(|s| s.parse::<u64>().unwrap_or(0))
2473                    .unwrap_or(0)
2474            })
2475            .unwrap_or(0);
2476        // `get_async` already errored on a non-success status.
2477        let total = if size == 0 { None } else { Some(size) };
2478        #[cfg(feature = "progress-bar")]
2479        let show_progress = if size == 0 { false } else { self.show_progress };
2480
2481        let mut downloaded: u64 = 0;
2482        #[cfg(feature = "progress-bar")]
2483        let mut bar = if show_progress {
2484            let style = IndicatifProgressStyle::default_bar()
2485                .template(&self.progress_template)
2486                .map_err(|e| Error::InvalidProgressStyle {
2487                    source: Box::new(e),
2488                })?
2489                .progress_chars(&self.progress_chars);
2490            let pb = ProgressBar::new(size);
2491            pb.set_style(style);
2492            Some(pb)
2493        } else {
2494            None
2495        };
2496
2497        let mut stream = resp.bytes_stream();
2498        while let Some(chunk) = stream.next().await {
2499            let chunk = chunk?;
2500            dest.write_all(&chunk)?;
2501            downloaded += chunk.len() as u64;
2502            if let Some(cap) = self.max_download_size
2503                && downloaded > cap
2504            {
2505                return Err(max_download_size_exceeded(cap));
2506            }
2507
2508            #[cfg(feature = "progress-bar")]
2509            if let Some(ref mut bar) = bar {
2510                bar.set_position(min(downloaded, size));
2511            }
2512            if let Some(ref cb) = self.on_progress {
2513                (cb.0)(downloaded, total);
2514            }
2515        }
2516        #[cfg(feature = "progress-bar")]
2517        if let Some(ref mut bar) = bar {
2518            bar.finish_with_message("Done");
2519        }
2520        Ok(())
2521    }
2522}
2523
2524#[cfg(test)]
2525mod tests {
2526    use super::*;
2527    #[cfg(feature = "compression-tar-gz")]
2528    use flate2::{self, write::GzEncoder};
2529    #[allow(unused_imports)]
2530    use std::{
2531        fs::{self, File},
2532        io::{self, Read, Write},
2533        path::{Path, PathBuf},
2534    };
2535
2536    #[test]
2537    fn version_status_is_up_to_date() {
2538        assert!(VersionStatus::UpToDate("1.2.3".to_string()).is_up_to_date());
2539        assert!(!VersionStatus::Updated("1.2.3".to_string()).is_up_to_date());
2540        // `is_updated()` is the complement.
2541        assert!(VersionStatus::Updated("1.2.3".to_string()).is_updated());
2542        assert!(!VersionStatus::UpToDate("1.2.3".to_string()).is_updated());
2543    }
2544
2545    #[test]
2546    fn version_status_version_accessor() {
2547        // version() returns the wrapped string for both variants.
2548        assert_eq!(
2549            VersionStatus::UpToDate("1.0.0".to_string()).version(),
2550            "1.0.0"
2551        );
2552        assert_eq!(
2553            VersionStatus::Updated("2.0.0".to_string()).version(),
2554            "2.0.0"
2555        );
2556    }
2557
2558    #[test]
2559    fn version_status_display() {
2560        // Display is human-readable, not Debug form.
2561        assert_eq!(
2562            VersionStatus::UpToDate("1.0.0".to_string()).to_string(),
2563            "UpToDate(1.0.0)"
2564        );
2565        assert_eq!(
2566            VersionStatus::Updated("2.0.0".to_string()).to_string(),
2567            "Updated(2.0.0)"
2568        );
2569    }
2570
2571    // `ArchiveKind` renders a friendly, human-readable name via `Display` (used in error messages),
2572    // not the `Debug` form (which leaks the enum shape like `Tar(Some(Gz))`).
2573    #[test]
2574    fn archive_kind_display_is_human_readable() {
2575        assert_eq!(ArchiveKind::Plain(None).to_string(), "plain");
2576        assert_eq!(ArchiveKind::Plain(Some(Compression::Gz)).to_string(), "gz");
2577        assert_eq!(ArchiveKind::Plain(Some(Compression::Xz)).to_string(), "xz");
2578        #[cfg(feature = "archive-tar")]
2579        {
2580            assert_eq!(ArchiveKind::Tar(None).to_string(), "tar");
2581            assert_eq!(
2582                ArchiveKind::Tar(Some(Compression::Gz)).to_string(),
2583                "tar.gz"
2584            );
2585            assert_eq!(
2586                ArchiveKind::Tar(Some(Compression::Xz)).to_string(),
2587                "tar.xz"
2588            );
2589        }
2590        #[cfg(feature = "archive-zip")]
2591        assert_eq!(ArchiveKind::Zip.to_string(), "zip");
2592    }
2593
2594    // A3/A4/A12/A5: the ergonomic argument types are accepted. These are compile-locks plus light
2595    // assertions: `Download::from_url` takes `impl Into<String>`; `Extract::from_source` /
2596    // `Move::from_source` / `MoveAll::from_temp` take `impl AsRef<Path>` (now lifetime-free); the
2597    // Download header verb is `request_header`; and `progress_style` takes a typed `ProgressStyle`.
2598    #[test]
2599    fn ergonomic_constructors_accept_owned_and_borrowed_paths_and_strings() {
2600        // from_url accepts &str and String.
2601        let _ = Download::from_url("https://example.com/a.bin");
2602        let _ = Download::from_url(String::from("https://example.com/b.bin"));
2603
2604        // Extract::from_source accepts &str, PathBuf, and &Path — and the struct holds no lifetime.
2605        let _: Extract = Extract::from_source("some/path.tar.gz");
2606        let _: Extract = Extract::from_source(PathBuf::from("some/path.tar.gz"));
2607        let owned = PathBuf::from("some/path.tar.gz");
2608        let _: Extract = Extract::from_source(owned.as_path());
2609
2610        // Move::from_source / replace_using_temp accept path-like; the type is lifetime-free.
2611        let mut mv: Move = Move::from_source("src");
2612        mv.replace_using_temp("tmp");
2613
2614        // MoveAll::from_temp accepts path-like; lifetime-free.
2615        let _: MoveAll = MoveAll::from_temp("tmp-dir");
2616    }
2617
2618    #[cfg(feature = "progress-bar")]
2619    #[test]
2620    fn progress_style_newtype_threads_template_and_chars() {
2621        // A5: `ProgressStyle::new(template, chars)` builds the typed pair and the Download setter
2622        // threads both fields through (no transposable two-arg setter).
2623        let style = ProgressStyle::new("[{bar:40}] {bytes}", "#>-");
2624        assert_eq!(style.template, "[{bar:40}] {bytes}");
2625        assert_eq!(style.chars, "#>-");
2626
2627        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2628        dl.progress_style(style);
2629        assert_eq!(dl.progress_template, "[{bar:40}] {bytes}");
2630        assert_eq!(dl.progress_chars, "#>-");
2631    }
2632
2633    #[test]
2634    fn download_header_accepts_str_name_and_value() {
2635        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2636        // Plain string literals must convert into a valid name/value.
2637        dl.request_header("x-custom-header", "custom-value");
2638        let stored = dl
2639            .headers
2640            .get("x-custom-header")
2641            .expect("header should be inserted");
2642        assert_eq!(stored, "custom-value");
2643    }
2644
2645    #[test]
2646    fn download_header_accepts_typed_name_and_value() {
2647        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2648        // The typed `HeaderName` / `&str` value form still works.
2649        dl.request_header(http_client::header::ACCEPT, "application/octet-stream");
2650        assert_eq!(
2651            dl.headers.get(http_client::header::ACCEPT).unwrap(),
2652            "application/octet-stream"
2653        );
2654    }
2655
2656    #[test]
2657    fn download_header_overwrites_on_repeated_name() {
2658        // B5: `header()` inserts into the existing map. Calling it twice with the same name must
2659        // keep the *last* value (insert semantics), not append or keep the first.
2660        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2661        dl.request_header("x-dup", "first");
2662        dl.request_header("x-dup", "second");
2663        // `get` returns the (single) value; `get_all` must contain exactly one entry.
2664        assert_eq!(dl.headers.get("x-dup").unwrap(), "second");
2665        assert_eq!(
2666            dl.headers.get_all("x-dup").iter().count(),
2667            1,
2668            "a repeated header name must overwrite, not accumulate"
2669        );
2670    }
2671
2672    #[test]
2673    fn replace_headers_wholesale_replaces_after_header_calls() {
2674        // B5: after building up headers with `header()`, `replace_headers` must discard them all
2675        // and install only the supplied map (it is a whole-map setter, not a merge).
2676        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2677        dl.request_header("x-old-a", "a");
2678        dl.request_header("x-old-b", "b");
2679
2680        let mut fresh = http_client::header::HeaderMap::new();
2681        fresh.insert("x-new", "n".parse().unwrap());
2682        dl.replace_headers(fresh);
2683
2684        assert!(
2685            dl.headers.get("x-old-a").is_none(),
2686            "replace_headers must drop previously-added headers"
2687        );
2688        assert!(dl.headers.get("x-old-b").is_none());
2689        assert_eq!(dl.headers.get("x-new").unwrap(), "n");
2690        assert_eq!(
2691            dl.headers.len(),
2692            1,
2693            "replace_headers installs exactly the supplied map"
2694        );
2695
2696        // And `header()` still works after a replace, inserting into the new map.
2697        dl.request_header("x-after", "y");
2698        assert_eq!(dl.headers.get("x-after").unwrap(), "y");
2699        assert_eq!(dl.headers.get("x-new").unwrap(), "n");
2700    }
2701
2702    #[test]
2703    fn download_header_rejects_invalid_value() {
2704        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2705        // A newline is not a valid header value. The setter is infallible (deferred): the bad
2706        // header is not inserted, and the error surfaces from `download_to`.
2707        dl.request_header("x-ok", "bad\nvalue");
2708        assert!(
2709            dl.headers.get("x-ok").is_none(),
2710            "the bad header must not be inserted"
2711        );
2712        let err = dl
2713            .download_to(Vec::<u8>::new())
2714            .expect_err("a deferred invalid header must surface from download_to");
2715        assert!(
2716            matches!(err, Error::InvalidHeader { .. }),
2717            "expected Error::InvalidHeader, got {:?}",
2718            err
2719        );
2720    }
2721
2722    #[test]
2723    fn download_header_rejects_invalid_name() {
2724        let mut dl = Download::from_url("https://example.com/app.tar.gz");
2725        // A space is not valid in a header name. The setter is infallible (deferred); the invalid
2726        // name is rejected before any value insertion, so the map stays empty and the error
2727        // surfaces from download_to.
2728        dl.request_header("inva lid", "ok");
2729        assert!(
2730            dl.headers.is_empty(),
2731            "an invalid header name must not leave a partial value inserted"
2732        );
2733        let err = dl
2734            .download_to(Vec::<u8>::new())
2735            .expect_err("a deferred invalid header name must surface from download_to");
2736        assert!(matches!(err, Error::InvalidHeader { .. }));
2737    }
2738
2739    #[test]
2740    fn detect_plain() {
2741        assert_eq!(
2742            ArchiveKind::Plain(None),
2743            detect_archive(&PathBuf::from("Something.exe")).unwrap()
2744        );
2745    }
2746
2747    #[test]
2748    fn move_all_commits_every_move() {
2749        let dir = tempfile::tempdir().unwrap();
2750        let temp = tempfile::tempdir().unwrap();
2751
2752        // Two new files to install over two existing destinations.
2753        let src_a = dir.path().join("src_a");
2754        let src_b = dir.path().join("src_b");
2755        fs::write(&src_a, b"new-a").unwrap();
2756        fs::write(&src_b, b"new-b").unwrap();
2757        let dst_a = dir.path().join("dst_a");
2758        let dst_b = dir.path().join("dst_b");
2759        fs::write(&dst_a, b"old-a").unwrap();
2760        fs::write(&dst_b, b"old-b").unwrap();
2761
2762        MoveAll::from_temp(temp.path())
2763            .add(&src_a, &dst_a)
2764            .add(&src_b, &dst_b)
2765            .commit()
2766            .unwrap();
2767
2768        assert_eq!(fs::read(&dst_a).unwrap(), b"new-a");
2769        assert_eq!(fs::read(&dst_b).unwrap(), b"new-b");
2770    }
2771
2772    #[test]
2773    fn move_all_rolls_back_on_failure() {
2774        let dir = tempfile::tempdir().unwrap();
2775        let temp = tempfile::tempdir().unwrap();
2776
2777        // Three moves: the first two are valid and overwrite existing destinations (so both are
2778        // stashed and applied), the third points at a non-existent source so its move fails. This
2779        // drives the already-applied first two back through `rollback()` (the stash-restore path).
2780        let src_a = dir.path().join("src_a");
2781        let src_b = dir.path().join("src_b");
2782        fs::write(&src_a, b"new-a").unwrap();
2783        fs::write(&src_b, b"new-b").unwrap();
2784        let missing_src = dir.path().join("does_not_exist");
2785
2786        let dst_a = dir.path().join("dst_a");
2787        let dst_b = dir.path().join("dst_b");
2788        let dst_c = dir.path().join("dst_c");
2789        fs::write(&dst_a, b"old-a").unwrap();
2790        fs::write(&dst_b, b"old-b").unwrap();
2791        fs::write(&dst_c, b"old-c").unwrap();
2792
2793        let res = MoveAll::from_temp(temp.path())
2794            .add(&src_a, &dst_a)
2795            .add(&src_b, &dst_b)
2796            .add(&missing_src, &dst_c)
2797            .commit();
2798        assert!(res.is_err(), "a failing move must abort the transaction");
2799
2800        // Every destination is restored to its original contents — both the applied moves
2801        // (rolled back via the stash) and the one whose move failed mid-step.
2802        assert_eq!(
2803            fs::read(&dst_a).unwrap(),
2804            b"old-a",
2805            "the first applied move must be rolled back"
2806        );
2807        assert_eq!(
2808            fs::read(&dst_b).unwrap(),
2809            b"old-b",
2810            "the second applied move must be rolled back"
2811        );
2812        assert_eq!(
2813            fs::read(&dst_c).unwrap(),
2814            b"old-c",
2815            "the failed move's stashed destination must be restored"
2816        );
2817    }
2818
2819    #[test]
2820    fn move_all_installs_fresh_destinations() {
2821        let dir = tempfile::tempdir().unwrap();
2822        let temp = tempfile::tempdir().unwrap();
2823
2824        // Destination does not pre-exist (fresh install, no stash needed).
2825        let src = dir.path().join("src");
2826        fs::write(&src, b"fresh").unwrap();
2827        let dst = dir.path().join("new_dst");
2828
2829        MoveAll::from_temp(temp.path())
2830            .add(&src, &dst)
2831            .commit()
2832            .unwrap();
2833        assert_eq!(fs::read(&dst).unwrap(), b"fresh");
2834    }
2835
2836    #[test]
2837    fn move_all_second_commit_is_a_noop() {
2838        let dir = tempfile::tempdir().unwrap();
2839        let temp = tempfile::tempdir().unwrap();
2840
2841        let src = dir.path().join("src");
2842        fs::write(&src, b"new").unwrap();
2843        let dst = dir.path().join("dst");
2844        fs::write(&dst, b"old").unwrap();
2845
2846        let mut mover = MoveAll::from_temp(temp.path());
2847        mover.add(&src, &dst);
2848        mover.commit().unwrap();
2849        assert_eq!(fs::read(&dst).unwrap(), b"new");
2850
2851        // The queue was drained, so a second commit does nothing and succeeds (rather than trying
2852        // to re-apply the move against the now-missing source and erroring out).
2853        mover.commit().unwrap();
2854        assert_eq!(fs::read(&dst).unwrap(), b"new");
2855    }
2856
2857    #[test]
2858    fn download_invokes_progress_callback() {
2859        use std::net::TcpListener;
2860        use std::sync::{Arc, Mutex};
2861
2862        // Serve a known-length body from a loopback server (no external network).
2863        let body = "x".repeat(20_000);
2864        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2865        let addr = listener.local_addr().unwrap();
2866        let served = body.clone();
2867        std::thread::spawn(move || {
2868            let (mut stream, _) = listener.accept().unwrap();
2869            let mut buf = [0u8; 1024];
2870            let _ = stream.read(&mut buf);
2871            let resp = format!(
2872                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2873                served.len(),
2874                served
2875            );
2876            let _ = stream.write_all(resp.as_bytes());
2877        });
2878
2879        let progress = Arc::new(Mutex::new(Vec::<(u64, Option<u64>)>::new()));
2880        let sink_progress = progress.clone();
2881        let mut out = Vec::new();
2882        Download::from_url(format!("http://{addr}/file"))
2883            .progress_callback(move |downloaded, total| {
2884                sink_progress.lock().unwrap().push((downloaded, total));
2885            })
2886            .download_to(&mut out)
2887            .unwrap();
2888
2889        assert_eq!(out.len(), 20_000);
2890        let calls = progress.lock().unwrap();
2891        assert!(!calls.is_empty(), "callback should have been invoked");
2892        // `total` reflects the Content-Length on every call.
2893        assert!(calls.iter().all(|(_, total)| *total == Some(20_000)));
2894        // `downloaded` is monotonically non-decreasing and reaches the full size.
2895        let mut last = 0u64;
2896        for (downloaded, _) in calls.iter() {
2897            assert!(*downloaded >= last);
2898            last = *downloaded;
2899        }
2900        assert_eq!(calls.last().unwrap().0, 20_000);
2901    }
2902
2903    /// A test-double [`HttpResponse`](http_client::HttpResponse) returning a canned body and a
2904    /// configurable `Content-Length`. Used to prove `download_to` streams the injected client's
2905    /// body through the trait (`headers()` + `body()`), not a real network response.
2906    struct DlResponse {
2907        body: Vec<u8>,
2908        headers: http_client::header::HeaderMap,
2909    }
2910
2911    impl http_client::HttpResponse for DlResponse {
2912        fn headers(&self) -> &http_client::header::HeaderMap {
2913            &self.headers
2914        }
2915        fn body(self: Box<Self>) -> Box<dyn io::Read> {
2916            Box::new(io::Cursor::new(self.body))
2917        }
2918    }
2919
2920    /// A test-double [`HttpClient`](http_client::HttpClient) (neither reqwest nor ureq) that records
2921    /// the requested URL and returns a canned [`DlResponse`].
2922    struct DlClient {
2923        body: Vec<u8>,
2924        content_length: Option<u64>,
2925        requested: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2926    }
2927
2928    impl http_client::HttpClient for DlClient {
2929        fn get(
2930            &self,
2931            url: &str,
2932            _headers: &http_client::header::HeaderMap,
2933            _timeout: Option<std::time::Duration>,
2934        ) -> Result<Box<dyn http_client::HttpResponse>> {
2935            self.requested.lock().unwrap().push(url.to_string());
2936            let mut headers = http_client::header::HeaderMap::new();
2937            if let Some(len) = self.content_length {
2938                headers.insert(
2939                    http_client::header::CONTENT_LENGTH,
2940                    len.to_string().parse().unwrap(),
2941                );
2942            }
2943            Ok(Box::new(DlResponse {
2944                body: self.body.clone(),
2945                headers,
2946            }))
2947        }
2948    }
2949
2950    /// A flaky [`HttpClient`](http_client::HttpClient) that fails the first `fail_times` GETs with a
2951    /// transport error, then succeeds — to prove `download_to` retries the request-establishment
2952    /// phase when a retry budget is configured (B9).
2953    struct FlakyDlClient {
2954        body: Vec<u8>,
2955        fail_times: std::sync::atomic::AtomicU32,
2956        attempts: std::sync::Arc<std::sync::atomic::AtomicU32>,
2957    }
2958
2959    impl http_client::HttpClient for FlakyDlClient {
2960        fn get(
2961            &self,
2962            _url: &str,
2963            _headers: &http_client::header::HeaderMap,
2964            _timeout: Option<std::time::Duration>,
2965        ) -> Result<Box<dyn http_client::HttpResponse>> {
2966            use std::sync::atomic::Ordering;
2967            self.attempts.fetch_add(1, Ordering::SeqCst);
2968            if self.fail_times.load(Ordering::SeqCst) > 0 {
2969                self.fail_times.fetch_sub(1, Ordering::SeqCst);
2970                return Err(Error::HttpStatus {
2971                    status: 503,
2972                    url: "u".into(),
2973                });
2974            }
2975            let mut headers = http_client::header::HeaderMap::new();
2976            headers.insert(
2977                http_client::header::CONTENT_LENGTH,
2978                self.body.len().to_string().parse().unwrap(),
2979            );
2980            Ok(Box::new(DlResponse {
2981                body: self.body.clone(),
2982                headers,
2983            }))
2984        }
2985    }
2986
2987    #[test]
2988    fn download_retries_request_establishment_with_configured_budget() {
2989        // B9: with a retry budget, `download_to` re-establishes the request after a transient
2990        // failure (before any bytes are streamed) and ultimately succeeds. Two failures then a
2991        // success => three attempts. A short base/cap keeps the test fast.
2992        use std::sync::atomic::{AtomicU32, Ordering};
2993        let body = b"payload-after-retries".to_vec();
2994        let attempts = std::sync::Arc::new(AtomicU32::new(0));
2995        let client = std::sync::Arc::new(FlakyDlClient {
2996            body: body.clone(),
2997            fail_times: AtomicU32::new(2),
2998            attempts: attempts.clone(),
2999        });
3000
3001        let mut out = Vec::new();
3002        let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
3003        dl.set_http_client(
3004            Some(client),
3005            #[cfg(feature = "async")]
3006            None,
3007        );
3008        dl.set_retries(
3009            3,
3010            std::time::Duration::from_millis(1),
3011            std::time::Duration::from_millis(2),
3012        );
3013        dl.download_to(&mut out).unwrap();
3014
3015        assert_eq!(out, body, "the download succeeds after retrying");
3016        assert_eq!(
3017            attempts.load(Ordering::SeqCst),
3018            3,
3019            "two failed attempts plus the successful third"
3020        );
3021    }
3022
3023    #[test]
3024    fn download_without_retry_budget_does_not_retry() {
3025        // With the default `retries == 0`, a single failure is fatal (one attempt, no retry).
3026        use std::sync::atomic::{AtomicU32, Ordering};
3027        let attempts = std::sync::Arc::new(AtomicU32::new(0));
3028        let client = std::sync::Arc::new(FlakyDlClient {
3029            body: b"never-reached".to_vec(),
3030            fail_times: AtomicU32::new(5),
3031            attempts: attempts.clone(),
3032        });
3033
3034        let mut out = Vec::new();
3035        let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
3036        dl.set_http_client(
3037            Some(client),
3038            #[cfg(feature = "async")]
3039            None,
3040        );
3041        let res = dl.download_to(&mut out);
3042        assert!(
3043            res.is_err(),
3044            "no retry budget => the first failure is fatal"
3045        );
3046        assert_eq!(
3047            attempts.load(Ordering::SeqCst),
3048            1,
3049            "exactly one attempt with retries == 0"
3050        );
3051    }
3052
3053    // -----------------------------------------------------------------------
3054    // The download path routes its request-establishment retries through
3055    // `backends::retry`, so it inherits that loop's `Error::RateLimited`
3056    // short-circuit. Nothing pinned that from this side: a future `download_to`
3057    // that grew its own retry loop (or dropped back to a bare `client.get`
3058    // wrapper) would silently start hammering an exhausted quota again. These
3059    // two tests count the requests that actually reach the wire, with a real
3060    // loopback server and the crate's default client, so the whole
3061    // classify-then-short-circuit chain is exercised. The 500 control is what
3062    // keeps the pair honest: a short-circuit firing on EVERY error would pass
3063    // the rate-limit test alone.
3064    // -----------------------------------------------------------------------
3065
3066    /// Bind a loopback stub that answers EVERY connection with the same fixed response and counts
3067    /// the requests it served. `status_line` is e.g. `"403 Forbidden"`; `extra_headers` is a block
3068    /// of CRLF-terminated header lines (possibly empty). Every response carries `Connection: close`,
3069    /// so one connection is one request and the counter is the exact number of requests the client
3070    /// issued. It accepts in a loop, so a retried request is served (and counted) rather than
3071    /// hanging. Mirrors the `counting_stub` in `backends::mod`'s tests.
3072    fn counting_stub(
3073        status_line: &str,
3074        extra_headers: &str,
3075    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
3076        use std::net::TcpListener;
3077        use std::sync::atomic::{AtomicUsize, Ordering};
3078        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3079        let base = format!("http://{}/asset.bin", listener.local_addr().unwrap());
3080        let hits = std::sync::Arc::new(AtomicUsize::new(0));
3081        let counter = hits.clone();
3082        let status_line = status_line.to_string();
3083        let extra_headers = extra_headers.to_string();
3084        std::thread::spawn(move || {
3085            loop {
3086                let (mut stream, _) = match listener.accept() {
3087                    Ok(c) => c,
3088                    Err(_) => return,
3089                };
3090                let mut buf = [0u8; 4096];
3091                let n = stream.read(&mut buf).unwrap_or(0);
3092                if n == 0 {
3093                    continue;
3094                }
3095                counter.fetch_add(1, Ordering::SeqCst);
3096                let body = "stub";
3097                let out = format!(
3098                    "HTTP/1.1 {status_line}\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n{}",
3099                    body.len(),
3100                    body
3101                );
3102                let _ = stream.write_all(out.as_bytes());
3103                let _ = stream.flush();
3104            }
3105        });
3106        (base, hits)
3107    }
3108
3109    /// A `Download` for `url` with `retries` retries and a 1ms backoff, so the control test that
3110    /// really does spend its whole budget stays fast (the backoff *values* are covered by the
3111    /// `retry_backoff_*` tests in `backends`; here only the request COUNT matters).
3112    fn download_with_retries(url: &str, retries: u32) -> Download {
3113        let mut dl = Download::from_url(url);
3114        dl.set_retries(
3115            retries,
3116            std::time::Duration::from_millis(1),
3117            std::time::Duration::from_millis(1),
3118        );
3119        dl
3120    }
3121
3122    #[test]
3123    fn download_issues_exactly_one_request_for_a_rate_limited_response() {
3124        // A 403 reporting a spent quota must put exactly ONE request on the wire even with a
3125        // retry budget of 3: `download_to` retries through `backends::retry`, which returns a
3126        // `RateLimited` immediately instead of re-issuing requests against an exhausted (and,
3127        // behind a shared egress IP, shared) budget.
3128        let (url, hits) = counting_stub("403 Forbidden", "x-ratelimit-remaining: 0\r\n");
3129        let mut out = Vec::new();
3130        let res = download_with_retries(&url, 3).download_to(&mut out);
3131        assert!(
3132            matches!(res, Err(Error::RateLimited { status: 403, .. })),
3133            "a 403 with a spent quota must surface as RateLimited, got: {:?}",
3134            res.err()
3135        );
3136        assert_eq!(
3137            hits.load(std::sync::atomic::Ordering::SeqCst),
3138            1,
3139            "a rate-limited download must not be retried: exactly one request may reach the server"
3140        );
3141    }
3142
3143    #[test]
3144    fn download_spends_the_whole_retry_budget_for_a_server_error() {
3145        // The control for the test above. A 500 is not rate limiting, so the download's retry
3146        // budget is still spent in full: 1 initial attempt + 3 retries = 4 requests on the wire.
3147        let (url, hits) = counting_stub("500 Internal Server Error", "");
3148        let mut out = Vec::new();
3149        let res = download_with_retries(&url, 3).download_to(&mut out);
3150        assert!(
3151            matches!(res, Err(Error::HttpStatus { status: 500, .. })),
3152            "a 500 must surface as HttpStatus, got: {:?}",
3153            res.err()
3154        );
3155        assert_eq!(
3156            hits.load(std::sync::atomic::Ordering::SeqCst),
3157            4,
3158            "a non-rate-limited download failure must still consume the retry budget (1 + 3)"
3159        );
3160    }
3161
3162    #[test]
3163    fn download_to_uses_injected_http_client_through_the_trait() {
3164        // Gap #4 (sync Download path): an arbitrary `Arc<dyn HttpClient>` that is NOT reqwest/ureq,
3165        // injected via `.http_client(...)`, must actually drive `download_to` — the streamed body
3166        // comes from the fake and the fake records the requested URL. No network is touched (the URL
3167        // is non-routable).
3168        let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3169        let body = b"injected-binary-payload".to_vec();
3170        let client = std::sync::Arc::new(DlClient {
3171            body: body.clone(),
3172            content_length: Some(body.len() as u64),
3173            requested: requested.clone(),
3174        });
3175
3176        let mut out = Vec::new();
3177        let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
3178        dl.set_http_client(
3179            Some(client),
3180            #[cfg(feature = "async")]
3181            None,
3182        );
3183        dl.download_to(&mut out).unwrap();
3184
3185        assert_eq!(out, body, "download_to streamed the injected client's body");
3186        let urls = requested.lock().unwrap();
3187        assert_eq!(
3188            urls.len(),
3189            1,
3190            "exactly one GET went through the injected client"
3191        );
3192        assert_eq!(urls[0], "https://nonroutable.invalid/asset.bin");
3193    }
3194
3195    #[test]
3196    fn download_to_handles_injected_client_without_content_length() {
3197        // When the injected response carries no Content-Length, `download_to` must still stream the
3198        // whole body to completion (size defaults to 0 -> no progress bar, `total == None`) and the
3199        // progress callback still fires with `total = None`.
3200        let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3201        let body = b"no-length-body".to_vec();
3202        let client = std::sync::Arc::new(DlClient {
3203            body: body.clone(),
3204            content_length: None,
3205            requested: requested.clone(),
3206        });
3207
3208        let totals = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Option<u64>>::new()));
3209        let sink = totals.clone();
3210        let mut out = Vec::new();
3211        let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
3212        dl.set_http_client(
3213            Some(client),
3214            #[cfg(feature = "async")]
3215            None,
3216        );
3217        dl.progress_callback(move |_d, total| sink.lock().unwrap().push(total));
3218        dl.download_to(&mut out).unwrap();
3219
3220        assert_eq!(
3221            out, body,
3222            "the full body is streamed even with no Content-Length"
3223        );
3224        let totals = totals.lock().unwrap();
3225        assert!(
3226            totals.iter().all(|t| t.is_none()),
3227            "with no Content-Length the callback's total must be None, got {:?}",
3228            totals
3229        );
3230    }
3231
3232    // --- S1: presigned-URL redaction in the download retry warning ---------------------------
3233
3234    /// A `log::Log` that captures every record's formatted message into a shared global buffer.
3235    /// Tests filter the buffer by a unique URL host so a single global logger can serve them all.
3236    struct CaptureLogger;
3237    static CAPTURE_LOGGER: CaptureLogger = CaptureLogger;
3238
3239    fn log_capture() -> &'static std::sync::Mutex<Vec<String>> {
3240        static BUF: std::sync::OnceLock<std::sync::Mutex<Vec<String>>> = std::sync::OnceLock::new();
3241        BUF.get_or_init(|| std::sync::Mutex::new(Vec::new()))
3242    }
3243
3244    impl log::Log for CaptureLogger {
3245        fn enabled(&self, _: &log::Metadata) -> bool {
3246            true
3247        }
3248        fn log(&self, record: &log::Record) {
3249            log_capture()
3250                .lock()
3251                .unwrap()
3252                .push(format!("{}", record.args()));
3253        }
3254        fn flush(&self) {}
3255    }
3256
3257    fn install_capture_logger() {
3258        static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
3259        INIT.get_or_init(|| {
3260            // Ignore an error if another harness already set the global logger.
3261            let _ = log::set_logger(&CAPTURE_LOGGER);
3262            log::set_max_level(log::LevelFilter::Warn);
3263        });
3264    }
3265
3266    #[test]
3267    fn download_retry_warning_redacts_presigned_signature() {
3268        // S1: a download that retries must not leak a presigned S3 `X-Amz-Signature` /
3269        // `X-Amz-Credential` into the retry warning. Drive `download_to` with a flaky client (one
3270        // failure, then success) so the retry closure fires exactly one `log::warn!`, and assert the
3271        // captured line carries neither secret. On the pre-fix code (which logged the raw
3272        // `self.url`) the signature would appear verbatim and this test fails.
3273        use std::sync::atomic::AtomicU32;
3274
3275        install_capture_logger();
3276        let sig = "abc123-secret-signature-value";
3277        let cred = "AKIAREDACTTESTONLY";
3278        let host = "s3-redact-retry-test.invalid";
3279        let url = format!(
3280            "https://{host}/app.tar.gz?X-Amz-Credential={cred}%2F20260101\
3281             &X-Amz-Expires=300&X-Amz-Signature={sig}&X-Amz-SignedHeaders=host"
3282        );
3283
3284        let attempts = std::sync::Arc::new(AtomicU32::new(0));
3285        let client = std::sync::Arc::new(FlakyDlClient {
3286            body: b"ok".to_vec(),
3287            fail_times: AtomicU32::new(1),
3288            attempts: attempts.clone(),
3289        });
3290
3291        let mut out = Vec::new();
3292        let mut dl = Download::from_url(url);
3293        dl.set_http_client(
3294            Some(client),
3295            #[cfg(feature = "async")]
3296            None,
3297        );
3298        dl.set_retries(
3299            1,
3300            std::time::Duration::from_millis(1),
3301            std::time::Duration::from_millis(2),
3302        );
3303        dl.download_to(&mut out).unwrap();
3304
3305        let lines: Vec<String> = log_capture()
3306            .lock()
3307            .unwrap()
3308            .iter()
3309            .filter(|l| l.contains(host))
3310            .cloned()
3311            .collect();
3312        assert!(
3313            !lines.is_empty(),
3314            "the retry closure should have logged a warning for {host}"
3315        );
3316        for line in &lines {
3317            assert!(
3318                !line.contains(sig),
3319                "presigned signature leaked into the retry warning: {line}"
3320            );
3321            assert!(
3322                !line.contains(cred),
3323                "presigned credential leaked into the retry warning: {line}"
3324            );
3325        }
3326    }
3327
3328    // --- S3: extracted zip modes must not carry setuid/setgid/sticky --------------------------
3329
3330    #[cfg(all(unix, feature = "archive-zip"))]
3331    #[test]
3332    fn extract_zip_masks_setuid_setgid_sticky_bits() {
3333        // S3: a zip entry archived with a setuid mode (0o4755) must NOT install a setuid file; the
3334        // extractor masks the mode to `& 0o777`, so the setuid/setgid/sticky bits are dropped while
3335        // the ordinary rwx bits survive. On the pre-fix code (`from_mode(mode)`) the installed file
3336        // would be setuid and this test fails.
3337        use std::io::Write as _;
3338        use std::os::unix::fs::PermissionsExt;
3339
3340        let tmp = tempfile::tempdir().unwrap();
3341        let zip_path = tmp.path().join("archive.zip");
3342        {
3343            let file = fs::File::create(&zip_path).unwrap();
3344            let mut zip = zip::ZipWriter::new(file);
3345            let opts = zip::write::SimpleFileOptions::default()
3346                .compression_method(zip::CompressionMethod::Stored)
3347                .unix_permissions(0o4755);
3348            zip.start_file("payload", opts).unwrap();
3349            zip.write_all(b"#!/bin/sh\n").unwrap();
3350            zip.finish().unwrap();
3351        }
3352
3353        let out_dir = tmp.path().join("out");
3354        fs::create_dir_all(&out_dir).unwrap();
3355        let mut ex = Extract::from_source(&zip_path);
3356        ex.archive(ArchiveKind::Zip);
3357        ex.extract_into(&out_dir).unwrap();
3358
3359        let extracted = out_dir.join("payload");
3360        let mode = fs::metadata(&extracted).unwrap().permissions().mode();
3361        assert_eq!(
3362            mode & 0o7000,
3363            0,
3364            "extracted file must carry no setuid/setgid/sticky bits, got mode {mode:o}"
3365        );
3366        assert_eq!(
3367            mode & 0o777,
3368            0o755,
3369            "the ordinary rwx bits should be preserved, got mode {mode:o}"
3370        );
3371    }
3372
3373    // --- S4: optional max_download_size cap ---------------------------------------------------
3374
3375    #[test]
3376    fn download_max_download_size_aborts_when_body_exceeds_cap() {
3377        // S4: a body larger than the configured cap aborts the streaming download with an error
3378        // naming the cap, instead of writing an unbounded amount to `dest`.
3379        let body = vec![0u8; 4096];
3380        let client = std::sync::Arc::new(DlClient {
3381            body: body.clone(),
3382            content_length: Some(body.len() as u64),
3383            requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
3384        });
3385
3386        let mut out = Vec::new();
3387        let mut dl = Download::from_url("https://nonroutable.invalid/big.bin");
3388        dl.set_http_client(
3389            Some(client),
3390            #[cfg(feature = "async")]
3391            None,
3392        );
3393        dl.max_download_size(1024);
3394        let res = dl.download_to(&mut out);
3395        assert!(res.is_err(), "a body over the cap must error");
3396        let msg = res.unwrap_err().to_string();
3397        assert!(
3398            msg.contains("max_download_size"),
3399            "the error should name the cap: {msg}"
3400        );
3401    }
3402
3403    #[test]
3404    fn download_max_download_size_allows_body_under_cap() {
3405        // S4: with the default (no cap) unchanged, an explicit cap larger than the body still lets
3406        // the whole body download successfully.
3407        let body = vec![7u8; 512];
3408        let client = std::sync::Arc::new(DlClient {
3409            body: body.clone(),
3410            content_length: Some(body.len() as u64),
3411            requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
3412        });
3413
3414        let mut out = Vec::new();
3415        let mut dl = Download::from_url("https://nonroutable.invalid/small.bin");
3416        dl.set_http_client(
3417            Some(client),
3418            #[cfg(feature = "async")]
3419            None,
3420        );
3421        dl.max_download_size(1024);
3422        dl.download_to(&mut out).unwrap();
3423        assert_eq!(out, body, "a body under the cap downloads in full");
3424    }
3425
3426    /// Async test-double response: yields the body as a single `bytes_stream` chunk and as `text`.
3427    #[cfg(feature = "async")]
3428    struct DlAsyncResponse {
3429        body: Vec<u8>,
3430        headers: http_client::header::HeaderMap,
3431    }
3432
3433    #[cfg(feature = "async")]
3434    impl http_client::AsyncHttpResponse for DlAsyncResponse {
3435        fn headers(&self) -> &http_client::header::HeaderMap {
3436            &self.headers
3437        }
3438        fn text(self: Box<Self>) -> futures_util::future::BoxFuture<'static, Result<String>> {
3439            Box::pin(async move { Ok(String::from_utf8_lossy(&self.body).into_owned()) })
3440        }
3441        fn bytes_stream(
3442            self: Box<Self>,
3443        ) -> futures_util::stream::BoxStream<'static, Result<bytes::Bytes>> {
3444            Box::pin(futures_util::stream::once(async move {
3445                Ok(bytes::Bytes::from(self.body))
3446            }))
3447        }
3448    }
3449
3450    /// Async test-double client (not reqwest) that records the URL and returns [`DlAsyncResponse`].
3451    #[cfg(feature = "async")]
3452    struct DlAsyncClient {
3453        body: Vec<u8>,
3454        content_length: Option<u64>,
3455        requested: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
3456    }
3457
3458    #[cfg(feature = "async")]
3459    impl http_client::AsyncHttpClient for DlAsyncClient {
3460        fn get<'a>(
3461            &'a self,
3462            url: &'a str,
3463            _headers: &'a http_client::header::HeaderMap,
3464            _timeout: Option<std::time::Duration>,
3465        ) -> futures_util::future::BoxFuture<'a, Result<Box<dyn http_client::AsyncHttpResponse>>>
3466        {
3467            self.requested.lock().unwrap().push(url.to_string());
3468            let mut headers = http_client::header::HeaderMap::new();
3469            if let Some(len) = self.content_length {
3470                headers.insert(
3471                    http_client::header::CONTENT_LENGTH,
3472                    len.to_string().parse().unwrap(),
3473                );
3474            }
3475            let body = self.body.clone();
3476            Box::pin(async move {
3477                Ok(Box::new(DlAsyncResponse { body, headers })
3478                    as Box<dyn http_client::AsyncHttpResponse>)
3479            })
3480        }
3481    }
3482
3483    #[cfg(feature = "async")]
3484    #[tokio::test]
3485    async fn download_to_async_uses_injected_async_client_through_the_trait() {
3486        // Gap #4 (async Download path): an injected `Arc<dyn AsyncHttpClient>` (not reqwest) must
3487        // drive `download_to_async` via `bytes_stream()`, independently of the sync injection path.
3488        let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3489        let body = b"async-injected-payload".to_vec();
3490        let client = std::sync::Arc::new(DlAsyncClient {
3491            body: body.clone(),
3492            content_length: Some(body.len() as u64),
3493            requested: requested.clone(),
3494        });
3495
3496        let mut out = Vec::new();
3497        let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
3498        dl.set_http_client(None, Some(client));
3499        dl.download_to_async(&mut out).await.unwrap();
3500
3501        assert_eq!(
3502            out, body,
3503            "download_to_async streamed the injected client's body"
3504        );
3505        let urls = requested.lock().unwrap();
3506        assert_eq!(
3507            urls.len(),
3508            1,
3509            "exactly one async GET went through the injected client"
3510        );
3511        assert_eq!(urls[0], "https://nonroutable.invalid/asset.bin");
3512    }
3513
3514    #[cfg(feature = "async")]
3515    #[tokio::test]
3516    async fn sync_and_async_injection_are_independent() {
3517        // Setting only the async client must leave the sync client unset (and vice versa), proving
3518        // the two injection slots are independent: a `download_to_async` with only an async client
3519        // injected uses it, and does not fall back to / require the sync slot.
3520        let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3521        let body = b"only-async".to_vec();
3522        let async_client = std::sync::Arc::new(DlAsyncClient {
3523            body: body.clone(),
3524            content_length: Some(body.len() as u64),
3525            requested: requested.clone(),
3526        });
3527
3528        let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
3529        dl.set_http_client(None, Some(async_client));
3530        // The sync slot was never set.
3531        assert!(
3532            dl.client.is_none(),
3533            "injecting an async client must not populate the sync client slot"
3534        );
3535        assert!(dl.async_client.is_some(), "the async slot is populated");
3536
3537        let mut out = Vec::new();
3538        dl.download_to_async(&mut out).await.unwrap();
3539        assert_eq!(out, body);
3540    }
3541
3542    // Regression: `progress_callback` (the byte-level hook) must still fire even when the
3543    // `progress-bar` feature is disabled. The terminal `indicatif` bar and the callback are
3544    // orthogonal; disabling the former must not silence the latter.
3545    #[cfg(not(feature = "progress-bar"))]
3546    #[test]
3547    fn progress_callback_fires_without_progress_bar_feature() {
3548        use std::net::TcpListener;
3549        use std::sync::{Arc, Mutex};
3550
3551        let body = "y".repeat(8_000);
3552        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3553        let addr = listener.local_addr().unwrap();
3554        let served = body.clone();
3555        std::thread::spawn(move || {
3556            let (mut stream, _) = listener.accept().unwrap();
3557            let mut buf = [0u8; 1024];
3558            let _ = stream.read(&mut buf);
3559            let resp = format!(
3560                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3561                served.len(),
3562                served
3563            );
3564            let _ = stream.write_all(resp.as_bytes());
3565        });
3566
3567        let calls = Arc::new(Mutex::new(Vec::<(u64, Option<u64>)>::new()));
3568        let sink = calls.clone();
3569        let mut out = Vec::new();
3570        Download::from_url(format!("http://{addr}/file"))
3571            // `show_download_progress(true)` is intentionally set: with progress-bar OFF it
3572            // must be a no-op, while the callback below must still fire.
3573            .show_download_progress(true)
3574            .progress_callback(move |downloaded, total| {
3575                sink.lock().unwrap().push((downloaded, total));
3576            })
3577            .download_to(&mut out)
3578            .unwrap();
3579
3580        assert_eq!(out.len(), 8_000);
3581        let calls = calls.lock().unwrap();
3582        assert!(
3583            !calls.is_empty(),
3584            "progress_callback must fire even with progress-bar feature disabled"
3585        );
3586        assert!(
3587            calls.iter().all(|(_, total)| *total == Some(8_000)),
3588            "total should reflect Content-Length"
3589        );
3590        assert_eq!(
3591            calls.last().unwrap().0,
3592            8_000,
3593            "final byte count should equal body length"
3594        );
3595    }
3596
3597    #[cfg(feature = "compression-tar-gz")]
3598    #[test]
3599    fn detect_plain_gz() {
3600        assert_eq!(
3601            ArchiveKind::Plain(Some(Compression::Gz)),
3602            detect_archive(&PathBuf::from("Something.exe.gz")).unwrap()
3603        );
3604    }
3605
3606    // Without the gzip feature, a plain `.gz` asset must be rejected with `CompressionNotEnabled`,
3607    // not silently detected as a decodable archive (which would install the compressed bytes).
3608    #[cfg(not(feature = "compression-tar-gz"))]
3609    #[test]
3610    fn detect_plain_gz_without_feature_errors() {
3611        assert!(matches!(
3612            detect_archive(&PathBuf::from("Something.exe.gz")),
3613            Err(Error::CompressionNotEnabled(_))
3614        ));
3615    }
3616
3617    #[cfg(not(feature = "archive-tar"))]
3618    #[test]
3619    #[ignore]
3620    fn detect_tar_gz() {
3621        println!("WARNING: Please enable 'archive-tar' feature!");
3622    }
3623    #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
3624    #[test]
3625    fn detect_tar_gz() {
3626        assert_eq!(
3627            ArchiveKind::Tar(Some(Compression::Gz)),
3628            detect_archive(&PathBuf::from("Something.tar.gz")).unwrap()
3629        );
3630    }
3631    // `.tar.gz` with the tar container but no gzip codec must error, not fall through to an opaque
3632    // failure inside the tar reader.
3633    #[cfg(all(feature = "archive-tar", not(feature = "compression-tar-gz")))]
3634    #[test]
3635    fn detect_tar_gz_without_compression_errors() {
3636        assert!(matches!(
3637            detect_archive(&PathBuf::from("Something.tar.gz")),
3638            Err(Error::CompressionNotEnabled(_))
3639        ));
3640    }
3641
3642    #[cfg(feature = "compression-tar-xz")]
3643    #[test]
3644    fn detect_plain_xz() {
3645        assert_eq!(
3646            ArchiveKind::Plain(Some(Compression::Xz)),
3647            detect_archive(&PathBuf::from("Something.exe.xz")).unwrap()
3648        );
3649    }
3650
3651    // Without the xz feature, a plain `.xz` asset must be rejected with `CompressionNotEnabled`
3652    // rather than silently installed as still-compressed bytes (the original #143 footgun).
3653    #[cfg(not(feature = "compression-tar-xz"))]
3654    #[test]
3655    fn detect_plain_xz_without_feature_errors() {
3656        assert!(matches!(
3657            detect_archive(&PathBuf::from("Something.exe.xz")),
3658            Err(Error::CompressionNotEnabled(_))
3659        ));
3660    }
3661
3662    #[cfg(all(feature = "archive-tar", feature = "compression-tar-xz"))]
3663    #[test]
3664    fn detect_tar_xz() {
3665        // Both the `.tar.xz` double extension and the `.txz` short form resolve to a gzip-free tar.
3666        assert_eq!(
3667            ArchiveKind::Tar(Some(Compression::Xz)),
3668            detect_archive(&PathBuf::from("Something.tar.xz")).unwrap()
3669        );
3670        assert_eq!(
3671            ArchiveKind::Tar(Some(Compression::Xz)),
3672            detect_archive(&PathBuf::from("Something.txz")).unwrap()
3673        );
3674    }
3675
3676    // `.tar.xz` / `.txz` with the tar container but no xz codec must error, not fall through.
3677    #[cfg(all(feature = "archive-tar", not(feature = "compression-tar-xz")))]
3678    #[test]
3679    fn detect_tar_xz_without_compression_errors() {
3680        assert!(matches!(
3681            detect_archive(&PathBuf::from("Something.tar.xz")),
3682            Err(Error::CompressionNotEnabled(_))
3683        ));
3684        assert!(matches!(
3685            detect_archive(&PathBuf::from("Something.txz")),
3686            Err(Error::CompressionNotEnabled(_))
3687        ));
3688    }
3689
3690    #[cfg(not(feature = "archive-tar"))]
3691    #[test]
3692    #[ignore]
3693    fn detect_plain_tar() {
3694        println!("WARNING: Please enable 'archive-tar' feature!");
3695    }
3696    #[cfg(feature = "archive-tar")]
3697    #[test]
3698    fn detect_plain_tar() {
3699        assert_eq!(
3700            ArchiveKind::Tar(None),
3701            detect_archive(&PathBuf::from("Something.tar")).unwrap()
3702        );
3703    }
3704
3705    #[cfg(not(feature = "archive-zip"))]
3706    #[test]
3707    #[ignore]
3708    fn detect_zip() {
3709        println!("WARNING: Please enable 'archive-zip' feature!");
3710    }
3711    #[cfg(feature = "archive-zip")]
3712    #[test]
3713    fn detect_zip() {
3714        assert_eq!(
3715            ArchiveKind::Zip,
3716            detect_archive(&PathBuf::from("Something.zip")).unwrap()
3717        );
3718    }
3719
3720    #[allow(dead_code)]
3721    fn cmp_content<T: AsRef<Path>>(path: T, s: &str) {
3722        let mut content = String::new();
3723        let mut f = File::open(&path).unwrap();
3724        f.read_to_string(&mut content).unwrap();
3725        assert!(s == content);
3726    }
3727
3728    #[cfg(not(feature = "compression-tar-gz"))]
3729    #[test]
3730    #[ignore]
3731    fn unpack_plain_gzip() {
3732        println!("WARNING: Please enable 'compression-tar-gz' feature!");
3733    }
3734    #[cfg(feature = "compression-tar-gz")]
3735    #[test]
3736    fn unpack_plain_gzip() {
3737        let tmp_dir = tempfile::Builder::new()
3738            .prefix("self_update_unpack_plain_gzip_src")
3739            .tempdir()
3740            .expect("tempdir fail");
3741        let fp = tmp_dir.path().with_file_name("temp.gz");
3742        let mut tmp_file = File::create(&fp).expect("temp file create fail");
3743        let mut e = GzEncoder::new(&mut tmp_file, flate2::Compression::default());
3744        e.write_all(b"This is a test!").expect("gz encode fail");
3745        e.finish().expect("gz finish fail");
3746
3747        let out_tmp = tempfile::Builder::new()
3748            .prefix("self_update_unpack_plain_gzip_outdir")
3749            .tempdir()
3750            .expect("tempdir fail");
3751        let out_path = out_tmp.path();
3752        Extract::from_source(&fp)
3753            .extract_into(out_path)
3754            .expect("extract fail");
3755        let out_file = out_path.join("temp");
3756        assert!(out_file.exists());
3757        cmp_content(out_file, "This is a test!");
3758    }
3759
3760    #[cfg(not(feature = "compression-tar-gz"))]
3761    #[test]
3762    #[ignore]
3763    fn unpack_plain_gzip_double_ext() {
3764        println!("WARNING: Please enable 'compression-tar-gz' feature!");
3765    }
3766    #[cfg(feature = "compression-tar-gz")]
3767    #[test]
3768    fn unpack_plain_gzip_double_ext() {
3769        let tmp_dir = tempfile::Builder::new()
3770            .prefix("self_update_unpack_plain_gzip_double_ext_src")
3771            .tempdir()
3772            .expect("tempdir fail");
3773        let fp = tmp_dir.path().with_file_name("temp.txt.gz");
3774        let mut tmp_file = File::create(&fp).expect("temp file create fail");
3775        let mut e = GzEncoder::new(&mut tmp_file, flate2::Compression::default());
3776        e.write_all(b"This is a test!").expect("gz encode fail");
3777        e.finish().expect("gz finish fail");
3778
3779        let out_tmp = tempfile::Builder::new()
3780            .prefix("self_update_unpack_plain_gzip_double_ext_outdir")
3781            .tempdir()
3782            .expect("tempdir fail");
3783        let out_path = out_tmp.path();
3784        Extract::from_source(&fp)
3785            .extract_into(out_path)
3786            .expect("extract fail");
3787        let out_file = out_path.join("temp.txt");
3788        assert!(out_file.exists());
3789        cmp_content(out_file, "This is a test!");
3790    }
3791
3792    #[cfg(not(all(feature = "archive-tar", feature = "compression-tar-gz")))]
3793    #[test]
3794    #[ignore]
3795    fn unpack_tar_gzip() {
3796        println!("WARNING: Please enable 'archive-tar compression-tar-gz' features!");
3797    }
3798    #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
3799    #[test]
3800    fn unpack_tar_gzip() {
3801        test_extract_into(
3802            "self_update_unpack_tar_gzip_src",
3803            "archive.tar.gz",
3804            ArchiveKind::Tar(Some(Compression::Gz)),
3805        );
3806    }
3807
3808    #[cfg(not(feature = "compression-tar-gz"))]
3809    #[test]
3810    #[ignore]
3811    fn unpack_file_plain_gzip() {
3812        println!("WARNING: Please enable 'compression-tar-gz' feature!");
3813    }
3814    #[cfg(feature = "compression-tar-gz")]
3815    #[test]
3816    fn unpack_file_plain_gzip() {
3817        let tmp_dir = tempfile::Builder::new()
3818            .prefix("self_update_unpack_file_plain_gzip_src")
3819            .tempdir()
3820            .expect("tempdir fail");
3821        let fp = tmp_dir.path().with_file_name("temp.gz");
3822        let mut tmp_file = File::create(&fp).expect("temp file create fail");
3823        let mut e = GzEncoder::new(&mut tmp_file, flate2::Compression::default());
3824        e.write_all(b"This is a test!").expect("gz encode fail");
3825        e.finish().expect("gz finish fail");
3826
3827        let out_tmp = tempfile::Builder::new()
3828            .prefix("self_update_unpack_file_plain_gzip_outdir")
3829            .tempdir()
3830            .expect("tempdir fail");
3831        let out_path = out_tmp.path();
3832        Extract::from_source(&fp)
3833            .extract_file(out_path, "renamed_file")
3834            .expect("extract fail");
3835        let out_file = out_path.join("renamed_file");
3836        assert!(out_file.exists());
3837        cmp_content(out_file, "This is a test!");
3838    }
3839
3840    #[cfg(not(all(feature = "archive-tar", feature = "compression-tar-gz")))]
3841    #[test]
3842    #[ignore]
3843    fn unpack_file_tar_gzip() {
3844        println!("WARNING: Please enable 'archive-tar compression-tar-gz' features!");
3845    }
3846    #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
3847    #[test]
3848    fn unpack_file_tar_gzip() {
3849        test_extract_file(
3850            "self_update_unpack_file_tar_gzip_src",
3851            "archive.tar.gz",
3852            ArchiveKind::Tar(Some(Compression::Gz)),
3853        );
3854    }
3855
3856    // --- xz (#143) round-trips, mirroring the gzip coverage above -----------------------------
3857
3858    // A plain single-file `.xz` decodes to the file with the `.xz` extension stripped.
3859    #[cfg(feature = "compression-tar-xz")]
3860    #[test]
3861    fn unpack_plain_xz() {
3862        let tmp_dir = tempfile::Builder::new()
3863            .prefix("self_update_unpack_plain_xz_src")
3864            .tempdir()
3865            .expect("tempdir fail");
3866        let fp = tmp_dir.path().with_file_name("temp.xz");
3867        {
3868            let mut tmp_file = File::create(&fp).expect("temp file create fail");
3869            lzma_rs::xz_compress(&mut &b"This is a test!"[..], &mut tmp_file)
3870                .expect("xz encode fail");
3871        }
3872
3873        let out_tmp = tempfile::Builder::new()
3874            .prefix("self_update_unpack_plain_xz_outdir")
3875            .tempdir()
3876            .expect("tempdir fail");
3877        let out_path = out_tmp.path();
3878        Extract::from_source(&fp)
3879            .extract_into(out_path)
3880            .expect("extract fail");
3881        let out_file = out_path.join("temp");
3882        assert!(out_file.exists());
3883        cmp_content(out_file, "This is a test!");
3884    }
3885
3886    // A plain `.xz` extracted via `extract_file` is written under the requested name.
3887    #[cfg(feature = "compression-tar-xz")]
3888    #[test]
3889    fn unpack_file_plain_xz() {
3890        let tmp_dir = tempfile::Builder::new()
3891            .prefix("self_update_unpack_file_plain_xz_src")
3892            .tempdir()
3893            .expect("tempdir fail");
3894        let fp = tmp_dir.path().with_file_name("temp.xz");
3895        {
3896            let mut tmp_file = File::create(&fp).expect("temp file create fail");
3897            lzma_rs::xz_compress(&mut &b"This is a test!"[..], &mut tmp_file)
3898                .expect("xz encode fail");
3899        }
3900
3901        let out_tmp = tempfile::Builder::new()
3902            .prefix("self_update_unpack_file_plain_xz_outdir")
3903            .tempdir()
3904            .expect("tempdir fail");
3905        let out_path = out_tmp.path();
3906        Extract::from_source(&fp)
3907            .extract_file(out_path, "renamed_file")
3908            .expect("extract fail");
3909        let out_file = out_path.join("renamed_file");
3910        assert!(out_file.exists());
3911        cmp_content(out_file, "This is a test!");
3912    }
3913
3914    // A `.tar.xz` unpacks its full tree, exercising the streamed tar-over-xz path end to end.
3915    #[cfg(all(feature = "archive-tar", feature = "compression-tar-xz"))]
3916    #[test]
3917    fn unpack_tar_xz() {
3918        test_extract_into(
3919            "self_update_unpack_tar_xz_src",
3920            "archive.tar.xz",
3921            ArchiveKind::Tar(Some(Compression::Xz)),
3922        );
3923    }
3924
3925    // A single member of a `.tar.xz` is extractable by path.
3926    #[cfg(all(feature = "archive-tar", feature = "compression-tar-xz"))]
3927    #[test]
3928    fn unpack_file_tar_xz() {
3929        test_extract_file(
3930            "self_update_unpack_file_tar_xz_src",
3931            "archive.tar.xz",
3932            ArchiveKind::Tar(Some(Compression::Xz)),
3933        );
3934    }
3935
3936    #[cfg(not(feature = "archive-zip"))]
3937    #[test]
3938    #[ignore]
3939    fn unpack_zip() {
3940        println!("WARNING: Please enable 'archive-zip' feature!");
3941    }
3942    #[cfg(feature = "archive-zip")]
3943    #[test]
3944    fn unpack_zip() {
3945        test_extract_into(
3946            "self_update_unpack_zip_src",
3947            "archive.zip",
3948            ArchiveKind::Zip,
3949        );
3950    }
3951
3952    #[cfg(not(feature = "archive-zip"))]
3953    #[test]
3954    #[ignore]
3955    fn unpack_zip_file() {
3956        println!("WARNING: Please enable 'archive-zip' feature!");
3957    }
3958    #[cfg(feature = "archive-zip")]
3959    #[test]
3960    fn unpack_zip_file() {
3961        test_extract_file(
3962            "self_update_unpack_zip_src",
3963            "archive.zip",
3964            ArchiveKind::Zip,
3965        );
3966    }
3967
3968    fn test_extract_into(tmpfile_prefix: &str, src_archive_path: &str, archive_kind: ArchiveKind) {
3969        let tmp_dir = tempfile::Builder::new()
3970            .prefix(tmpfile_prefix)
3971            .tempdir()
3972            .expect("Failed to create temp dir");
3973
3974        let tmp_path = tmp_dir.path();
3975        let archive_file_path = tmp_path.join(src_archive_path);
3976        let archive_file = File::create(&archive_file_path).expect("Failed to create archive file");
3977
3978        build_test_archive(archive_file, &archive_file_path, archive_kind);
3979
3980        let out_tmp = tempfile::Builder::new()
3981            .prefix(&format!("{}_outdir", tmpfile_prefix))
3982            .tempdir()
3983            .expect("tempdir fail");
3984        let out_path = out_tmp.path();
3985
3986        Extract::from_source(&archive_file_path)
3987            .extract_into(out_path)
3988            .expect("extract fail");
3989
3990        let out_file = out_path.join("temp.txt");
3991        assert!(out_file.exists());
3992        cmp_content(&out_file, "This is a test!");
3993
3994        let out_file = out_path.join("inner_archive/temp2.txt");
3995        assert!(out_file.exists());
3996        cmp_content(&out_file, "This is a second test!");
3997    }
3998
3999    fn test_extract_file(tmpfile_prefix: &str, src_archive_path: &str, archive_kind: ArchiveKind) {
4000        let tmp_dir = tempfile::Builder::new()
4001            .prefix(tmpfile_prefix)
4002            .tempdir()
4003            .expect("Failed to create temp dir");
4004
4005        let tmp_path = tmp_dir.path();
4006        let archive_file_path = tmp_path.join(src_archive_path);
4007        let archive_file = File::create(&archive_file_path).expect("Failed to create archive file");
4008
4009        build_test_archive(archive_file, &archive_file_path, archive_kind);
4010
4011        let out_tmp = tempfile::Builder::new()
4012            .prefix(&format!("{}_outdir", tmpfile_prefix))
4013            .tempdir()
4014            .expect("tempdir fail");
4015        let out_path = out_tmp.path();
4016
4017        Extract::from_source(&archive_file_path)
4018            .extract_file(out_path, "temp.txt")
4019            .expect("extract fail");
4020        let out_file = out_path.join("temp.txt");
4021        assert!(out_file.exists());
4022        cmp_content(&out_file, "This is a test!");
4023
4024        Extract::from_source(&archive_file_path)
4025            .extract_file(out_path, "inner_archive/temp2.txt")
4026            .expect("extract fail");
4027        let out_file = out_path.join("inner_archive/temp2.txt");
4028        assert!(out_file.exists());
4029        cmp_content(&out_file, "This is a second test!");
4030    }
4031
4032    // A zip whose entry name escapes the output dir (`../escape.txt`) must be rejected, and nothing
4033    // may be written outside `into_dir`. Guards against zip-slip.
4034    #[cfg(feature = "archive-zip")]
4035    #[test]
4036    fn extract_into_rejects_zip_slip() {
4037        let staging = tempfile::tempdir().expect("tempdir");
4038        let archive_path = staging.path().join("evil.zip");
4039        {
4040            let f = File::create(&archive_path).expect("create zip");
4041            let mut zip = zip::ZipWriter::new(f);
4042            let options = zip::write::SimpleFileOptions::default()
4043                .compression_method(zip::CompressionMethod::Stored);
4044            zip.start_file("../escape.txt", options).expect("start");
4045            zip.write_all(b"pwned").expect("write");
4046            zip.finish().expect("finish");
4047        }
4048        let out_tmp = tempfile::tempdir().expect("tempdir");
4049        let out_dir = out_tmp.path().join("into");
4050        fs::create_dir_all(&out_dir).expect("mkdir");
4051
4052        let res = Extract::from_source(&archive_path).extract_into(&out_dir);
4053        assert!(res.is_err(), "a zip-slip entry must be rejected");
4054        assert!(
4055            !out_tmp.path().join("escape.txt").exists(),
4056            "nothing must be written outside the extraction dir"
4057        );
4058    }
4059
4060    // A zip entry carrying an executable unix mode must extract with that mode preserved, so a
4061    // binary installed from a zip to a custom path stays runnable.
4062    #[cfg(all(feature = "archive-zip", unix))]
4063    #[test]
4064    fn extract_into_preserves_zip_unix_mode() {
4065        use std::os::unix::fs::PermissionsExt;
4066        let staging = tempfile::tempdir().expect("tempdir");
4067        let archive_path = staging.path().join("app.zip");
4068        {
4069            let f = File::create(&archive_path).expect("create zip");
4070            let mut zip = zip::ZipWriter::new(f);
4071            let options = zip::write::SimpleFileOptions::default()
4072                .compression_method(zip::CompressionMethod::Stored)
4073                .unix_permissions(0o755);
4074            zip.start_file("app", options).expect("start");
4075            zip.write_all(b"#!/bin/sh\n").expect("write");
4076            zip.finish().expect("finish");
4077        }
4078        let out_tmp = tempfile::tempdir().expect("tempdir");
4079        Extract::from_source(&archive_path)
4080            .extract_into(out_tmp.path())
4081            .expect("extract");
4082        let mode = fs::metadata(out_tmp.path().join("app"))
4083            .expect("stat")
4084            .permissions()
4085            .mode();
4086        assert!(
4087            mode & 0o111 != 0,
4088            "the executable bit must be preserved, got mode {:o}",
4089            mode
4090        );
4091    }
4092
4093    // A zip entry that is a relative symlink (target inside the tree) must be restored as a real
4094    // symlink, not written out as a regular file containing the target string. The file it points
4095    // at must be readable through the link. Guards the `.app` framework-symlink regression.
4096    #[cfg(all(feature = "archive-zip", unix))]
4097    #[test]
4098    fn extract_into_restores_relative_zip_symlink() {
4099        use std::os::unix::fs::FileTypeExt as _;
4100        let staging = tempfile::tempdir().expect("tempdir");
4101        let archive_path = staging.path().join("links.zip");
4102        {
4103            let f = File::create(&archive_path).expect("create zip");
4104            let mut zip = zip::ZipWriter::new(f);
4105            let options = zip::write::SimpleFileOptions::default()
4106                .compression_method(zip::CompressionMethod::Stored);
4107            // A real file, and a sibling symlink pointing at it by relative path.
4108            zip.start_file("dir/real.txt", options).expect("start");
4109            zip.write_all(b"payload").expect("write");
4110            zip.add_symlink("dir/link.txt", "real.txt", options)
4111                .expect("add_symlink");
4112            zip.finish().expect("finish");
4113        }
4114        let out_tmp = tempfile::tempdir().expect("tempdir");
4115        Extract::from_source(&archive_path)
4116            .extract_into(out_tmp.path())
4117            .expect("extract");
4118
4119        let link_path = out_tmp.path().join("dir/link.txt");
4120        let meta = fs::symlink_metadata(&link_path).expect("lstat link");
4121        assert!(
4122            meta.file_type().is_symlink(),
4123            "the entry must be restored as a symlink, not a regular file"
4124        );
4125        // Sanity: it must not be some other special file type either.
4126        assert!(!meta.file_type().is_fifo());
4127        // The link target must be the stored relative path, and reading through it yields the file.
4128        let target = fs::read_link(&link_path).expect("readlink");
4129        assert_eq!(target, Path::new("real.txt"));
4130        let via_link = fs::read_to_string(&link_path).expect("read through link");
4131        assert_eq!(via_link, "payload");
4132    }
4133
4134    // A zip symlink whose target is absolute must be rejected (it would escape the extraction root),
4135    // and no symlink or file may be left at the entry path.
4136    #[cfg(all(feature = "archive-zip", unix))]
4137    #[test]
4138    fn extract_into_rejects_absolute_zip_symlink() {
4139        let staging = tempfile::tempdir().expect("tempdir");
4140        let archive_path = staging.path().join("abs.zip");
4141        {
4142            let f = File::create(&archive_path).expect("create zip");
4143            let mut zip = zip::ZipWriter::new(f);
4144            let options = zip::write::SimpleFileOptions::default()
4145                .compression_method(zip::CompressionMethod::Stored);
4146            zip.add_symlink("evil", "/etc/passwd", options)
4147                .expect("add_symlink");
4148            zip.finish().expect("finish");
4149        }
4150        let out_tmp = tempfile::tempdir().expect("tempdir");
4151        let res = Extract::from_source(&archive_path).extract_into(out_tmp.path());
4152        assert!(res.is_err(), "an absolute-target symlink must be rejected");
4153        assert!(
4154            fs::symlink_metadata(out_tmp.path().join("evil")).is_err(),
4155            "no link/file may be left behind for a rejected symlink entry"
4156        );
4157    }
4158
4159    // A zip symlink whose relative target climbs above the extraction root with `..` must be
4160    // rejected, matching the entry-name zip-slip defense.
4161    #[cfg(all(feature = "archive-zip", unix))]
4162    #[test]
4163    fn extract_into_rejects_escaping_zip_symlink() {
4164        let staging = tempfile::tempdir().expect("tempdir");
4165        let archive_path = staging.path().join("escape.zip");
4166        {
4167            let f = File::create(&archive_path).expect("create zip");
4168            let mut zip = zip::ZipWriter::new(f);
4169            let options = zip::write::SimpleFileOptions::default()
4170                .compression_method(zip::CompressionMethod::Stored);
4171            // `add_symlink` preserves the raw target string (unlike `add_symlink_from_path`, which
4172            // would normalize the `..` away), so the escaping target reaches the extractor intact.
4173            zip.add_symlink("dir/escape", "../../outside", options)
4174                .expect("add_symlink");
4175            zip.finish().expect("finish");
4176        }
4177        let out_tmp = tempfile::tempdir().expect("tempdir");
4178        let res = Extract::from_source(&archive_path).extract_into(out_tmp.path());
4179        assert!(
4180            res.is_err(),
4181            "a `..`-escaping symlink target must be rejected"
4182        );
4183        assert!(
4184            fs::symlink_metadata(out_tmp.path().join("dir/escape")).is_err(),
4185            "no link/file may be left behind for a rejected symlink entry"
4186        );
4187    }
4188
4189    // A `..` target that stays within the root after resolving against the link's parent dir must
4190    // be allowed (the lexical check must not over-reject legitimate relative links).
4191    #[cfg(all(feature = "archive-zip", unix))]
4192    #[test]
4193    fn extract_into_allows_in_bounds_dotdot_zip_symlink() {
4194        let staging = tempfile::tempdir().expect("tempdir");
4195        let archive_path = staging.path().join("inbounds.zip");
4196        {
4197            let f = File::create(&archive_path).expect("create zip");
4198            let mut zip = zip::ZipWriter::new(f);
4199            let options = zip::write::SimpleFileOptions::default()
4200                .compression_method(zip::CompressionMethod::Stored);
4201            zip.start_file("top.txt", options).expect("start");
4202            zip.write_all(b"top-payload").expect("write");
4203            // Link at `dir/up`; target `../top.txt` resolves to `top.txt` inside the root.
4204            zip.add_symlink("dir/up", "../top.txt", options)
4205                .expect("add_symlink");
4206            zip.finish().expect("finish");
4207        }
4208        let out_tmp = tempfile::tempdir().expect("tempdir");
4209        Extract::from_source(&archive_path)
4210            .extract_into(out_tmp.path())
4211            .expect("extract");
4212        let link_path = out_tmp.path().join("dir/up");
4213        let meta = fs::symlink_metadata(&link_path).expect("lstat link");
4214        assert!(meta.file_type().is_symlink(), "must be a symlink");
4215        let via_link = fs::read_to_string(&link_path).expect("read through link");
4216        assert_eq!(via_link, "top-payload");
4217    }
4218
4219    // extract_file must not write a symlink entry's target string out as the requested file; it
4220    // errors instead (documented behavior; use extract_into to restore links).
4221    #[cfg(all(feature = "archive-zip", unix))]
4222    #[test]
4223    fn extract_file_rejects_zip_symlink_entry() {
4224        let staging = tempfile::tempdir().expect("tempdir");
4225        let archive_path = staging.path().join("single.zip");
4226        {
4227            let f = File::create(&archive_path).expect("create zip");
4228            let mut zip = zip::ZipWriter::new(f);
4229            let options = zip::write::SimpleFileOptions::default()
4230                .compression_method(zip::CompressionMethod::Stored);
4231            zip.add_symlink("link", "target.txt", options)
4232                .expect("add_symlink");
4233            zip.finish().expect("finish");
4234        }
4235        let out_tmp = tempfile::tempdir().expect("tempdir");
4236        let res = Extract::from_source(&archive_path).extract_file(out_tmp.path(), "link");
4237        assert!(
4238            res.is_err(),
4239            "extracting a symlink entry as a file must error"
4240        );
4241        assert!(
4242            fs::symlink_metadata(out_tmp.path().join("link")).is_err(),
4243            "no file may be written for a rejected symlink entry"
4244        );
4245    }
4246
4247    // --- symlink_target_escapes: lexical edge cases (private fn, adversarial) ---------------
4248    // These exercise the pure lexical resolver directly so the boundary logic is pinned
4249    // independently of zip fixture plumbing.
4250    #[cfg(all(feature = "archive-zip", unix))]
4251    #[test]
4252    fn symlink_target_escapes_lexical_edges() {
4253        use std::path::Path;
4254        let esc = symlink_target_escapes;
4255
4256        // A bare `..` from a top-level link (parent depth 0) climbs above the root -> escapes.
4257        assert!(
4258            esc(Path::new(""), Path::new("..")),
4259            "top-level `..` escapes"
4260        );
4261        // `..` from a depth-1 parent resolves exactly to the root -> allowed (boundary, in-bounds).
4262        assert!(
4263            !esc(Path::new("dir"), Path::new("..")),
4264            "`..` back to root is in-bounds"
4265        );
4266        // A deep link climbing exactly back to the root then descending stays in-bounds.
4267        assert!(
4268            !esc(Path::new("a/b"), Path::new("../../x")),
4269            "climb exactly to root then descend is in-bounds"
4270        );
4271        // One `..` past the root escapes.
4272        assert!(
4273            esc(Path::new("a/b"), Path::new("../../..")),
4274            "climbing one past root escapes"
4275        );
4276        // Mixed `./` current-dir components must not be miscounted as depth.
4277        assert!(
4278            !esc(Path::new("dir"), Path::new("./real.txt")),
4279            "`./` component is a no-op, stays in-bounds"
4280        );
4281        assert!(
4282            !esc(Path::new("dir"), Path::new("./../a")),
4283            "`./` then `..` from depth 1 stays in-bounds"
4284        );
4285        // An empty target string has no components -> resolves to the link's own parent, in-bounds.
4286        assert!(
4287            !esc(Path::new("dir"), Path::new("")),
4288            "empty target is in-bounds"
4289        );
4290        // Interior `..` that dips and re-descends but never passes the root is in-bounds.
4291        assert!(
4292            !esc(Path::new(""), Path::new("a/../b")),
4293            "dip and re-descend within root is in-bounds"
4294        );
4295        // Interior `..` that transiently passes the root escapes even if it would re-descend.
4296        assert!(
4297            esc(Path::new(""), Path::new("a/../../b")),
4298            "transiently passing root escapes"
4299        );
4300        // An absolute target always escapes regardless of parent depth.
4301        assert!(
4302            esc(Path::new("a/b/c"), Path::new("/etc/passwd")),
4303            "absolute target escapes"
4304        );
4305        assert!(
4306            esc(Path::new(""), Path::new("/")),
4307            "bare root target escapes"
4308        );
4309    }
4310
4311    // A symlink chain fully inside the archive (link A -> link B -> real file) must be restored as
4312    // two real links, readable through the chain. The checks are lexical and per-entry, so an
4313    // in-tree chain must extract cleanly regardless of order.
4314    #[cfg(all(feature = "archive-zip", unix))]
4315    #[test]
4316    fn extract_into_restores_symlink_chain() {
4317        let staging = tempfile::tempdir().expect("tempdir");
4318        let archive_path = staging.path().join("chain.zip");
4319        {
4320            let f = File::create(&archive_path).expect("create zip");
4321            let mut zip = zip::ZipWriter::new(f);
4322            let options = zip::write::SimpleFileOptions::default()
4323                .compression_method(zip::CompressionMethod::Stored);
4324            zip.start_file("real.txt", options).expect("start");
4325            zip.write_all(b"chain-payload").expect("write");
4326            // b -> real.txt, a -> b (all siblings at root).
4327            zip.add_symlink("b", "real.txt", options).expect("b");
4328            zip.add_symlink("a", "b", options).expect("a");
4329            zip.finish().expect("finish");
4330        }
4331        let out_tmp = tempfile::tempdir().expect("tempdir");
4332        Extract::from_source(&archive_path)
4333            .extract_into(out_tmp.path())
4334            .expect("extract");
4335        let a = out_tmp.path().join("a");
4336        assert!(
4337            fs::symlink_metadata(&a)
4338                .expect("lstat a")
4339                .file_type()
4340                .is_symlink(),
4341            "a must be a symlink"
4342        );
4343        assert!(
4344            fs::symlink_metadata(out_tmp.path().join("b"))
4345                .expect("lstat b")
4346                .file_type()
4347                .is_symlink(),
4348            "b must be a symlink"
4349        );
4350        assert_eq!(
4351            fs::read_to_string(&a).expect("read through chain"),
4352            "chain-payload"
4353        );
4354    }
4355
4356    // A link entry whose parent directory has no explicit archive entry (and thus no entry ordered
4357    // before it) must still extract: the loop's `create_dir_all(parent)` must materialize the path.
4358    #[cfg(all(feature = "archive-zip", unix))]
4359    #[test]
4360    fn extract_into_symlink_with_implicit_parent_dir() {
4361        let staging = tempfile::tempdir().expect("tempdir");
4362        let archive_path = staging.path().join("implicit.zip");
4363        {
4364            let f = File::create(&archive_path).expect("create zip");
4365            let mut zip = zip::ZipWriter::new(f);
4366            let options = zip::write::SimpleFileOptions::default()
4367                .compression_method(zip::CompressionMethod::Stored);
4368            zip.start_file("target.txt", options).expect("start");
4369            zip.write_all(b"impl-payload").expect("write");
4370            // No `nested/` directory entry precedes this link.
4371            zip.add_symlink("nested/deep/link", "../../target.txt", options)
4372                .expect("link");
4373            zip.finish().expect("finish");
4374        }
4375        let out_tmp = tempfile::tempdir().expect("tempdir");
4376        Extract::from_source(&archive_path)
4377            .extract_into(out_tmp.path())
4378            .expect("extract");
4379        let link = out_tmp.path().join("nested/deep/link");
4380        assert!(
4381            fs::symlink_metadata(&link)
4382                .expect("lstat")
4383                .file_type()
4384                .is_symlink(),
4385            "link must be created under implicitly-created parents"
4386        );
4387        assert_eq!(fs::read_to_string(&link).expect("read"), "impl-payload");
4388    }
4389
4390    // NOTE on duplicate-path entries: the handoff asked to probe two entries at the same path
4391    // (regular file then symlink, and symlink then regular file, the latter a possible
4392    // write-through-link vuln). `zip::ZipWriter` (8.6.0) rejects a second entry at an existing
4393    // name with `InvalidArchive("Duplicate filename")`, so a duplicate-path fixture cannot be
4394    // built through the crate's own writer; that code path (the `remove_file` before `symlink`,
4395    // and `File::create` over a pre-existing link) is only reachable via a hand-forged archive.
4396    // It is left uncovered here rather than hand-assembling raw zip bytes -- see certification.
4397    // The stronger, writer-buildable escape is the symlinked-parent bypass below, which needs no
4398    // duplicate paths.
4399
4400    // SECURITY: the per-entry lexical `symlink_target_escapes` check alone can be bypassed by
4401    // first creating a symlinked intermediate directory that aliases to a shallower path. The
4402    // lexical depth counted for a later link over-estimates the real filesystem depth, so an
4403    // escaping target would pass the lexical check. The physical-parent verification (canonicalize
4404    // the entry's parent and require it to equal `canonical_root/<lexical parent>`) is the backstop
4405    // that rejects any descent through a symlinked ancestor. This pins the rejection.
4406    #[cfg(all(feature = "archive-zip", unix))]
4407    #[test]
4408    fn extract_into_rejects_symlink_through_symlinked_parent() {
4409        let staging = tempfile::tempdir().expect("tempdir");
4410        let archive_path = staging.path().join("bypass.zip");
4411        {
4412            let f = File::create(&archive_path).expect("create zip");
4413            let mut zip = zip::ZipWriter::new(f);
4414            let options = zip::write::SimpleFileOptions::default()
4415                .compression_method(zip::CompressionMethod::Stored);
4416            // 1) `d/sl` -> `..`  (link_parent depth 1, `..` -> depth 0, lexically allowed).
4417            //    Physically aliases `d/sl` to the root.
4418            zip.add_symlink("d/sl", "..", options).expect("sl");
4419            // 2) `d/sl/evil` -> `../../x`. Lexically in-bounds, but `d/sl` aliases the root so the
4420            //    link would land at <root>/evil pointing ABOVE the root. The physical-parent check
4421            //    rejects it: canonicalize(<root>/d/sl) == <root>, but the expected parent is
4422            //    <root>/d/sl, so they differ.
4423            zip.add_symlink("d/sl/evil", "../../x", options)
4424                .expect("evil");
4425            zip.finish().expect("finish");
4426        }
4427        let out_tmp = tempfile::tempdir().expect("tempdir");
4428        let res = Extract::from_source(&archive_path).extract_into(out_tmp.path());
4429        assert!(
4430            res.is_err(),
4431            "a symlink descending through a symlinked parent must be rejected"
4432        );
4433        // No escaping link may be left behind, at the aliased root location or under `d/sl`.
4434        assert!(
4435            fs::symlink_metadata(out_tmp.path().join("evil")).is_err(),
4436            "no escaping link may be planted at the aliased root path"
4437        );
4438        assert!(
4439            fs::symlink_metadata(out_tmp.path().join("d/sl/evil")).is_err(),
4440            "no escaping link may be planted under the symlinked parent"
4441        );
4442    }
4443
4444    // A regular-file entry that descends through a symlinked parent must also be rejected: even
4445    // though the write would land inside the root through the alias (an in-bounds target is all a
4446    // link can hold), the physical-parent check rejects the descent so nothing is written through
4447    // an aliased directory.
4448    #[cfg(all(feature = "archive-zip", unix))]
4449    #[test]
4450    fn extract_into_rejects_regular_file_through_symlinked_parent() {
4451        let staging = tempfile::tempdir().expect("tempdir");
4452        let archive_path = staging.path().join("filebypass.zip");
4453        {
4454            let f = File::create(&archive_path).expect("create zip");
4455            let mut zip = zip::ZipWriter::new(f);
4456            let options = zip::write::SimpleFileOptions::default()
4457                .compression_method(zip::CompressionMethod::Stored);
4458            // `d/sl` -> `..` aliases to the root; then a regular file under it.
4459            zip.add_symlink("d/sl", "..", options).expect("sl");
4460            zip.start_file("d/sl/file.txt", options).expect("start");
4461            zip.write_all(b"through-alias").expect("write");
4462            zip.finish().expect("finish");
4463        }
4464        let out_tmp = tempfile::tempdir().expect("tempdir");
4465        let res = Extract::from_source(&archive_path).extract_into(out_tmp.path());
4466        assert!(
4467            res.is_err(),
4468            "a regular file descending through a symlinked parent must be rejected"
4469        );
4470        assert!(
4471            fs::symlink_metadata(out_tmp.path().join("file.txt")).is_err(),
4472            "no file may be written at the aliased root path"
4473        );
4474    }
4475
4476    // Positive control: a benign tree whose files legitimately descend through REAL directories
4477    // (a normal nested layout, plus a symlink to a real in-tree directory used only as a leaf link,
4478    // never descended through) still extracts fine. The physical-parent check must not over-reject.
4479    #[cfg(all(feature = "archive-zip", unix))]
4480    #[test]
4481    fn extract_into_allows_files_through_real_directories() {
4482        let staging = tempfile::tempdir().expect("tempdir");
4483        let archive_path = staging.path().join("benign.zip");
4484        {
4485            let f = File::create(&archive_path).expect("create zip");
4486            let mut zip = zip::ZipWriter::new(f);
4487            let options = zip::write::SimpleFileOptions::default()
4488                .compression_method(zip::CompressionMethod::Stored);
4489            zip.start_file("a/b/c/deep.txt", options).expect("start");
4490            zip.write_all(b"deep-payload").expect("write");
4491            zip.start_file("a/b/sibling.txt", options).expect("start");
4492            zip.write_all(b"sibling-payload").expect("write");
4493            // A symlink to a real in-tree directory (leaf link, not descended through).
4494            zip.add_symlink("a/link-to-b", "b", options).expect("link");
4495            zip.finish().expect("finish");
4496        }
4497        let out_tmp = tempfile::tempdir().expect("tempdir");
4498        Extract::from_source(&archive_path)
4499            .extract_into(out_tmp.path())
4500            .expect("extract");
4501        assert_eq!(
4502            fs::read_to_string(out_tmp.path().join("a/b/c/deep.txt")).expect("read deep"),
4503            "deep-payload"
4504        );
4505        assert_eq!(
4506            fs::read_to_string(out_tmp.path().join("a/b/sibling.txt")).expect("read sibling"),
4507            "sibling-payload"
4508        );
4509        // The leaf symlink resolves to the real directory and reads the same content through it.
4510        assert_eq!(
4511            fs::read_to_string(out_tmp.path().join("a/link-to-b/sibling.txt"))
4512                .expect("read through link"),
4513            "sibling-payload"
4514        );
4515    }
4516
4517    // ADVERSARIAL (deeper chain): a symlinked ancestor aliases the root, then a file entry
4518    // descends TWO real levels below the alias (`a/sl -> ..`, then `a/sl/b/c/deep.txt`). The
4519    // lexical depth of the entry (a/sl/b/c) over-counts the physical depth (root/b/c), so the
4520    // per-entry lexical check would pass; the physical-parent equality check must still reject the
4521    // descent through the symlinked ancestor. Pins that the guard holds across multi-level descents,
4522    // not just a single level below the symlink.
4523    #[cfg(all(feature = "archive-zip", unix))]
4524    #[test]
4525    fn extract_into_rejects_file_deep_below_symlinked_ancestor() {
4526        let staging = tempfile::tempdir().expect("tempdir");
4527        let archive_path = staging.path().join("deepchain.zip");
4528        {
4529            let f = File::create(&archive_path).expect("create zip");
4530            let mut zip = zip::ZipWriter::new(f);
4531            let options = zip::write::SimpleFileOptions::default()
4532                .compression_method(zip::CompressionMethod::Stored);
4533            // `a/sl` -> `..` aliases `a/sl` to the extraction root.
4534            zip.add_symlink("a/sl", "..", options).expect("sl");
4535            // Two levels below the alias. Lexically in-bounds (no `..`), physically root/b/c.
4536            zip.start_file("a/sl/b/c/deep.txt", options).expect("start");
4537            zip.write_all(b"deep-through-alias").expect("write");
4538            zip.finish().expect("finish");
4539        }
4540        let out_tmp = tempfile::tempdir().expect("tempdir");
4541        let res = Extract::from_source(&archive_path).extract_into(out_tmp.path());
4542        assert!(
4543            res.is_err(),
4544            "a file two levels below a symlinked ancestor must be rejected"
4545        );
4546        // Nothing may be planted at the aliased root location (root/b/c/deep.txt) either.
4547        assert!(
4548            fs::symlink_metadata(out_tmp.path().join("b/c/deep.txt")).is_err(),
4549            "no file may be written at the aliased (shallower) root path"
4550        );
4551        assert!(
4552            fs::symlink_metadata(out_tmp.path().join("a/sl/b/c/deep.txt")).is_err(),
4553            "no file may be written below the symlinked ancestor"
4554        );
4555    }
4556
4557    // ADVERSARIAL (symlinked destination): the caller's own `into_dir` may legitimately contain a
4558    // symlink component (e.g. `/tmp/link-to-real`). `canonical_root` captures its resolved
4559    // (symlink-free) form up front, so a benign nested archive must extract without being falsely
4560    // rejected: every entry's physical parent resolves to `canonical_root/<lexical parent>` by
4561    // construction. Guards against the equality check tripping on a symlink the CALLER supplied
4562    // rather than one an archive entry created.
4563    #[cfg(all(feature = "archive-zip", unix))]
4564    #[test]
4565    fn extract_into_allows_symlinked_destination() {
4566        let staging = tempfile::tempdir().expect("tempdir");
4567        let archive_path = staging.path().join("benign-nested.zip");
4568        {
4569            let f = File::create(&archive_path).expect("create zip");
4570            let mut zip = zip::ZipWriter::new(f);
4571            let options = zip::write::SimpleFileOptions::default()
4572                .compression_method(zip::CompressionMethod::Stored);
4573            zip.start_file("sub/deep/file.txt", options).expect("start");
4574            zip.write_all(b"nested-payload").expect("write");
4575            zip.finish().expect("finish");
4576        }
4577        // The destination handed to `extract_into` is itself a symlink to the real output dir.
4578        let out_tmp = tempfile::tempdir().expect("tempdir");
4579        let real_dest = out_tmp.path().join("real-dest");
4580        fs::create_dir(&real_dest).expect("mkdir real dest");
4581        let link_dest = out_tmp.path().join("link-to-dest");
4582        std::os::unix::fs::symlink(&real_dest, &link_dest).expect("symlink dest");
4583
4584        Extract::from_source(&archive_path)
4585            .extract_into(&link_dest)
4586            .expect("extraction into a symlinked destination must not be rejected");
4587        // The file lands under the real destination, reachable through the caller's symlink.
4588        assert_eq!(
4589            fs::read_to_string(real_dest.join("sub/deep/file.txt")).expect("read real"),
4590            "nested-payload"
4591        );
4592        assert_eq!(
4593            fs::read_to_string(link_dest.join("sub/deep/file.txt")).expect("read via link"),
4594            "nested-payload"
4595        );
4596    }
4597
4598    // ADVERSARIAL (unguarded dir branch): the `is_dir()` branch runs `create_dir_all` WITHOUT the
4599    // physical-parent check, on the reasoning that a symlinked ancestor only aliases in-bounds (its
4600    // target was validated by `symlink_target_escapes`), so directories created through it stay in
4601    // bounds, and any later FILE entry under it is rejected by the parent check. This probes that
4602    // reasoning with a symlink aliasing a real in-tree sibling directory: the dir entry created
4603    // through the alias must land in-bounds, nothing may be created outside the root, and a file
4604    // entry descending through the same alias must be rejected even though it too would land
4605    // in-bounds (the equality check is strict, not a mere prefix/containment check).
4606    #[cfg(all(feature = "archive-zip", unix))]
4607    #[test]
4608    fn extract_into_dir_through_symlink_stays_in_bounds_and_file_rejected() {
4609        let staging = tempfile::tempdir().expect("tempdir");
4610        let archive_path = staging.path().join("dirthrough.zip");
4611        {
4612            let f = File::create(&archive_path).expect("create zip");
4613            let mut zip = zip::ZipWriter::new(f);
4614            let options = zip::write::SimpleFileOptions::default()
4615                .compression_method(zip::CompressionMethod::Stored);
4616            // Real in-tree directory `a/b` (materialized by a file entry).
4617            zip.start_file("a/b/keep.txt", options).expect("start keep");
4618            zip.write_all(b"keep").expect("write keep");
4619            // `a/sl` -> `b`: a symlink aliasing a real sibling directory (in-bounds target).
4620            zip.add_symlink("a/sl", "b", options).expect("sl");
4621            // Directory entry descending through the alias. The unguarded dir branch creates it.
4622            zip.add_directory("a/sl/planted", options)
4623                .expect("dir entry");
4624            // A file entry under the alias must be rejected by the physical-parent equality check
4625            // even though it would land in-bounds (root/a/b/planted/f.txt).
4626            zip.start_file("a/sl/planted/f.txt", options)
4627                .expect("start f");
4628            zip.write_all(b"through-alias-file").expect("write f");
4629            zip.finish().expect("finish");
4630        }
4631        // Extract into a nested dest so we can assert nothing escapes into the parent.
4632        let out_tmp = tempfile::tempdir().expect("tempdir");
4633        let dest = out_tmp.path().join("dest");
4634        let res = Extract::from_source(&archive_path).extract_into(&dest);
4635        assert!(
4636            res.is_err(),
4637            "a file descending through a symlinked directory must be rejected"
4638        );
4639        // The file must not exist at the aliased location, the lexical location, or anywhere.
4640        assert!(
4641            fs::symlink_metadata(dest.join("a/b/planted/f.txt")).is_err(),
4642            "no file may be written at the aliased in-bounds path"
4643        );
4644        assert!(
4645            fs::symlink_metadata(dest.join("a/sl/planted/f.txt")).is_err(),
4646            "no file may be written at the lexical path under the symlink"
4647        );
4648        // The dir branch is unguarded, so `planted` was materialized through the alias -- but it
4649        // must be IN-BOUNDS (root/a/b/planted), never outside the extraction root.
4650        if let Ok(meta) = fs::symlink_metadata(dest.join("a/b/planted")) {
4651            assert!(
4652                meta.file_type().is_dir(),
4653                "planted, if present, is a real dir"
4654            );
4655        }
4656        // Nothing may have been created outside `dest`: the extraction parent holds only `dest`.
4657        let mut stray: Vec<String> = fs::read_dir(out_tmp.path())
4658            .expect("read parent")
4659            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
4660            .collect();
4661        stray.retain(|name| name != "dest");
4662        assert!(
4663            stray.is_empty(),
4664            "nothing may be created outside the extraction root, found: {:?}",
4665            stray
4666        );
4667    }
4668
4669    // extract_file on a normal executable-mode regular zip entry is unaffected by the symlink
4670    // rejection: it extracts and preserves the exec bit.
4671    #[cfg(all(feature = "archive-zip", unix))]
4672    #[test]
4673    fn extract_file_regular_exec_entry_unaffected() {
4674        use std::os::unix::fs::PermissionsExt as _;
4675        let staging = tempfile::tempdir().expect("tempdir");
4676        let archive_path = staging.path().join("exec.zip");
4677        {
4678            let f = File::create(&archive_path).expect("create zip");
4679            let mut zip = zip::ZipWriter::new(f);
4680            let options = zip::write::SimpleFileOptions::default()
4681                .compression_method(zip::CompressionMethod::Stored)
4682                .unix_permissions(0o755);
4683            zip.start_file("bin", options).expect("start");
4684            zip.write_all(b"#!/bin/sh\n").expect("write");
4685            zip.finish().expect("finish");
4686        }
4687        let out_tmp = tempfile::tempdir().expect("tempdir");
4688        Extract::from_source(&archive_path)
4689            .extract_file(out_tmp.path(), "bin")
4690            .expect("extract_file");
4691        let out = out_tmp.path().join("bin");
4692        let mode = fs::metadata(&out).expect("stat").permissions().mode();
4693        assert!(mode & 0o111 != 0, "exec bit preserved, got {:o}", mode);
4694        assert_eq!(fs::read_to_string(&out).expect("read"), "#!/bin/sh\n");
4695    }
4696
4697    fn build_test_archive<T: AsRef<Path>>(
4698        mut archive_file: fs::File,
4699        archive_file_path: T,
4700        archive_kind: ArchiveKind,
4701    ) {
4702        let archive_file_path = archive_file_path.as_ref();
4703
4704        match archive_kind {
4705            #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
4706            ArchiveKind::Tar(Some(Compression::Gz)) => {
4707                let tmp_tar_path = archive_file_path
4708                    .parent()
4709                    .expect("Missing archive file path parent")
4710                    .join("tar_contents");
4711                let tmp_tar_inner_path = tmp_tar_path.join("inner_archive");
4712                fs::create_dir_all(&tmp_tar_inner_path).expect("Failed to create temp tar path");
4713
4714                let fp = tmp_tar_path.join("temp.txt");
4715                let mut tmp_file = File::create(fp).expect("temp file create fail");
4716                tmp_file.write_all(b"This is a test!").unwrap();
4717
4718                let fp = tmp_tar_inner_path.join("temp2.txt");
4719                let mut tmp_file = File::create(fp).expect("temp file create fail");
4720                tmp_file.write_all(b"This is a second test!").unwrap();
4721
4722                let mut ar = tar::Builder::new(vec![]);
4723                ar.append_dir_all(".", &tmp_tar_path)
4724                    .expect("tar append dir all fail");
4725                let tar_writer = ar.into_inner().expect("failed getting tar writer");
4726
4727                let mut e = GzEncoder::new(&mut archive_file, flate2::Compression::default());
4728                io::copy(&mut tar_writer.as_slice(), &mut e)
4729                    .expect("failed writing from tar archive to gz encoder");
4730                e.finish().expect("gz finish fail");
4731            }
4732
4733            #[cfg(all(feature = "archive-tar", feature = "compression-tar-xz"))]
4734            ArchiveKind::Tar(Some(Compression::Xz)) => {
4735                let tmp_tar_path = archive_file_path
4736                    .parent()
4737                    .expect("Missing archive file path parent")
4738                    .join("tar_contents_xz");
4739                let tmp_tar_inner_path = tmp_tar_path.join("inner_archive");
4740                fs::create_dir_all(&tmp_tar_inner_path).expect("Failed to create temp tar path");
4741
4742                let fp = tmp_tar_path.join("temp.txt");
4743                let mut tmp_file = File::create(fp).expect("temp file create fail");
4744                tmp_file.write_all(b"This is a test!").unwrap();
4745
4746                let fp = tmp_tar_inner_path.join("temp2.txt");
4747                let mut tmp_file = File::create(fp).expect("temp file create fail");
4748                tmp_file.write_all(b"This is a second test!").unwrap();
4749
4750                let mut ar = tar::Builder::new(vec![]);
4751                ar.append_dir_all(".", &tmp_tar_path)
4752                    .expect("tar append dir all fail");
4753                let tar_writer = ar.into_inner().expect("failed getting tar writer");
4754
4755                lzma_rs::xz_compress(&mut tar_writer.as_slice(), &mut archive_file)
4756                    .expect("failed writing from tar archive to xz encoder");
4757            }
4758
4759            #[cfg(feature = "archive-zip")]
4760            ArchiveKind::Zip => {
4761                let mut zip = zip::ZipWriter::new(archive_file);
4762                let options = zip::write::SimpleFileOptions::default()
4763                    .compression_method(zip::CompressionMethod::Stored);
4764                zip.start_file("temp.txt", options)
4765                    .expect("failed starting zip file");
4766                zip.write_all(b"This is a test!")
4767                    .expect("failed writing to zip");
4768                zip.start_file("inner_archive/temp2.txt", options)
4769                    .expect("failed starting second zip file");
4770                zip.write_all(b"This is a second test!")
4771                    .expect("failed writing to second zip");
4772                zip.finish().expect("failed finishing zip");
4773            }
4774
4775            _ => {
4776                unimplemented!("{:?} not handled", archive_kind);
4777            }
4778        }
4779    }
4780
4781    // --- extractor `Internal { source: None }` variant-routing -----------------------------
4782    //
4783    // These pin the invariant-violation sites in `extract_file`/`extract_into` to EXACTLY
4784    // `Error::Internal` carrying NO source (the genuine-invariant residue, distinct from the
4785    // JoinError `Internal { source: Some(..) }`).
4786
4787    // `extract_file` on a Plain source where the *requested* `file_to_extract` has no file name
4788    // (e.g. `..`) must route to `Error::Internal { source: None }` ("Extractor source has no
4789    // file-name"), not an Io error. `file_to_extract` is caller-supplied and need not exist, so
4790    // this is reachable without a real hostless-path file. (~lib.rs:852)
4791    #[test]
4792    fn extract_file_plain_no_file_name_routes_to_internal_without_source() {
4793        use std::error::Error as _;
4794        let src_dir = tempfile::tempdir().expect("tempdir");
4795        let src = src_dir.path().join("payload.bin");
4796        fs::write(&src, b"hello").expect("write source");
4797
4798        let out_dir = tempfile::tempdir().expect("out tempdir");
4799
4800        // `..` has no `file_name()`, firing the invariant branch.
4801        let err = Extract::from_source(&src)
4802            .archive(ArchiveKind::Plain(None))
4803            .extract_file(out_dir.path(), "..")
4804            .expect_err("a file_to_extract with no file name must error");
4805        match err {
4806            Error::Internal {
4807                ref message,
4808                ref source,
4809            } => {
4810                assert!(
4811                    source.is_none(),
4812                    "the no-file-name invariant carries no source, got {:?}",
4813                    source
4814                );
4815                assert!(
4816                    message.contains("file-name"),
4817                    "message must describe the missing file name, got: {}",
4818                    message
4819                );
4820            }
4821            other => panic!("expected Error::Internal, got {:?}", other),
4822        }
4823        // Defensive: confirm the variant truly chains no source via the trait too.
4824        let err = Extract::from_source(&src)
4825            .archive(ArchiveKind::Plain(None))
4826            .extract_file(out_dir.path(), "..")
4827            .unwrap_err();
4828        assert!(err.source().is_none());
4829    }
4830
4831    // `extract_file` on a Tar source where the requested path is not present in the archive must
4832    // route to `Error::Internal { source: None }` ("Could not find the required path in the
4833    // archive"), naming the missing path. (~lib.rs:873)
4834    #[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
4835    #[test]
4836    fn extract_file_tar_missing_path_routes_to_internal_without_source() {
4837        let tmp_dir = tempfile::Builder::new()
4838            .prefix("self_update_ws3_tar_missing_src")
4839            .tempdir()
4840            .expect("tempdir");
4841        let archive_file_path = tmp_dir.path().join("archive.tar.gz");
4842        let archive_file = File::create(&archive_file_path).expect("create archive");
4843        build_test_archive(
4844            archive_file,
4845            &archive_file_path,
4846            ArchiveKind::Tar(Some(Compression::Gz)),
4847        );
4848
4849        let out_tmp = tempfile::tempdir().expect("out tempdir");
4850        let err = Extract::from_source(&archive_file_path)
4851            .extract_file(out_tmp.path(), "does/not/exist.txt")
4852            .expect_err("a path absent from the tar must error");
4853        match err {
4854            Error::Internal {
4855                ref message,
4856                ref source,
4857            } => {
4858                assert!(
4859                    source.is_none(),
4860                    "the path-not-found invariant carries no source, got {:?}",
4861                    source
4862                );
4863                assert!(
4864                    message.contains("Could not find the required path"),
4865                    "message must describe the missing archive path, got: {}",
4866                    message
4867                );
4868            }
4869            other => panic!("expected Error::Internal, got {:?}", other),
4870        }
4871    }
4872
4873    // `extract_file` on a Zip source where the requested path is not valid UTF-8 must route to
4874    // `Error::Internal { source: None }` ("cannot extract file with a non-UTF-8 path"). Reachable
4875    // on Unix by building an `OsStr` from raw non-UTF-8 bytes. (~lib.rs:903)
4876    #[cfg(all(feature = "archive-zip", unix))]
4877    #[test]
4878    fn extract_file_zip_non_utf8_path_routes_to_internal_without_source() {
4879        use std::os::unix::ffi::OsStrExt;
4880
4881        let tmp_dir = tempfile::Builder::new()
4882            .prefix("self_update_ws3_zip_nonutf8_src")
4883            .tempdir()
4884            .expect("tempdir");
4885        let archive_file_path = tmp_dir.path().join("archive.zip");
4886        let archive_file = File::create(&archive_file_path).expect("create archive");
4887        build_test_archive(archive_file, &archive_file_path, ArchiveKind::Zip);
4888
4889        let out_tmp = tempfile::tempdir().expect("out tempdir");
4890        // 0xFF is never valid UTF-8.
4891        let bad = std::ffi::OsStr::from_bytes(b"bad\xFFname");
4892        let err = Extract::from_source(&archive_file_path)
4893            .extract_file(out_tmp.path(), bad)
4894            .expect_err("a non-UTF-8 zip path must error");
4895        match err {
4896            Error::Internal {
4897                ref message,
4898                ref source,
4899            } => {
4900                assert!(
4901                    source.is_none(),
4902                    "the non-UTF-8-path invariant carries no source, got {:?}",
4903                    source
4904                );
4905                assert!(
4906                    message.contains("non-UTF-8 path"),
4907                    "message must describe the non-UTF-8 path, got: {}",
4908                    message
4909                );
4910            }
4911            other => panic!("expected Error::Internal, got {:?}", other),
4912        }
4913    }
4914}