diff --git a/CHANGELOG.md b/CHANGELOG.md index 008ee4e..8f509fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,27 @@ (`Error::InvalidAssetKeyPattern`); a captured version that does not parse as semver skips the key. Unset keeps the existing matcher unchanged. ([#61](https://github.com/jaemk/self_update/issues/61)) +- `self_update::restart` module: `restart()` and `restart_with(args)` relaunch the (already + replaced) executable after an update so a long-running process picks up the new binary + immediately. `restart()` reuses the current arguments; `restart_with(args)` supplies a fresh + argument list (e.g. to drop an `--upgrade` flag so the restarted process does not update again). + On unix the process image is replaced with `exec`; on windows the new binary is spawned and the + current process exits. No feature gate, no new dependencies. + ([#62](https://github.com/jaemk/self_update/issues/62)) +- `self_update::check_interval::UpdateCheckGuard`: a small stamp-file guard that throttles how often + an application checks for updates. `should_check()` reports whether the configured interval has + elapsed since the last recorded check (a missing, corrupt, or future-dated stamp counts as due); + `record_check()` stamps the current time via a write-to-temp-then-rename so a concurrent reader + never sees a partial stamp. The caller owns the stamp-file path; it is a guard, not a scheduler, + and pulls in no time/date dependency. ([#79](https://github.com/jaemk/self_update/issues/79)) +- Docs: a "GitHub rate limits" section in the crate docs covering GitHub's 60/hour (unauthenticated) + vs 5000/hour (authenticated) API limits, that a rate-limited response surfaces as + `Error::Unauthorized { status: 403, .. }`, and how to mitigate (a token, and checking less often + via `UpdateCheckGuard`). ([#78](https://github.com/jaemk/self_update/issues/78)) +- Docs: the Features section now names the exact `no HTTP client selected` compile error a + 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)) ### Changed - A recognized-but-unsupported compression extension now fails loudly instead of silently diff --git a/README.md b/README.md index 440a787..8410e49 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,12 @@ fn update() -> Result<(), Box> { ### Features -At least one HTTP client must be selected; having zero clients is a compile error. Multiple -clients and multiple TLS backends may coexist (reqwest is preferred when both are present): +At least one HTTP client must be selected. A build with **no** client -- for example +`default-features = false` with only a TLS feature such as `features = ["rustls"]` -- fails to +compile with `no HTTP client selected - enable at least one of the reqwest (default) or ureq +features`. Add a client explicitly, e.g. `default-features = false, features = ["ureq", "rustls", +"github"]`. Multiple clients and multiple TLS backends may coexist (reqwest is preferred when both +are present): * `reqwest` (default): use the [`reqwest`](https://docs.rs/reqwest) HTTP client; * `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); @@ -269,6 +273,44 @@ fn check() -> Result<(), Box> { } ``` +### Restarting after an update + +After `update()` returns [`VersionStatus::Updated`](crate::VersionStatus::Updated) the on-disk +executable has been replaced, but the running process keeps executing the old code until it exits. +To relaunch into the new binary immediately, use the [`restart`](crate::restart) module: +`restart::restart()` re-runs with the current arguments, and `restart::restart_with(args)` re-runs +with a fresh argument list (e.g. to drop an `--upgrade` flag so the new process does not update +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. + +### Periodic update checks + +Every `update()` / `is_update_available()` call makes a network request. To avoid checking on every +run, gate the check behind [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard), a small +stamp-file guard: `should_check()` reports whether the configured interval has elapsed since the +last recorded check, and `record_check()` stamps the current time. The caller owns the stamp-file +path. It is a guard, not a scheduler -- no threads or timers, and no extra dependencies. See the +[`check_interval`](crate::check_interval) module for the semantics. + +### GitHub rate limits + +Requests to the GitHub REST API are rate limited by GitHub itself, not by this crate: + +- **Unauthenticated** requests are limited to **60 per hour per source IP**; **authenticated** + requests (set any personal access token via `auth_token`) get **5000 per hour**. A token needs no + scopes to raise the limit for a public repository. +- An update check costs **one** API request (the latest-release lookup, or one request per page of a + paginated listing). The asset **download** itself is a CDN redirect and does not count against the + core API limit. +- When you are rate limited, GitHub responds with **HTTP 403** (and an `x-ratelimit-remaining: 0` + header), which this crate surfaces as `Error::Unauthorized { status: 403, .. }` -- the same + variant as a genuine auth failure, so recognize it by the symptom (a 403 that appears only under + frequent checking). +- To avoid it: set an `auth_token` (5000/hour), and check less often -- the + [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard) above throttles how often you check. + The retry/backoff setters do **not** help here; retrying a rate-limited request only consumes more + quota. + ### Listing releases (`ReleaseList`) Each built-in backend exposes a `ReleaseList` builder for fetching the list of available releases diff --git a/specs/README.md b/specs/README.md index 5cc9b60..7e5d86b 100644 --- a/specs/README.md +++ b/specs/README.md @@ -47,6 +47,8 @@ design before it can be built). Keep each row's status current with `spec.py set | Choose Latest Release Sort | done | [choose-latest-release-sort.md](choose-latest-release-sort.md) | | Embedded Key Verification | done | [embedded-key-verification.md](embedded-key-verification.md) | | Corporate Network Config | pending | [corporate-network-config.md](corporate-network-config.md) | +| Restart After Update | done | [ref-restart.md](ref-restart.md) | +| Update-check Interval Guard | done | [ref-check-interval.md](ref-check-interval.md) | ## Conventions diff --git a/specs/ref-check-interval.md b/specs/ref-check-interval.md new file mode 100644 index 0000000..045f19e --- /dev/null +++ b/specs/ref-check-interval.md @@ -0,0 +1,81 @@ +# Update-check interval guard (reference) + +Status: implemented + +## Scope + +Documents the `self_update::check_interval` module (`src/check_interval.rs`): the +`UpdateCheckGuard` type that throttles how often an application checks for updates using a +timestamp stamp file. No feature gate; depends only on `std` and the existing `tempfile` +dependency. + +## Behavior + +Each `update()` / `is_update_available()` call makes a network request. `UpdateCheckGuard` gates +that behind a stamp file recording the last check time (unix epoch seconds), so an application that +would otherwise check on every run checks at most once per interval. It is a guard, not a scheduler: +no threads or timers, and no `chrono`/`time` dependency (the timestamp is stored via +`std::time::SystemTime`). + +The guard owns two fields: `stamp_path: PathBuf` and `interval: Duration` +(`src/check_interval.rs`). The caller chooses both; the file and its parent directory need not exist +until `record_check` runs. + +- `UpdateCheckGuard::new(stamp_path: impl Into, interval: Duration) -> Self` + (`src/check_interval.rs`): construct a guard. +- `should_check(&self) -> Result` (`src/check_interval.rs`): whether a check is due. Reads the + stamp with `std::fs::read_to_string`: + - Missing file (`ErrorKind::NotFound`): due (`Ok(true)`), the first-run case. + - Any other read error (e.g. permissions, or the path is a directory): surfaced as + `Err(Error::Io(..))`, not silently treated as due. + - Contents not parseable as `u64`: due (`Ok(true)`). A corrupt stamp self-heals rather than + erroring. + - Parsed stamp dated in the future relative to now (clock skew): due (`Ok(true)`). + - Otherwise: due iff `now_secs - stamp_secs >= interval.as_secs()`. +- `record_check(&self) -> Result<()>` (`src/check_interval.rs`): stamp the current time. Writes the + epoch seconds to a `tempfile::NamedTempFile` in the stamp's parent directory (or `.` when the path + has no parent), flushes, and `persist`s it over `stamp_path`, so the replacement is atomic and a + concurrent reader never observes a partial write. An IO failure (missing or unwritable directory) + surfaces as `Err(Error::Io(..))`. + +Now is computed by the private `now_epoch_secs()` (`src/check_interval.rs`) as +`SystemTime::now().duration_since(UNIX_EPOCH)` seconds, clamped to `0` on the impossible pre-1970 +clock so the guard never panics. + +## Public surface + +- `self_update::check_interval::UpdateCheckGuard` (`Clone`, `Debug`). +- `UpdateCheckGuard::new(stamp_path: impl Into, interval: Duration) -> Self`. +- `UpdateCheckGuard::should_check(&self) -> Result`. +- `UpdateCheckGuard::record_check(&self) -> Result<()>`. + +## Invariants and regression checklist + +- A missing stamp is due and is not an error; only a genuine (non-`NotFound`) IO read error surfaces. +- A corrupt/unparseable stamp is treated as due (self-healing), not an error. +- A future-dated stamp is due (clock-skew safety). +- `record_check` writes parseable epoch seconds and replaces the stamp atomically + (temp-file-then-rename in the same directory). +- The caller owns the path; no xdg/dirs dependency is pulled in. No feature gate; only `std` + + `tempfile`. + +## Tests + +`src/check_interval.rs` (`tests` module): + +- `missing_stamp_is_due`: a first run (no stamp) is due. +- `record_then_not_due_within_interval`: after `record_check`, `should_check` is `false` within the + interval, and the stamp holds parseable epoch seconds. +- `back_dated_stamp_is_due`: a stamp older than the interval is due. +- `zero_interval_is_always_due`: a zero interval is due immediately after recording. +- `garbage_stamp_is_due_not_error`: a corrupt stamp is due, not an error. +- `future_stamp_is_due`: a future-dated stamp is due. +- `record_into_missing_dir_errors` / `record_into_readonly_dir_errors` (`#[cfg(unix)]` for the 0o555 + case): `record_check` errors when the directory is missing or unwritable. +- `unreadable_stamp_surfaces_io_error`: a non-`NotFound` read error (stamp path is a directory) + surfaces as `Err`, not "due". + +## Related + +- `ref-restart.md` (the companion application-flow helper). +- `ref-update-pipeline.md` (the `update()` path this throttles). diff --git a/specs/ref-feature-flags.md b/specs/ref-feature-flags.md index 4f59029..f391c0d 100644 --- a/specs/ref-feature-flags.md +++ b/specs/ref-feature-flags.md @@ -70,7 +70,10 @@ The only `compile_error!` guards that remain: - neither `reqwest` nor `ureq` -> error (`http_client/mod.rs:111`): "no HTTP client selected - enable at least one of the `reqwest` (default) or - `ureq` features". A build with no client cannot service any request. + `ureq` features". A build with no client cannot service any request. This is the exact failure a + `default-features = false` manifest that enables only a TLS feature (e.g. `features = ["rustls"]`) + hits; the crate-level Features section (`src/lib.rs`) names the error and shows the fix (add a + client, e.g. `features = ["ureq", "rustls", "github"]`). - `async` without `reqwest` -> error (`lib.rs:434`): "feature `async` requires the `reqwest` client - `ureq` has no async API". (`async` implies `reqwest` in `Cargo.toml`, so this only fires on a manual diff --git a/specs/ref-github-backend.md b/specs/ref-github-backend.md index 0435a1e..f58f7a2 100644 --- a/specs/ref-github-backend.md +++ b/specs/ref-github-backend.md @@ -88,6 +88,19 @@ host does not receive the token; `dangerously_allow_non_https_auth_forwarding()` https requirement for a host-matched request. A user-set `Authorization` via `request_header` overrides the crate's token header. +### Rate limits + +GitHub rate limits its REST API: 60 requests/hour/IP unauthenticated, 5000/hour with a token (no +scopes needed for a public repo). This crate does not track the limit; it is documented in the +crate-level "GitHub rate limits" section (`src/lib.rs`) and pointed at from the `auth_token` setter +rustdoc (`github.rs:138-149`). An update check costs one API request (a `/latest` or `/tags/{tag}` +lookup, or one per page of a listing); the asset download is a CDN redirect and does not count +against the core limit. A rate-limited response is HTTP 403 (with `x-ratelimit-remaining: 0`), which +maps through `status_to_error` to `Error::Unauthorized { status: 403, .. }` (the same variant as a +genuine auth failure). Mitigation is an `auth_token` and checking less often (the +`check_interval::UpdateCheckGuard` throttle); the retry/backoff setters do not help, since retrying +a rate-limited request only consumes more quota. + ### Pagination The listing is described transport-free as a `PageRequest` via `releases_plan(base, diff --git a/specs/ref-restart.md b/specs/ref-restart.md new file mode 100644 index 0000000..435b1a7 --- /dev/null +++ b/specs/ref-restart.md @@ -0,0 +1,72 @@ +# Restart after update (reference) + +Status: implemented + +## Scope + +Documents the `self_update::restart` module (`src/restart.rs`): the `restart()` and +`restart_with(args)` helpers that relaunch the running executable after an update has replaced it on +disk. No feature gate, no dependencies beyond `std`. + +## Behavior + +`update()` replaces the on-disk executable via `self_replace` (`src/update.rs`, `install_binary`), +but the live process keeps running the old image until it exits. The `restart` helpers relaunch the +(now-updated) binary so a long-running process picks up the new code immediately. + +Both helpers target `std::env::current_exe()` and inherit the current environment. They share a +private `restart_command(args)` helper that builds a `std::process::Command` for the current exe +with the given args (`src/restart.rs`), which is the single point the platform paths and the unit +test agree on. + +- `restart() -> Result` (`src/restart.rs`): re-runs with the current arguments, i.e. + `std::env::args_os().skip(1)`. Implemented as `restart_with(std::env::args_os().skip(1))`. +- `restart_with(args) -> Result` where `I: IntoIterator, S: AsRef` + (`src/restart.rs`): re-runs with `args` verbatim, replacing (not appending to) the original + arguments. The bound mirrors `Command::args`, so `&str`, `String`, and `OsString` iterators all + work. + +The return type is `std::convert::Infallible`: on success neither helper returns, so a returned +value can only be `Err`. + +### Platform behavior + +- Unix (`#[cfg(unix)]`): the process image is replaced in place with + `std::os::unix::process::CommandExt::exec`. `exec` returns only on failure, which is mapped to + `Err(Error::Io(..))`; on success the call never returns and the PID is preserved. +- Windows (`#[cfg(windows)]`): there is no `exec`. The updated binary is spawned as a separate + process (inheriting stdio) with `Command::spawn`, then the current process exits with + `std::process::exit(0)`. The PID therefore changes; a console application's replacement runs in + the same console. A spawn failure surfaces as `Err(Error::Io(..))` via the `?` on `spawn()`. + +## Public surface + +- `self_update::restart::restart() -> Result` (`src/restart.rs`). +- `self_update::restart::restart_with(args: I) -> Result` + where `I: IntoIterator, S: AsRef` (`src/restart.rs`). + +## Invariants and regression checklist + +- Both helpers re-exec `current_exe()`, never a hard-coded path. +- `restart_with` forwards its args verbatim, in order, with nothing appended and the original + arguments discarded. +- `restart()` forwards `args_os().skip(1)` (the current arguments minus the program name). +- Success never returns (unix `exec` / windows `exit(0)`); only failures yield `Err(Error::Io)`. +- No feature gate and no new dependencies: the module compiles on a default build. + +## Tests + +- `src/restart.rs` (`tests` module): `restart_command_targets_current_exe_with_given_args` pins that + the built command targets `current_exe()` and forwards exactly the supplied args; + `restart_command_with_no_args_forwards_none` pins that an empty arg list is honored verbatim. + These run on every platform without replacing the process. +- `tests/restart.rs` (`#[cfg(unix)]`): `restart_with_reexecs_current_exe_with_given_args` drives a + real `exec` end to end. It spawns a fresh copy of the test binary that execs itself via + `restart_with`, and asserts the re-executed process observed exactly the forwarded arguments. The + windows spawn-then-exit path is verified manually (a detached spawn followed by `exit(0)` is racy + to assert on in an automated test). + +## Related + +- `ref-update-pipeline.md` (the `install_binary` / `self_replace` step this composes with). +- `ref-check-interval.md` (the companion application-flow helper). diff --git a/src/backends/github.rs b/src/backends/github.rs index 4d8d404..3315799 100644 --- a/src/backends/github.rs +++ b/src/backends/github.rs @@ -141,6 +141,9 @@ impl ReleaseListBuilder { /// **Make sure not to bake the token into your app**; it is recommended /// you obtain it via another mechanism, such as environment variables /// or prompting the user for input + /// + /// A token also raises GitHub's API rate limit from 60 to 5000 requests/hour (no scopes are + /// needed for a public repo); see the crate-level "GitHub rate limits" section. pub fn auth_token(&mut self, auth_token: impl Into) -> &mut Self { self.auth_token = Some(auth_token.into()); self diff --git a/src/check_interval.rs b/src/check_interval.rs new file mode 100644 index 0000000..f3e4a76 --- /dev/null +++ b/src/check_interval.rs @@ -0,0 +1,264 @@ +/*! Throttle how often an application checks for updates. + +Every call to [`update()`](crate::update::ReleaseUpdate::update) (or `is_update_available()`) makes +a network request. An application that would otherwise check on every run can gate that behind +[`UpdateCheckGuard`], a small timestamp-stamp-file guard: it records the time of the last check in a +file you nominate and reports whether enough time has passed to check again. + +This is intentionally a guard, not a scheduler: it does not spawn threads or timers, and it stores +nothing but a single unix-epoch-seconds timestamp (no `chrono`/`time` dependency). It is also not a +preferences store; where the stamp file lives and what interval to use are the application's +decisions. + +```rust,no_run +use std::time::Duration; +use self_update::check_interval::UpdateCheckGuard; + +fn maybe_update() -> Result<(), Box> { + // The caller owns the path. A real app typically builds it from a per-user cache directory + // (e.g. the `dirs` crate's `dirs::cache_dir()`) added as the application's own dependency. + let stamp = std::env::temp_dir().join("myapp/update-check"); + let guard = UpdateCheckGuard::new(stamp, Duration::from_secs(24 * 60 * 60)); + + if guard.should_check()? { + // ... run the self_update check/update here ... + guard.record_check()?; + } + Ok(()) +} +``` + +## Semantics + +[`should_check`](UpdateCheckGuard::should_check) returns `true` (a check is due) when: + +- the stamp file does not exist yet (first run), +- its contents are not a valid timestamp (a corrupt stamp self-heals: it is treated as due, not as + an error), or +- the recorded time is at least `interval` in the past. + +A stamp dated in the *future* (clock skew, or a stamp copied from another machine) also counts as +due. Only a genuine IO error reading the file (e.g. a permissions failure) surfaces as `Err`; a +missing file does not. + +[`record_check`](UpdateCheckGuard::record_check) writes the current time by writing to a temporary +file in the same directory and renaming it over the stamp path, so a concurrent reader never +observes a half-written stamp. +*/ + +use crate::errors::*; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A timestamp-stamp-file guard that throttles how often an application checks for updates. See the +/// [module docs](crate::check_interval) for the full model and an example. +#[derive(Clone, Debug)] +pub struct UpdateCheckGuard { + stamp_path: PathBuf, + interval: Duration, +} + +impl UpdateCheckGuard { + /// Build a guard that records its last-check timestamp at `stamp_path` and considers a new check + /// due once `interval` has elapsed since that timestamp. The path and interval are the caller's + /// choice; the file and its parent directory need not exist yet (they are created by + /// [`record_check`](Self::record_check)). + pub fn new(stamp_path: impl Into, interval: Duration) -> Self { + Self { + stamp_path: stamp_path.into(), + interval, + } + } + + /// Whether an update check is due: `true` when the stamp file is missing, holds an unparseable + /// timestamp, is dated in the future, or is at least `interval` old. See the + /// [module docs](crate::check_interval) for the exact rules. Returns `Err` only on a genuine IO + /// error reading the stamp (a missing file is not an error). + pub fn should_check(&self) -> Result { + let contents = match std::fs::read_to_string(&self.stamp_path) { + Ok(s) => s, + // First run: no stamp yet, so a check is due. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true), + // A real IO failure (e.g. permissions) is surfaced, not silently treated as due. + Err(e) => return Err(Error::Io(e)), + }; + + let stamp_secs = match contents.trim().parse::() { + Ok(secs) => secs, + // A corrupt/unparseable stamp self-heals: treat it as due rather than erroring. + Err(_) => return Ok(true), + }; + + let now_secs = now_epoch_secs(); + // A stamp dated in the future (clock skew) counts as due. + if now_secs < stamp_secs { + return Ok(true); + } + Ok(now_secs - stamp_secs >= self.interval.as_secs()) + } + + /// Record "now" as the time of the last check, so [`should_check`](Self::should_check) returns + /// `false` until `interval` has elapsed. Writes to a temporary file in the stamp's directory and + /// renames it into place so a concurrent reader never sees a partial write. Returns `Err` on an + /// IO failure (e.g. the directory does not exist or is not writable). + pub fn record_check(&self) -> Result<()> { + let secs = now_epoch_secs(); + // Write-to-temp + rename in the same directory so the replacement is atomic and a reader + // never observes a half-written stamp. Fall back to the current directory when the path has + // no parent component (a bare filename). + let dir = match self.stamp_path.parent() { + Some(p) if !p.as_os_str().is_empty() => p, + _ => Path::new("."), + }; + let mut tmp = tempfile::NamedTempFile::new_in(dir)?; + write!(tmp, "{secs}")?; + tmp.flush()?; + tmp.persist(&self.stamp_path) + .map_err(|e| Error::Io(e.error))?; + Ok(()) + } +} + +/// Seconds since the unix epoch, clamped at `0` for the (impossible in practice) pre-1970 clock so +/// the guard never panics on a broken clock. +fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::UpdateCheckGuard; + use std::time::Duration; + + // A missing stamp file (first run) is due, and reading it is not an error. + #[test] + fn missing_stamp_is_due() { + let dir = tempfile::TempDir::new().unwrap(); + let guard = UpdateCheckGuard::new(dir.path().join("stamp"), Duration::from_secs(3600)); + assert!(guard.should_check().unwrap(), "a missing stamp must be due"); + } + + // record_check() then should_check() within the interval reports not-due, and the stamp holds a + // parseable epoch-seconds value. + #[test] + fn record_then_not_due_within_interval() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stamp"); + let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600)); + guard.record_check().unwrap(); + assert!( + !guard.should_check().unwrap(), + "a freshly recorded check must not be due within the interval" + ); + let written = std::fs::read_to_string(&path).unwrap(); + assert!( + written.trim().parse::().is_ok(), + "record_check must write parseable epoch seconds, got {written:?}" + ); + } + + // A stamp older than the interval is due. + #[test] + fn back_dated_stamp_is_due() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stamp"); + // A timestamp far in the past (epoch second 1000 ~ 1970). + std::fs::write(&path, "1000").unwrap(); + let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600)); + assert!( + guard.should_check().unwrap(), + "a stamp older than the interval must be due" + ); + } + + // A zero interval makes every recorded check due again immediately. + #[test] + fn zero_interval_is_always_due() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stamp"); + let guard = UpdateCheckGuard::new(&path, Duration::from_secs(0)); + guard.record_check().unwrap(); + assert!( + guard.should_check().unwrap(), + "a zero interval must always be due" + ); + } + + // A corrupt/unparseable stamp self-heals: it is treated as due, not as an error. + #[test] + fn garbage_stamp_is_due_not_error() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stamp"); + std::fs::write(&path, "not-a-timestamp").unwrap(); + let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600)); + assert!( + guard.should_check().unwrap(), + "a corrupt stamp must be treated as due, not error" + ); + } + + // A stamp dated in the future (clock skew) counts as due. + #[test] + fn future_stamp_is_due() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stamp"); + // Far-future epoch seconds. + std::fs::write(&path, "99999999999").unwrap(); + let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600)); + assert!( + guard.should_check().unwrap(), + "a future-dated stamp must be treated as due" + ); + } + + // record_check() into a non-existent directory surfaces an IO error rather than silently + // succeeding. + #[test] + fn record_into_missing_dir_errors() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("does-not-exist").join("stamp"); + let guard = UpdateCheckGuard::new(path, Duration::from_secs(3600)); + assert!( + guard.record_check().is_err(), + "record_check into a missing directory must error" + ); + } + + // An unwritable stamp directory (0o555) makes record_check() fail. Unix-only: the permission + // model is what makes this deterministic. + #[cfg(unix)] + #[test] + fn record_into_readonly_dir_errors() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::TempDir::new().unwrap(); + let ro = dir.path().join("ro"); + std::fs::create_dir(&ro).unwrap(); + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap(); + let guard = UpdateCheckGuard::new(ro.join("stamp"), Duration::from_secs(3600)); + let result = guard.record_check(); + // Restore write permission so the TempDir can be cleaned up. + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).ok(); + assert!( + result.is_err(), + "record_check into a read-only directory must error" + ); + } + + // A real IO error reading the stamp (here: the stamp path is a directory, so read_to_string + // fails with something other than NotFound) surfaces as Err, not as a silent "due". + #[test] + fn unreadable_stamp_surfaces_io_error() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("stamp-as-dir"); + std::fs::create_dir(&path).unwrap(); + let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600)); + assert!( + guard.should_check().is_err(), + "a non-NotFound read error must surface, not be treated as due" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3d88b4e..c07b46b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,8 +48,12 @@ fn update() -> Result<(), Box> { ### Features -At least one HTTP client must be selected; having zero clients is a compile error. Multiple -clients and multiple TLS backends may coexist (reqwest is preferred when both are present): +At least one HTTP client must be selected. A build with **no** client -- for example +`default-features = false` with only a TLS feature such as `features = ["rustls"]` -- fails to +compile with `no HTTP client selected - enable at least one of the reqwest (default) or ureq +features`. Add a client explicitly, e.g. `default-features = false, features = ["ureq", "rustls", +"github"]`. Multiple clients and multiple TLS backends may coexist (reqwest is preferred when both +are present): * `reqwest` (default): use the [`reqwest`](https://docs.rs/reqwest) HTTP client; * `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); @@ -274,6 +278,44 @@ fn check() -> Result<(), Box> { } ``` +### Restarting after an update + +After `update()` returns [`VersionStatus::Updated`](crate::VersionStatus::Updated) the on-disk +executable has been replaced, but the running process keeps executing the old code until it exits. +To relaunch into the new binary immediately, use the [`restart`](crate::restart) module: +`restart::restart()` re-runs with the current arguments, and `restart::restart_with(args)` re-runs +with a fresh argument list (e.g. to drop an `--upgrade` flag so the new process does not update +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. + +### Periodic update checks + +Every `update()` / `is_update_available()` call makes a network request. To avoid checking on every +run, gate the check behind [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard), a small +stamp-file guard: `should_check()` reports whether the configured interval has elapsed since the +last recorded check, and `record_check()` stamps the current time. The caller owns the stamp-file +path. It is a guard, not a scheduler -- no threads or timers, and no extra dependencies. See the +[`check_interval`](crate::check_interval) module for the semantics. + +### GitHub rate limits + +Requests to the GitHub REST API are rate limited by GitHub itself, not by this crate: + +- **Unauthenticated** requests are limited to **60 per hour per source IP**; **authenticated** + requests (set any personal access token via `auth_token`) get **5000 per hour**. A token needs no + scopes to raise the limit for a public repository. +- An update check costs **one** API request (the latest-release lookup, or one request per page of a + paginated listing). The asset **download** itself is a CDN redirect and does not count against the + core API limit. +- When you are rate limited, GitHub responds with **HTTP 403** (and an `x-ratelimit-remaining: 0` + header), which this crate surfaces as `Error::Unauthorized { status: 403, .. }` -- the same + variant as a genuine auth failure, so recognize it by the symptom (a 403 that appears only under + frequent checking). +- To avoid it: set an `auth_token` (5000/hour), and check less often -- the + [`UpdateCheckGuard`](crate::check_interval::UpdateCheckGuard) above throttles how often you check. + The retry/backoff setters do **not** help here; retrying a rate-limited request only consumes more + quota. + ### Listing releases (`ReleaseList`) Each built-in backend exposes a `ReleaseList` builder for fetching the list of available releases @@ -557,10 +599,12 @@ use std::path; #[macro_use] mod macros; pub mod backends; +pub mod check_interval; #[cfg(feature = "checksums")] mod checksum; pub mod errors; pub mod http_client; +pub mod restart; mod tls; pub mod update; pub mod version; diff --git a/src/restart.rs b/src/restart.rs new file mode 100644 index 0000000..5202c9a --- /dev/null +++ b/src/restart.rs @@ -0,0 +1,149 @@ +/*! Restart the current process after an update. + +After [`update()`](crate::update::ReleaseUpdate::update) reports +[`VersionStatus::Updated`](crate::VersionStatus::Updated) it has already replaced the running +executable on disk (via [`self_replace`](https://crates.io/crates/self-replace)), but the *live* +process keeps running the old code until it exits. To pick up the new binary immediately, restart +into it. + +Two entry points: + +- [`restart`] re-runs the new binary with the **same arguments** the current process was launched + with (`std::env::args_os().skip(1)`). +- [`restart_with`] re-runs it with **arguments you supply**, for the common case of dropping a flag + so the restarted process does not immediately update again (e.g. a `--upgrade` subcommand that + should not recurse). + +Both inherit the current environment. On success neither returns (the return type is +[`Infallible`](std::convert::Infallible)); they yield `Err` only when the restart itself could not +be started. + +## Platform behavior + +- **Unix:** the process image is replaced in place with + [`exec`](std::os::unix::process::CommandExt::exec), so the PID is preserved and nothing returns on + success. +- **Windows:** there is no `exec`. The new binary is **spawned** as a separate process (inheriting + stdio) and the current process then exits with code `0`. The PID therefore changes, and a console + application's replacement runs in the same console. + +Because the running executable was already replaced on disk, the restart launches the *updated* +binary, not the old one. + +```rust,no_run +fn run() -> Result<(), Box> { + let status = self_update::backends::github::Update::configure() + .repo_owner("jaemk") + .repo_name("self_update") + .bin_name("myapp") + .current_version(self_update::cargo_crate_version!()) + .build()? + .update()?; + + if status.is_updated() { + // Relaunch without the `--upgrade` flag so the new process runs normally + // instead of trying to update itself again. + self_update::restart::restart_with(["run"])?; + } + Ok(()) +} +``` +*/ + +use crate::errors::*; +use std::convert::Infallible; +use std::ffi::OsStr; +use std::process::Command; + +/// Build the [`Command`] that restarts the current process: the current executable +/// ([`current_exe`](std::env::current_exe)) with `args` and the inherited environment. +/// +/// Factored out so the argument/target wiring is testable without actually replacing the process. +fn restart_command(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let exe = std::env::current_exe()?; + let mut cmd = Command::new(exe); + cmd.args(args); + Ok(cmd) +} + +/// Restart the current process, re-running the (already updated) executable with the **same +/// arguments** it was launched with. +/// +/// Equivalent to [`restart_with`] passing `std::env::args_os().skip(1)`. See the +/// [module docs](crate::restart) for platform behavior. On success this does not return; it yields +/// `Err(Error::Io(..))` only if the restart could not be started. +pub fn restart() -> Result { + restart_with(std::env::args_os().skip(1)) +} + +/// Restart the current process, re-running the (already updated) executable with the arguments in +/// `args` (replacing, not appending to, the original arguments) and the inherited environment. +/// +/// Use this to relaunch with a different argument list than the current process was started with, +/// e.g. dropping an `--upgrade` flag so the restarted process runs normally instead of updating +/// again. See the [module docs](crate::restart) for platform behavior. On success this does not +/// return; it yields `Err(Error::Io(..))` only if the restart could not be started. +/// +/// ```rust,no_run +/// # fn run() -> Result<(), Box> { +/// // Relaunch the updated binary with a fresh argument list. +/// self_update::restart::restart_with(["run", "--quiet"])?; +/// # Ok(()) +/// # } +/// ``` +#[cfg(unix)] +pub fn restart_with(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + use std::os::unix::process::CommandExt; + let mut cmd = restart_command(args)?; + // `exec` replaces the current process image; it returns only on failure. + Err(Error::Io(cmd.exec())) +} + +/// See the unix version above; on windows there is no `exec`, so this spawns the updated binary as +/// a new process (inheriting stdio) and then exits the current process with code `0`. +#[cfg(windows)] +pub fn restart_with(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = restart_command(args)?; + // No `exec` on windows: spawn the replacement, then exit so only the new process remains. + cmd.spawn()?; + std::process::exit(0); +} + +#[cfg(test)] +mod tests { + use super::restart_command; + use std::ffi::OsStr; + + // `restart_command` targets the current executable and forwards exactly the supplied args, in + // order, with nothing appended. This pins the wiring `restart_with` relies on, on every + // platform, without actually replacing the process (which `exec`/spawn-then-exit make + // impossible to observe in-process). The unix end-to-end exec path is covered by the + // integration test in `tests/restart.rs`. + #[test] + fn restart_command_targets_current_exe_with_given_args() { + let cmd = restart_command(["run", "--quiet"]).unwrap(); + assert_eq!(cmd.get_program(), std::env::current_exe().unwrap()); + let args: Vec<&OsStr> = cmd.get_args().collect(); + assert_eq!(args, ["run", "--quiet"]); + } + + // An empty argument list is honored verbatim (the restarted process gets no args), not silently + // backfilled from the current process's args. + #[test] + fn restart_command_with_no_args_forwards_none() { + let cmd = restart_command(std::iter::empty::<&OsStr>()).unwrap(); + assert_eq!(cmd.get_args().count(), 0); + } +} diff --git a/tests/restart.rs b/tests/restart.rs new file mode 100644 index 0000000..b4d2dfb --- /dev/null +++ b/tests/restart.rs @@ -0,0 +1,87 @@ +//! End-to-end coverage for `self_update::restart::restart_with` on unix, where the restart replaces +//! the process image with `exec` and therefore cannot be observed in-process. `exec` is unix-only, +//! so the whole file is gated on `cfg(unix)`; the cross-platform argument/target wiring is unit +//! tested in `src/restart.rs`, and the windows spawn-then-exit path is verified manually (a +//! detached spawn followed by `exit(0)` is inherently racy to assert on). +//! +//! ## How the test drives a real `exec` without a dedicated helper binary +//! +//! `restart_with` always re-execs `std::env::current_exe()`, so the "helper" has to *be* this test +//! binary. The flow is a three-generation self-re-exec, coordinated entirely through this file's +//! own tests: +//! +//! 1. `restart_with_reexecs_current_exe_with_given_args` (the assertion) spawns a fresh copy of the +//! test binary filtered to run only `restart_child_entry`, passing an output-file path in the +//! `SU_RESTART_OUT` env var. Its presence is what tells the child it is a spawned generation +//! rather than a normal `cargo test` run. +//! 2. That child runs `restart_child_entry` in the "parent" generation (env set, no marker arg yet) +//! and calls `restart_with([...])` with a marker argument. `exec` replaces it with a third +//! generation. +//! 3. The third generation runs `restart_child_entry` again, now sees the marker argument, and +//! writes its own `argv[1..]` to the output file. The assertion reads it back and checks the +//! args were forwarded to `current_exe` verbatim. +//! +//! Without `SU_RESTART_OUT` set (a normal `cargo test` run), `restart_child_entry` is a no-op, so +//! it never execs the plain test process. + +#![cfg(unix)] + +use std::process::Command; + +const OUT_ENV: &str = "SU_RESTART_OUT"; +// A marker arg that distinguishes the post-exec generation from the parent generation. It is also a +// (non-matching) libtest substring filter, so it does not change which test runs. +const MARKER: &str = "su_restart_grandchild_marker"; +// The exact args the parent generation asks `restart_with` to forward, and therefore what the +// post-exec generation must observe as its own `argv[1..]`. +const FORWARDED: [&str; 3] = ["restart_child_entry", MARKER, "--nocapture"]; + +/// Not a real assertion on its own: this is the code the spawned generations run. It is a no-op +/// during an ordinary `cargo test` (when `SU_RESTART_OUT` is unset), so it never execs the plain +/// test process. +#[test] +fn restart_child_entry() { + let Ok(out_path) = std::env::var(OUT_ENV) else { + // Ordinary test run, not a spawned generation: do nothing. + return; + }; + + let is_post_exec = std::env::args().any(|a| a == MARKER); + if is_post_exec { + // Third generation: prove `exec` forwarded exactly the args `restart_with` was given. + let args: Vec = std::env::args().skip(1).collect(); + std::fs::write(&out_path, args.join("\n")).expect("write forwarded args"); + } else { + // Second generation: exec into the third with a fresh, marker-carrying arg list. + let err = self_update::restart::restart_with(FORWARDED) + .expect_err("restart_with returns only on failure"); + panic!("exec failed instead of replacing the process: {err}"); + } +} + +/// Spawn a fresh copy of the test binary that execs itself via `restart_with`, and assert the +/// re-executed process saw exactly the forwarded arguments. +#[test] +fn restart_with_reexecs_current_exe_with_given_args() { + let dir = tempfile::TempDir::new().unwrap(); + let out_path = dir.path().join("forwarded-args.txt"); + + let status = Command::new(std::env::current_exe().unwrap()) + // Run only the child entry, exactly. + .args(["restart_child_entry", "--exact", "--nocapture"]) + .env(OUT_ENV, &out_path) + .status() + .expect("spawn test binary"); + assert!( + status.success(), + "spawned restart generation exited unsuccessfully: {status:?}" + ); + + let recorded = std::fs::read_to_string(&out_path) + .expect("post-exec generation should have written the forwarded args"); + let got: Vec<&str> = recorded.lines().collect(); + assert_eq!( + got, FORWARDED, + "restart_with must re-exec current_exe with the given args, verbatim" + ); +}