self_update 1.3.0

Self updates for standalone executables
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# Update pipeline (reference)

Status: implemented

## Scope

Files: `src/update.rs` (the `ReleaseUpdate::update` / `update_extended` flow and the
shared helpers `choose_latest_release`, `resolve_and_confirm`, `build_download`,
`finish_update` / `finish_update_owned`, `install_binary`, `verify_signature`, plus the async
sibling `update_extended_async` and the public sealed `AsyncReleaseUpdate` trait) and
`src/lib.rs` (the `Download`, `Extract`, `ArchiveKind`, `Compression`, `Move`, and `MoveAll`
install primitives). This subsystem is the end-to-end install pipeline: how a built updater turns
"there is a newer release" into a replaced on-disk binary.

## Behavior

### Entry points

`update()` calls `update_extended()` and maps its result through
`ReleaseStatus::into_version_status(current_version)`. `update_extended()` is the sync flow; the
free `update::update_extended_async()` is the async flow, which differs in that the release listing
and the download are awaited and the verify/extract/replace tail runs on
`tokio::task::spawn_blocking`. The sync and async paths share the same selection/asset/download
helpers and the same verify/extract/replace tail (`finish_update_owned`).

The verify/extract/replace tail is `finish_update_owned(ctx, dir: TempDir, archive: &Path)`, which
takes a `FinishCtx` of **owned** fields (install path, target, bin name, in-archive path,
show_output, the verify callback, and under the features the owned checksum, the selected asset's
release-published digest plus the `verify_release_digest` flag, and verifying keys) and
the `TempDir` moved in by value. The sync `finish_update(&U, release, &target_asset, dir, archive)`
builds the ctx from the updater and the selected asset and calls the owned twin inline (no spawn). The async path builds the same ctx,
moves the `TempDir` into the closure, and runs `finish_update_owned` inside
`tokio::task::spawn_blocking(move || ...)`, awaiting the join handle and mapping a `JoinError` to
`Error::Internal { message, source }`. So the async update never blocks the executor on the verify/extract/replace work,
and `update_extended_async`'s future stays `Send` (the `PageRequest::parse` parser is `+ Send`).

### Fetch and select

1. Print the target-arch / current-version header (`print_check_header`, `src/update.rs:print_check_header`),
   gated on `show_output`.
2. If `release_tag()` is set, fetch exactly that tag via `get_release_version`.
   Otherwise fetch the candidate list via `get_newer_releases()` and run
   `choose_latest_release`, which: filters to releases strictly newer
   than the current version (`bump_is_greater`), sorts them semver-descending so selection is
   order-independent, then applies the `update_strategy()`: under `UpdateStrategy::Compatible`
   (default) it prefers the newest semver-*compatible* release and falls back to the newest
   available (flagged "*NOT* compatible"); under `UpdateStrategy::Latest` it always takes the
   newest available, even across a major bump. Empty candidate list => `Ok(None)` => `UpToDate`
   (`src/update.rs:choose_latest_release`). Unparseable versions are dropped by the leading
   `.unwrap_or(false)` filter and never reach the comparator.
3. `resolve_and_confirm` (`src/update.rs:resolve_and_confirm`) selects the asset: a custom `asset_matcher()` closure
   if present, otherwise `Release::asset_for(target, asset_identifier())`
   (`src/update.rs:Release::asset_for`), which matches by `target` substring (optionally `identifier`), then by
   `OS`+`ARCH` substring, then by `identifier` alone. No match =>
   `Error::NoReleaseFound { target: Some(...) }`. A server-supplied asset name that is empty,
   `.`/`..`, contains a path separator, contains a control character, or is absolute =>
   `Error::InvalidAssetName { name }` before any file is created. The control-character rejection
   is a display defense, not a path defense: the name is echoed into the confirmation block, where
   a `\r` or an ESC sequence could repaint the lines the user reads before authorizing the
   replacement.

### Download

