self_update/lib.rs
1/*!
2
3[](https://crates.io/crates/self_update)
4[](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}