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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,22 @@
client-less build (e.g. `default-features = false, features = ["rustls"]`) produces, and shows the
fix (add a client, e.g. `features = ["ureq", "rustls", "github"]`).
([#168](https://github.com/jaemk/self_update/issues/168))
- `check_install_path_writable(bool)` builder setter (default `false`) and
`Error::InstallPathNotWritable { path: PathBuf }`: opt-in preflight that probes `bin_install_path`
writability before the download; only a definite `PermissionDenied` refusal errors, indeterminate
results proceed. The install step also raises this error on a permission failure, always naming
the path. ([#112](https://github.com/jaemk/self_update/issues/112))

### Changed
- A recognized-but-unsupported compression extension now fails loudly instead of silently
installing the still-compressed bytes as the binary: a `.tar.xz` / `.txz` / `.xz` asset without
the `compression-tar-xz` feature returns `Error::CompressionNotEnabled("xz")` (matching the
existing `.gz` handling), rather than writing the compressed archive to the install path.
([#143](https://github.com/jaemk/self_update/issues/143))
- Install-step IO failures now name the install path: `PermissionDenied` becomes
`Error::InstallPathNotWritable { path }` and any other IO error becomes `Error::Io` with the
install path embedded in the message (the `ErrorKind` is preserved).
([#112](https://github.com/jaemk/self_update/issues/112))

### Removed

Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,47 @@ with a fresh argument list (e.g. to drop an `--upgrade` flag so the new process
again). On unix the process image is replaced with `exec` (the PID is preserved); on windows the new
binary is spawned and the current process exits. See the module docs for the platform details.

### Permissions

The crate never escalates privileges. There is no sudo re-exec, no polkit interaction, and no UAC
prompt. Privilege escalation is always the caller's choice.

An install into an unwritable location fails with
[`Error::InstallPathNotWritable`](crate::errors::Error::InstallPathNotWritable) naming the path
(the configured `bin_install_path`). Any other IO failure at the install step surfaces as
[`Error::Io`](crate::errors::Error::Io) with a message naming the install path, so the path is
visible in the error regardless of the kind.

Setting `check_install_path_writable(true)` on the builder opts into a preflight probe that runs
immediately before the download. Only a definite `PermissionDenied` refusal errors early;
indeterminate results (a missing parent directory, an unusual filesystem) are treated as "proceed"
and let the real install step surface the outcome. The default is `false`.

```rust
fn update() -> Result<(), Box<dyn std::error::Error>> {
match self_update::backends::github::Update::configure()
.repo_owner("owner")
.repo_name("repo")
.bin_name("app")
.current_version(self_update::cargo_crate_version!())
.check_install_path_writable(true)
.build()?
.update()
{
Ok(status) => println!("updated: {}", status.version()),
Err(self_update::Error::InstallPathNotWritable { .. }) => {
// The install path is not writable by this process. Elevation is the
// application's choice: re-run under sudo, spawn a UAC-elevated child, etc.
// Use the `restart` module for the exec/spawn mechanics when relaunching
// with a modified argument list.
eprintln!("install path not writable; re-run with elevated privileges");
}
Err(e) => return Err(e.into()),
}
Ok(())
}
```

### Periodic update checks

Every `update()` / `is_update_available()` call makes a network request. To avoid checking on every
Expand Down
18 changes: 14 additions & 4 deletions specs/ref-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ code builds them via the public constructors (`http_status_error(404, ..)`,
| `MissingAssetField { field: String }` | A release/asset payload was missing a required field (`url`/`name`/`tag_name`/`created_at`/`assets`/`browser_download_url`/`assets.links`) in each backend's DTO conversion (`github.rs`, `gitlab.rs`, `gitea.rs`). `String` so a custom source can report a dynamic field path (e.g. `assets[2].url`). `#[non_exhaustive]`. | none | no (struct fields) |
| `InvalidResponse { source: Box<dyn Error + Send + Sync> }` | A backend response could not be parsed: a malformed (non-array) JSON release-listing body (`github.rs`, `gitlab.rs`, `gitea.rs`), the S3 listing regex build failure, and the S3 XML parse failure (`s3.rs`). The underlying error is carried as `source`. `#[non_exhaustive]`. | none | yes (boxed source) |
| `MissingField { field: &'static str }` | A required builder/configuration field was not set: `current_version`/`bin_name`/`bin_path_in_archive` (`common.rs`), `version` (`update.rs`), `source` (`custom.rs`), `repo_owner`/`repo_name` (`github.rs`, `gitlab.rs`, `gitea.rs`), `host` (`gitea.rs`), `bucket_name`/`region` (`s3.rs`). `#[non_exhaustive]`. | none | no (struct fields) |
| `InstallPathNotWritable { path: PathBuf }` | The opt-in preflight probe (`check_install_path_writable(true)`, `probe_install_path_writable` at `update.rs:1606`) when the path is definitely not writable, or the install step (`map_install_io_error` at `update.rs:1582`) when the replace/move fails with `PermissionDenied`. `path` is the configured `bin_install_path`. `#[non_exhaustive]`. | none | no (struct fields) |
| `InvalidHeader { source: Box<dyn Error + Send + Sync> }` | A request header (`request_header` on the builders or on `Download`) was not a valid HTTP header. The setters are infallible; the error is deferred and surfaced from `build()` (via `common.rs`) or from `Download::download_to` / `download_to_async` (`lib.rs`). The source is a crate-internal `MessageError` carrying the validation message. `#[non_exhaustive]`. | none | yes (boxed source) |
| `InvalidAuthToken { source: Box<dyn Error + Send + Sync> }` | An auth token could not be encoded as an HTTP `Authorization` header value (`github.rs`, `gitlab.rs`, `gitea.rs`, `update.rs`). The underlying header-value parse error is carried as `source`. `#[non_exhaustive]`. | none | yes (boxed source) |
| `InvalidCertificate { source: Box<dyn Error + Send + Sync> }` | A custom TLS root certificate could not be parsed, or the HTTP client that would trust it could not be built. Produced by `RequestConfig::check()` (`common.rs`, surfaced from `build()`) and by `Download::download_to` / `download_to_async` (`lib.rs`) when `add_root_certificate` certs are supplied. Exception: on a ureq-only build a malformed **DER** certificate is not caught at `build()` (ureq's `from_der` is infallible) and surfaces as `Transport` at connection time; PEM is validated at `build()` on both clients. `#[non_exhaustive]`. | none | yes (boxed source) |
Expand Down Expand Up @@ -126,6 +127,7 @@ Each variant renders with a specific Display string:
- `MissingAssetField { field }` -> `"ReleaseError: release/asset payload missing \`{field}\`"`
- `InvalidResponse { source }` -> `"ReleaseError: invalid response: {source}"`
- `MissingField { field }` -> `"ConfigError: \`{field}\` required"`
- `InstallPathNotWritable { path }` -> `"InstallPathNotWritableError: cannot write to install path {path}: run with elevated privileges or choose a user-writable bin_install_path"`
- `InvalidHeader { source }` -> `"ConfigError: invalid HTTP header: {source}"`
- `InvalidAuthToken { source }` -> `"ConfigError: failed to parse auth token: {source}"`
- `InvalidCertificate { source }` -> `"ConfigError: invalid root certificate: {source}"`
Expand Down Expand Up @@ -157,8 +159,9 @@ boxed-source variants `InvalidResponse`, `InvalidHeader`, `InvalidAuthToken`,
`Internal` when its `source` is `Some`
-- each via deref of the box. The `Internal { source: None }` form and all field-only variants
(`VerificationRejected`, `ChecksumMismatch`, `Aborted`, `NotFound`, `Unauthorized`, `HttpStatus`,
`NoReleaseFound`, `MissingAssetField`, `MissingField`, `ArchiveNotEnabled`,
`CompressionNotEnabled`, `InvalidAssetName`, `NoSignatures`, `SignatureNonUTF8`) return `None`. The concrete inner error of
`NoReleaseFound`, `MissingAssetField`, `MissingField`, `InstallPathNotWritable`,
`ArchiveNotEnabled`, `CompressionNotEnabled`, `InvalidAssetName`, `NoSignatures`,
`SignatureNonUTF8`) return `None`. The concrete inner error of
a boxed variant is reachable at runtime through `source()` and `downcast_ref::<ConcreteType>()`
(e.g. `err.source().and_then(|s| s.downcast_ref::<reqwest::Error>())`).

Expand Down Expand Up @@ -267,15 +270,22 @@ type directly, since `std::io::Error` is stable std.)
- A user-declined confirmation prompt produces `Error::Aborted`.
- Every struct-form variant carries `#[non_exhaustive]` on the variant (`Unauthorized`,
`HttpStatus`, `Internal`, `VerificationRejected`, `NoReleaseFound`, `MissingAssetField`,
`InvalidResponse`, `MissingField`, `InvalidHeader`, `InvalidAuthToken`, `InvalidCertificate`,
`InvalidProgressStyle`, `InvalidAssetName`, `NotFound`, `ChecksumMismatch`).
`InvalidResponse`, `MissingField`, `InstallPathNotWritable`, `InvalidHeader`, `InvalidAuthToken`,
`InvalidCertificate`, `InvalidProgressStyle`, `InvalidAssetName`, `NotFound`,
`ChecksumMismatch`).
- `Error::Internal` is reserved for genuine internal/invariant failures: extractor invariants,
archive-path failures, and tokio blocking-task join failures (which carry the `JoinError` as
`source`).
- A rejecting `verify_binary` callback produces `Error::VerificationRejected { reason: Some(<error message>) }`.
- The sites that previously stringified-and-discarded a source now chain it via `source()`: the
S3 XML/regex parse (`InvalidResponse`), the auth-token header-value parse (`InvalidAuthToken`),
and the tokio `JoinError` sites (`Internal`).
- `Error::InstallPathNotWritable { path }` names the `bin_install_path` that could not be
written. It carries no source and exposes no `http_status()`/`url()`. Display prefix is
`"InstallPathNotWritableError: "`. Raised by the opt-in preflight probe
(`check_install_path_writable(true)`) on a definite `PermissionDenied`, and always by the
install step on a permission failure. Other install-step IO errors map to `Error::Io` with the
path embedded in the message, `ErrorKind` preserved.
- `Error::Config(String)` no longer exists. Its former producers route to structured variants:
the `s3-auth` SigV4 host-extraction site (`s3.rs`) -> `S3Auth`; the root-certificate/client-build
failures in `RequestConfig::check()` (`common.rs`) and `Download::download_to` /
Expand Down
22 changes: 20 additions & 2 deletions specs/ref-update-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ and `update_extended_async`'s future stays `Send` (the `PageRequest::parse` pars
### Download

`resolve_and_confirm` prints the release-status block and (unless `no_confirm`) prompts
(see below). Then a `tempfile::TempDir` is created and the asset is downloaded to
`<tmpdir>/<asset.name>` (`update.rs:608-613`). `build_download` (`update.rs:741`) builds the
(see below). If `check_install_path_writable()` is `true`, `probe_install_path_writable`
(`update.rs:1606`) runs immediately after the confirmation and before any download
(`update.rs:1005-1009` sync, `update.rs:1509-1510` async): only a definite `PermissionDenied`
errors as `Error::InstallPathNotWritable { path }`; any other result (missing parent directory,
unusual filesystem, `Ok`) proceeds. Default is `false` (off). Then a `tempfile::TempDir` is
created and the asset is downloaded to `<tmpdir>/<asset.name>` (`update.rs:608-613`). `build_download` (`update.rs:741`) builds the
`Download` from the asset URL, applies auth/`api_headers`, sets `ACCEPT:
application/octet-stream`, merges the user's `request_headers()` *after* (so a same-named
user header overrides), forwards the injected HTTP client, per-request timeout, progress
Expand Down Expand Up @@ -126,6 +130,13 @@ and renames it back if the source->dest rename fails (rollback). `rename` cannot
filesystems, so source, dest, and temp must share one. The high-level flow does not call
`replace_using_temp`.

Both the `self_replace` call and the `Move::to_dest` call have their IO errors wrapped by
`map_install_io_error` (`update.rs:1582`): a `PermissionDenied` becomes
`Error::InstallPathNotWritable { path }` naming the install path; any other `io::Error` kind is
rewrapped as `Error::Io` with the message `"installing to {path}: {orig}"`, preserving the
original `ErrorKind` for inspection. This annotation is always on, independent of the opt-in
preflight probe (`check_install_path_writable`).

### Multi-file install

`MoveAll` (`lib.rs:988`) is the transactional multi-file primitive, not used by the
Expand Down Expand Up @@ -216,6 +227,13 @@ under feature `async`; the free `update::update_extended_async` they route to is
`!no_confirm`. Suppressing one does not suppress the other.
- The retry budget covers the download's request-establishment phase (before bytes stream); mid-stream failures are not retried. User `request_headers` override the crate's ACCEPT/auth
headers on the download.
- When `check_install_path_writable` is `true`, the preflight probe (`probe_install_path_writable`,
`update.rs:1606`) runs after confirmation and before any download; only a definite
`PermissionDenied` errors, indeterminate results proceed. Default is `false`.
- The install step always annotates IO failures with the install path: `PermissionDenied` becomes
`Error::InstallPathNotWritable { path }` and other kinds become `Error::Io` with the path in the
message, `ErrorKind` preserved (`map_install_io_error`, `update.rs:1582`). Independent of the
preflight probe.
- `update()` reports `VersionStatus` (version only); `update_extended()` reports `ReleaseStatus`
(`UpToDate` or `Updated(Release)`).
- The async path never blocks the executor on the finish tail: `finish_update_owned` runs inside
Expand Down
8 changes: 8 additions & 0 deletions src/backends/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@ pub(crate) struct CommonBuilderConfig {
pub asset_identifier: Option<String>,
pub bin_name: Option<String>,
pub bin_install_path: Option<PathBuf>,
/// Opt-in preflight: probe `bin_install_path` writability before any download, failing early
/// with [`Error::InstallPathNotWritable`] on a definite permission refusal. Default `false`;
/// set via `check_install_path_writable(true)`.
pub check_install_path_writable: bool,
pub bin_path_in_archive: Option<String>,
/// `true` when `bin_path_in_archive` was auto-derived from `bin_name` (not set explicitly by
/// the user). Used by `bin_name` to re-derive when called again, while leaving an explicitly
Expand Down Expand Up @@ -485,6 +489,7 @@ impl Default for CommonBuilderConfig {
asset_identifier: None,
bin_name: None,
bin_install_path: None,
check_install_path_writable: false,
bin_path_in_archive: None,
bin_path_in_archive_auto: false,
show_download_progress: false,
Expand Down Expand Up @@ -550,6 +555,7 @@ impl CommonBuilderConfig {
Some(p) => p.clone(),
None => std::env::current_exe()?,
},
check_install_path_writable: self.check_install_path_writable,
bin_path_in_archive: self
.bin_path_in_archive
.clone()
Expand Down Expand Up @@ -589,6 +595,8 @@ pub(crate) struct CommonConfig {
pub tag_prefix: Option<String>,
pub bin_name: String,
pub bin_install_path: PathBuf,
/// Opt-in preflight writability probe of `bin_install_path` (default `false`).
pub check_install_path_writable: bool,
pub bin_path_in_archive: String,
pub show_download_progress: bool,
pub show_output: bool,
Expand Down
61 changes: 61 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ pub enum Error {
/// The name of the missing required field.
field: &'static str,
},
/// The binary's install path (or its parent directory) is not writable by this process.
///
/// Returned either by the opt-in preflight writability check
/// (`check_install_path_writable(true)`), which probes before any download, or by the install
/// step itself when the replace/move fails with a permission error. `path` is the configured
/// `bin_install_path`. Re-run with elevated privileges, or choose a user-writable
/// `bin_install_path`; the crate never escalates privileges itself.
#[non_exhaustive]
InstallPathNotWritable {
/// The install path (`bin_install_path`) that could not be written.
path: std::path::PathBuf,
},
/// A bare release listing ([`ReleaseList::fetch`](crate::backends)) carries no current version,
/// so [`Releases::is_update_available`](crate::update::Releases::is_update_available) has nothing
/// to compare its releases against.
Expand Down Expand Up @@ -407,6 +419,12 @@ impl std::fmt::Display for Error {
}
InvalidResponse { source } => write!(f, "ReleaseError: invalid response: {}", source),
MissingField { field } => write!(f, "ConfigError: `{}` required", field),
InstallPathNotWritable { path } => write!(
f,
"InstallPathNotWritableError: cannot write to install path {}: run with elevated \
privileges or choose a user-writable bin_install_path",
path.display()
),
NoCurrentVersion => write!(
f,
"ReleaseError: this Releases has no current_version to compare against; use \
Expand Down Expand Up @@ -1235,6 +1253,49 @@ mod tests {
assert_eq!(err.url(), None);
}

// `InstallPathNotWritable` Display names the path and suggests elevated privileges or a
// user-writable bin_install_path. It carries no source and exposes no http_status()/url().
#[test]
fn install_path_not_writable_display_and_no_source() {
let err = Error::InstallPathNotWritable {
path: std::path::PathBuf::from("/usr/local/bin/app"),
};
let shown = err.to_string();
assert!(
shown.starts_with("InstallPathNotWritableError: "),
"InstallPathNotWritable Display must keep the greppable prefix, got: {shown}"
);
assert!(
shown.contains("/usr/local/bin/app"),
"InstallPathNotWritable Display must name the path, got: {shown}"
);
assert!(
shown.contains("elevated privileges") && shown.contains("bin_install_path"),
"InstallPathNotWritable Display must suggest elevated privileges or a user-writable \
bin_install_path, got: {shown}"
);
assert!(
err.source().is_none(),
"InstallPathNotWritable carries no source, got {:?}",
err.source()
);
assert_eq!(err.http_status(), None);
assert_eq!(err.url(), None);
}

// `InstallPathNotWritable` is `#[non_exhaustive]`; a `..`-destructure that reads `path` must
// compile (adding a field stays non-breaking for downstream matchers).
#[test]
fn install_path_not_writable_is_non_exhaustive_struct_variant() {
let err = Error::InstallPathNotWritable {
path: std::path::PathBuf::from("/opt/app"),
};
let Error::InstallPathNotWritable { path, .. } = err else {
panic!("expected InstallPathNotWritable");
};
assert_eq!(path, std::path::PathBuf::from("/opt/app"));
}

// `NoCurrentVersion` is a distinct, self-describing variant (not `MissingField`): its Display
// names the missing current_version and points at `Update::is_update_available`, carries no
// source, and exposes no http_status()/url(). Pins that the bare-listing precheck error is not
Expand Down
Loading
Loading