`resolve_and_confirm` prints the release-status block and (unless `no_confirm`) prompts
(see below). If `check_install_path_writable()` is `true`, `probe_writable` (`src/update.rs:probe_writable`) runs
immediately after the confirmation and before any download, probing the bundle's parent directory in
bundle mode (`probe_dir_writable`) and otherwise `bin_install_path`
(`probe_install_path_writable`)
(`src/update.rs:update_extended` sync, `src/update.rs:update_extended_async` 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>` (`src/update.rs:update_extended`). `build_download` (`src/update.rs:build_download`) 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
callback, and progress style. The download is driven by `download_to` (sync, `src/lib.rs:download_to`)
or `download_to_async` (`src/lib.rs:download_to_async`). The retry budget covers the download's request-establishment phase (before bytes stream); mid-stream failures are not retried.

### Extract

`finish_update` (`src/update.rs:finish_update`) runs verification (below), then extracts. The in-archive
binary path comes from `bin_path_in_archive()` with `{{ version }}`, `{{ target }}`, and
`{{ bin }}` placeholders substituted (`src/update.rs:substitute`). Extraction is
`Extract::from_source(archive).extract_file(tmpdir, bin_path)` (`src/update.rs:finish_update_owned`). Archive kind
is detected from the file extension by `detect_archive` (`src/lib.rs:detect_archive`) unless overridden via
`Extract::archive`: `.zip` => `Zip`; `.tar` => `Tar(None)`; `.tgz` and `.tar.gz` =>
`Tar(Some(Gz))`; a bare `.gz` => `Plain(Some(Gz))`; `.txz` and `.tar.xz` => `Tar(Some(Xz))`;
a bare `.xz` => `Plain(Some(Xz))`; anything else => `Plain(None)`. A kind whose archive
feature is not enabled yields `Error::ArchiveNotEnabled`, and a recognized compression whose
codec feature is off (a `.gz` without `compression-tar-gz`, a `.xz` without
`compression-tar-xz`) yields `Error::CompressionNotEnabled` rather than installing the still
-compressed bytes (`src/lib.rs:detect_archive`). `ArchiveKind` (`src/lib.rs:ArchiveKind`) and `Compression` (`Gz`, `Xz`)
are `#[non_exhaustive]`; the `Tar` and `Zip` variants are feature-gated on `archive-tar` /
`archive-zip`. `Plain` files are copied (gz/xz-decoded when the matching codec feature is on),
`Tar` is unpacked via the `tar` crate over the decoded stream, `Zip` via the `zip` crate
(`src/lib.rs:extract_into`, `src/lib.rs:extract_file`). The extracted binary is `<tmpdir>/<bin_path>`
(`src/update.rs:finish_update_owned`).

Zip entry names that would escape `into_dir` are rejected via `enclosed_name` (zip-slip defense,
`src/lib.rs:enclosed_name`). On unix, `extract_into` restores a zip symlink entry (unix mode carrying
`S_IFLNK`, detected by `ZipFile::is_symlink`) as a real symlink rather than writing its target
string out as a regular file (`src/lib.rs:extract_into`), preserving symlink-dependent trees such as a macOS
`.app` bundle's `Frameworks/*/Versions/Current` links; `tar` extraction already restores symlinks
itself. A symlink target that escapes the extraction root (absolute, or `..` resolving above
`into_dir`) is rejected by `symlink_target_escapes` with an `Error::Internal`, mirroring the
entry-name defense; a duplicate entry at the link path is removed before the link is created, and
no permission bits are applied to the link. That target check is purely lexical, so it cannot catch
a symlinked intermediate directory that aliases an entry's parent to a shallower physical path (the
symlinked-parent traversal: `d/sl -> ..` then `d/sl/evil -> ../../x`, lexically in-bounds but
physically above the root). As a backstop, the extraction root is canonicalized once up front and,
for every zip entry (symlink or regular file), after its parents are materialized the physical
parent is canonicalized and must equal `canonical_root` joined with the entry's lexical parent;
descent through a symlinked ancestor (or a canonicalize failure) is rejected with an
`Error::Internal`, while descent through real directories is allowed. On non-unix targets the
symlink-restore
block is compiled out and such an entry is written as a regular file (creating symlinks needs
elevated privileges on windows). `extract_file` errors on a symlink entry (`src/lib.rs:extract_file`) rather
than writing its target string as the requested file.

### Verify ordering

In `finish_update`, before any extraction or replacement:

1. **Checksum** (feature `checksums`): if `verify_checksum()` is set, `checksum.verify(archive_path)`
   on the downloaded archive; a mismatch aborts here (`src/update.rs:finish_update_owned`).
1b. **Published sums digest** (feature `checksums`): if `checksum_from_asset()` names an asset, the
   digest resolved from it before the download (see below) is verified the same way, independently
   of gate 1 (`src/update.rs:finish_update_owned`).
2. **Release digest** (feature `checksums`): if `verify_release_digest()` is on (the default) and
   the selected asset carries a backend-published digest (`ReleaseAsset::digest()`, the
   `algorithm:hex` form github publishes per asset), the digest is parsed via
   `Checksum::parse_digest` and verified against the archive (`src/update.rs:finish_update_owned`). A digest
   that is present but malformed or an unsupported algorithm aborts with
   `Error::InvalidResponse` naming the digest (no silent skip); an absent digest skips the gate.
   Independent of gate 1: when both apply, both must pass.
3. **Signature** (feature `signatures`): `verify_signature(archive_path, verifying_keys())`
   (`src/update.rs:verify_signature`). Empty key set is a no-op; otherwise the archive is detected and verified
   with zipsign (`verify_tar` for `Tar(Some(Gz))`, `verify_zip` for `Zip`), keyed with the
   archive file name as context; any other kind => `Error::NoSignatures(kind)`,
   whose message names the kind via its `Display` impl
   (`tar.gz` / `zip` / `tar` / `gz` / `plain`), e.g. "signature verification is only
   implemented for `.tar.gz` and `.zip` assets, not gz files".

4. **Archive hook**: if `verify_archive_callback()` is set, it is called with the archive path
   (`src/update.rs:run_archive_verify_hook`, from `src/update.rs:finish_update_owned`). `Err(..)`
   aborts with `Error::ArchiveVerificationRejected { reason }`, nothing extracted and nothing
   installed; an error that already is an `ArchiveVerificationRejected` passes through unwrapped,
   any other error's message becomes the reason. This is the caller's own gate over the artifact as
   published -- an external attestation/signature check (`gh attestation verify`,
   `cosign verify-blob`) whose subject is the release file itself. It runs *after* gates 1-3 so a
   corrupt download is rejected by the cheap built-in digest check before an external tool is
   spawned on it.

The sums digest itself is resolved *before* the artifact download, in both orchestrators
(`src/update.rs:update_extended`, `src/update.rs:update_extended_async`): after the asset is selected
and confirmed, `sums_asset_for` finds the release asset named by `checksum_from_asset()`
(absent => `Error::ChecksumSourceInvalid`, never a skipped check), `build_sums_download` fetches it
over the artifact download's transport with progress reporting cleared
(`src/lib.rs:Download::clear_progress_reporting`), and `checksum_from_sums_bytes` ->
`Checksum::from_sums_file` (`src/checksum.rs`) parses the entry for the selected asset's file name.
Resolving first means a release missing the sums asset fails without pulling the artifact. The
algorithm comes from the digest length (64 -> sha256, 128 -> sha512).

All four gates run on the *downloaded archive bytes* and before extraction. The last hook,
`verify_binary`, runs later inside `install_binary` on the *extracted binary* (in bundle mode, on
the *staged bundle root*), immediately before the swap. Ordering: verify_checksum -> release
digest -> verify_keys -> verify_archive -> extract -> verify_binary -> replace.

The two hooks are deliberately distinct error variants (`ArchiveVerificationRejected` vs
`VerificationRejected`) because they see different files: a caller registering both can tell which
one refused the update.

### Replace

`install_binary` (`src/update.rs:install_binary`): runs the `verify_binary` hook first; `Err(..)` => bail
`Error::VerificationRejected { reason }` with nothing replaced. Then
if `bin_install_path()` equals `std::env::current_exe()`, the swap goes through
`self_replace::self_replace(new_exe)` (atomic in-place replace of the running exe,
`src/update.rs:install_binary`). Otherwise `Move::from_source(new_exe).to_dest(bin_install_path)`
(`src/update.rs:install_binary`). `Move::to_dest` (`src/lib.rs:Move::to_dest`) renames source -> dest; with
`replace_using_temp` set and an existing dest, it first renames dest aside to the temp path
and renames it back if the source->dest rename fails (rollback). `rename` cannot cross
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` (`src/update.rs:map_install_io_error`): 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`).

### Bundle install (directory bundles)

`bundle_path_in_archive()` being `Some` selects bundle mode, resolved at `build()` time by
`CommonBuilderConfig::resolve_bundle_mode` (`src/backends/common.rs:CommonBuilderConfig::resolve_bundle_mode`): an explicit
`bin_install_path` or a non-auto `bin_path_in_archive` alongside it is
`Error::ConflictingConfig { field, conflict }`; a `bundle_install_path` set *without*
`bundle_path_in_archive` is `Error::MissingField { field: "bundle_path_in_archive" }` rather than a
silently discarded path; and an unset `bundle_install_path` resolves via
`default_bundle_install_path` (`src/update.rs:default_bundle_install_path`) -- on macOS the nearest `.app` ancestor of
`current_exe()` (`enclosing_app_bundle`, `src/update.rs:enclosing_app_bundle`), with a translocated exe
(`is_translocated`, `src/update.rs:is_translocated`) rejected as `Error::AppTranslocated` and no `.app` ancestor as
`Error::NoAppBundle`; on every other target `Error::MissingField { field: "bundle_install_path" }`.

In the finish tail the same `{{ bin }}` / `{{ target }}` / `{{ version }}` substitution runs over
the bundle path, then `install_bundle` (`src/update.rs:install_bundle`) replaces the single-file
extract-and-install pair: the configured path is first run through `resolve_bundle_target`, which
maps a live symlink to the tree it designates (`rename` does not follow a path's final component, so
swapping onto the link itself would stash the link and orphan the installed tree; a dangling link and
a plain path pass through unchanged); two `tempfile::TempDir`s (staging and stash) are created inside
`install_parent(<resolved target>)`, so every rename is same-filesystem and there is no cross-device
case; `Extract::extract_into` unpacks the whole archive into staging; the staged root is
`staging/<substituted bundle path>`. Failure to create either temp dir goes through
`map_install_io_error` naming the bundle path.

`swap_bundle` (`src/update.rs:swap_bundle`) performs the swap, taking the running exe as a parameter (so it is
testable against a temp tree). Pre-swap checks, none of which touch the destination: the staged root
must exist and be a directory (else `Error::Io` NotFound naming it); when `exe_inside_bundle`
(`src/update.rs:exe_inside_bundle`, canonicalizing both sides like `same_file`) reports the running exe inside the
installed bundle, the staged tree must carry a file at the same relative path; then the
`verify_binary` hook runs against the *staged bundle root* via the shared `run_verify_hook`
(`src/update.rs:run_verify_hook`). Then, in order: rename the running exe to `stash/exe-aside` (only when it is
inside the bundle), rename `bundle_install_path` to `stash/old` (only when it exists), rename the
staged root onto `bundle_install_path`. A failure at either later step reverses the applied renames
(old tree first, then the exe, via `restore_stashed`, `src/update.rs:restore_stashed`) and returns the original
error mapped by `map_install_io_error`; rollback is best-effort and logged, matching the `MoveAll`
contract. After the final rename the update is committed and the file at the running exe's path is
the new tree's executable, so no `self_replace` call is involved. On unix the stashed old image is
unlinked with the stash `TempDir`; on windows it may stay locked until process exit, which never
affects the installed tree. The swap is one code path on all targets: a windows bundle holding other
open files (a loaded DLL) fails at the directory rename and rolls back.

Output messages in bundle mode are "Extracting archive... Done" then "Replacing bundle directory...
Done"; the confirmation block names the bundle path ("Current bundle:") and says the existing bundle
directory will be replaced, since `bin_install_path` is never written in bundle mode.
`ReleaseStatus` / `VersionStatus` reporting is unchanged.

Existence at the destination is tested with `fs::symlink_metadata`, not `exists()`: a dangling
symlink is an entry that must be stashed out of the way (renaming a directory onto one fails with
`ENOTDIR`), where `exists()` would report it absent. Concurrency is not coordinated: the existence
test and the renames are not atomic as a unit, so racing updaters can interleave.

### Multi-file install

`MoveAll` (`src/lib.rs:MoveAll`) is the transactional multi-file primitive, not used by the
single-binary `update()` flow; callers drive it by hand after extracting an archive
themselves. `from_temp(temp)` starts it, `add(source, dest)` queues moves, `commit()` applies
them in order (`src/lib.rs:commit`). Each existing destination is stashed under `temp` so it can be
restored; on the first failed rename, the just-stashed dest is restored and all
already-applied moves are rolled back in reverse via `rollback` (`src/lib.rs:rollback`), restoring
stashed originals or removing freshly-installed files, and the original error is returned.
Rollback is best-effort: a failing rollback step is logged via `log::error!`, not surfaced.
`commit` drains the queue (`std::mem::take`), so a second `commit` is a no-op returning
`Ok(())`. All sources, destinations, and `temp` must be on one filesystem (`rename`).

### Confirm and output

`no_confirm()` controls the prompt; `show_output()` controls informational printing. In
`resolve_and_confirm` (`src/update.rs:resolve_and_confirm`), the release-status block (current exe, new exe
name, download URL, "will be downloaded/extracted and replaced") prints when either
`show_output` is true or a confirmation will be prompted, so it prints even with
`show_output(false)` unless `no_confirm(true)` is also set. The install-target line is built by
`install_target_line` (`src/update.rs:install_target_line`), which formats the path with `Path::display()`, so it
prints unquoted and with one separator per component on windows; the asset name and the redacted
download URL are strings formatted with `{:?}` and stay quoted. The confirmation prompt
(`confirm("Do you want to continue? [Y/n] ")`, `src/lib.rs:confirm`) reads stdin; blank or `y`
continues, anything else => `Error::Aborted` (Display "AbortedError: the update was not
confirmed", `src/lib.rs:confirm`). `print_check_header`,
`finish_update`'s "Extracting archive..."/"Done"/"Replacing binary file..." messages, and
`choose_latest_release`'s release messages are all gated on `show_output`
(`print_flush`/`println` helpers, `src/update.rs:print_flush`, `src/update.rs:println`). `show_download_progress()` toggles the
`indicatif` terminal bar in `Download` (`src/lib.rs:show_download_progress`); the bar is suppressed when the server
sends no `Content-Length`. An independent `progress_callback` fires per chunk regardless of
the bar.

### Status reported

`ReleaseStatus` (`src/update.rs:ReleaseStatus`) is `UpToDate` or `Updated(Release)` (carries the full installed
`Release`). `update_extended` returns `Updated(release)` after a successful install
(`src/update.rs:update_extended`) or `UpToDate` when nothing newer was found. `update()` collapses this to
`VersionStatus` (`src/lib.rs:VersionStatus`), `UpToDate(String)` / `Updated(String)` carrying only the version tag,
via `into_version_status`.

## Public surface

- `update::ReleaseUpdate` (sealed): `update(&self) -> Result<VersionStatus>`,
  `update_extended(&self) -> Result<ReleaseStatus>`, plus `get_latest_release`,
  `get_newer_releases`, `get_release_version`. Accessors live on the sealed `UpdateConfig`
  supertrait. Each backend `build()` returns the concrete `Update` (`Send`), which
  exposes these verbs plus `is_update_available` as inherent methods.
- `update::AsyncReleaseUpdate` (sealed via `UpdateConfig: sealed::Sealed`, feature `async`): the
  async counterpart of `ReleaseUpdate`. Fetch verbs `get_latest_release_async`,
  `get_newer_releases_async`, `get_release_version_async`, plus default-bodied `update_async` (->
  `VersionStatus`) and `update_extended_async` (-> `ReleaseStatus`) that route to the free
  `update::update_extended_async`. Its methods are RPITIT (`impl Future<Output = ...> + Send`), so
  the trait is not object-safe (nameable and usable as a generic bound, like `AsyncReleaseSource`,
  but never `dyn`). Bring it into scope to call the verbs.
- `update::ReleaseStatus` (`#[non_exhaustive]`): `into_version_status`, `is_up_to_date`, `is_updated`.
- `VersionStatus` (`#[non_exhaustive]`): `version`, `is_up_to_date`, `is_updated`, `Display`.
- `Download`: `from_url`, `show_download_progress`, `timeout`, `progress_callback`,
  `progress_style`, `replace_headers`, `request_header`, `download_to`, `download_to_async`
  (feature `async`).
- `Extract`: `from_source`, `archive`, `extract_into`, `extract_file`; the path
  arguments take `impl AsRef<Path>` (as do `Move` / `MoveAll`), with no lifetime
  parameter on the types.
- `ArchiveKind` (`#[non_exhaustive]`): `Plain(Option<Compression>)`, `Tar(...)` (feature
  `archive-tar`), `Zip` (feature `archive-zip`). `Compression` (`#[non_exhaustive]`): `Gz`
  (feature `compression-tar-gz`), `Xz` (feature `compression-tar-xz`).
- `Move`: `from_source`, `replace_using_temp`, `to_dest`.
- `MoveAll` (`#[must_use]`, `#[non_exhaustive]`): `from_temp`, `add`, `commit`.

Async `update_async` / `update_extended_async` are default methods on the public sealed
`AsyncReleaseUpdate` trait, implemented by each backend's `Update` (and the custom `AsyncUpdate`)
under feature `async`; the free `update::update_extended_async` they route to is `pub(crate)`.

## Invariants and regression checklist

- Verify-before-replace: checksum, release digest, signature, and the `verify_archive` hook all run
  on the downloaded archive *before* extraction; `verify_binary` runs on the extracted binary
  *before* the swap. Nothing is extracted or replaced if any of the five rejects
  (`src/update.rs:finish_update_owned`, `src/update.rs:install_binary`).
- The `verify_archive` hook runs last among the archive gates, so a corrupt download is rejected by
  the built-in digest checks before the caller's external verifier is invoked
  (`src/update.rs:finish_update_owned`). It fires in bundle mode too: the hook sits ahead of the
  branch into `install_bundle`.
- `checksum_from_asset` never degrades to "no verification": a missing sums asset, a missing entry,
  a non-UTF-8 body, or an unusable digest length all abort with `Error::ChecksumSourceInvalid`
  (`src/update.rs:sums_asset_for`, `src/checksum.rs:from_sums_file`).
- The release-digest gate is on by default under `checksums` and only fires when the selected
  asset carries a digest; `verify_release_digest(false)` opts out. A present-but-unparseable
  digest is a hard `Error::InvalidResponse`, not a silent skip (`src/update.rs:finish_update_owned`).
- Order independence: `choose_latest_release` sorts candidates semver-descending and filters
  to strictly-newer, so a custom source's unordered/stale list selects correctly and never
  re-installs the current version (`src/update.rs:choose_latest_release`).
- Download/extract happen entirely under a `tempfile::TempDir`; it is cleaned up on drop. The
  running exe is replaced atomically via `self_replace` when it is the install target.
- `MoveAll` is all-or-nothing: success replaces every dest, first failure restores every
  destination to its prior contents; the original error (not a rollback error) is returned;
  rollback failures are logged only. A second `commit` is a no-op.
- The status block prints when `show_output || !no_confirm`; the prompt prints only when
  `!no_confirm`. Suppressing one does not suppress the other.
- The status block's install-target line goes through `Path::display()`, never `{:?}`: a path is
  shown exactly as the platform writes it, so a windows path keeps single backslashes
  (`install_target_line`, `src/update.rs:install_target_line`).
- 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_writable`,
  `src/update.rs:probe_writable`) runs after confirmation and before any download, targeting the bundle's parent
  directory in bundle mode and `bin_install_path` otherwise; only a definite `PermissionDenied`
  errors, indeterminate results proceed. Default is `false`.
- Bundle mode is all-or-nothing at whole-tree granularity: nothing under `bundle_install_path`
  changes until the old tree is stashed, a failure at any step restores the old tree (and the
  running exe inside it), and the original error is returned with rollback failures logged only. It
  never falls back to a copy, so an install is never partially visible; and it never calls
  `self_replace` (the exe rides along inside the swapped tree).
- Bundle mode and the single-file `bin_*` paths are mutually exclusive: setting both explicitly is
  `Error::ConflictingConfig` from `build()`, not a silently-dropped setter.
- 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`, `src/update.rs:map_install_io_error`). 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
  `tokio::task::spawn_blocking` over owned fields, with the `TempDir` moved into the closure. The
  sync and async paths share the same owned finish tail, so verify/extract/replace behavior is
  identical (sync/async parity). `update_extended_async`'s future is `Send` (the page parsers are
  `+ Send`).

## Tests

`update.rs` `mod tests`: `choose_latest_release_*` (up-to-date / prefers-newest-compatible /
sorts-out-of-order / ignores-unparseable / falls-back-to-incompatible);
`install_binary_aborts_when_verify_rejects`, `install_binary_installs_when_verify_accepts`,
`install_target_line_prints_paths_without_debug_escaping`;
`finish_update_rejects_a_mismatched_checksum_before_extracting`,
`finish_update_passes_a_matching_checksum_then_proceeds`,
`finish_update_rejects_a_mismatched_release_digest_by_default`,
`finish_update_passes_a_matching_release_digest_then_proceeds`,
`finish_update_release_digest_opt_out_skips_the_gate`,
`finish_update_rejects_an_unsupported_release_digest` (feature-gated); the bundle set
`swap_bundle_installs_when_nothing_is_there`, `swap_bundle_replaces_the_whole_tree`,
`swap_bundle_rejects_a_missing_or_non_directory_staged_root`,
`swap_bundle_rolls_back_when_the_install_rename_fails`,
`swap_bundle_moves_the_running_exe_aside_and_restores_its_path`,
`swap_bundle_rollback_restores_the_running_exe_inside_the_old_tree`,
`swap_bundle_requires_the_staged_tree_to_carry_the_running_exe_path`,
`swap_bundle_verifies_the_staged_root_and_a_rejection_replaces_nothing`,
`install_bundle_extracts_and_swaps_a_real_archive` (zip fixture with an exec bit and a symlink),
`exe_inside_bundle_detects_containment_through_symlinks`,
`enclosing_app_bundle_finds_the_nearest_app_ancestor`,
`is_translocated_matches_the_translocation_mount`,
`probe_writable_probes_the_bundle_parent_in_bundle_mode`, and
`probe_writable_falls_back_to_the_bin_path_without_bundle_mode`; `backends/common.rs` `mod tests`
covers the bundle-mode resolution (`build_resolves_bundle_mode_with_an_explicit_install_path`,
`build_leaves_bundle_fields_none_without_the_setter`,
`build_rejects_bundle_mode_with_an_explicit_bin_install_path`,
`build_rejects_bundle_mode_only_with_an_explicit_bin_path_in_archive`,
`build_requires_bundle_install_path_off_macos`). `lib.rs` `mod tests`:
`detect_*` (archive detection), `unpack_*` / `test_extract_into` / `test_extract_file`
(extraction), `move_all_commits_every_move`, `move_all_rolls_back_on_failure`,
`move_all_installs_fresh_destinations`, `move_all_second_commit_is_a_noop`,
`download_invokes_progress_callback`, the `download_header_*` / `replace_headers_*` header
tests, and `status_is_up_to_date`. Doctests in the `lib.rs` crate docs cover the manual
download/extract/replace and `MoveAll` flows.

## Related

- `ref-signatures-and-checksums.md` (verify primitives), `checksum-verification.md`,
  `checksum-from-asset.md`
- `post-update-verify.md` (the `verify_binary` hook)
- `archive-verify.md` (the `verify_archive` hook)
- `multi-file-install.md` (`MoveAll`)
- `progress-callback.md` (download progress)
- `custom-asset-matching.md` (the `asset_matcher` override)
- `choose-latest-release-sort.md` (selection ordering)
- `async-api.md` (the async update path)
- `transport-control.md` (download client/headers/timeout)