keyhog 0.5.73

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
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
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
//! Shared self-install / self-update primitives.
//!
//! The in-crate seed of the planned standalone installer library: `keyhog
//! doctor`, `update`, and `repair` all build on these. Keeping them in one
//! place is what lets the premium installer commands stay thin and lets the
//! whole layer be lifted into a published crate later without re-deriving the
//! GitHub-release resolution, asset selection, version comparison, executable
//! sanity check, signature/checksum verification, atomic self-replace, and
//! end-to-end scan self-test.
//!
//! ## Responsibility split
//!
//! - [`release`], the NETWORK + TRUST half: GitHub release resolution, asset
//!   selection, semver comparison, executable-magic sanity check, minisign and
//!   SHA-256 verification, and the scan-engine self-test. It produces
//!   *verified bytes*.
//! - this module, the LOCAL-INSTALL half: resolving the running binary, the
//!   atomic / rename-away self-replace, backup + rollback, and reaping the
//!   orphaned temp artifacts a killed update leaves behind. It consumes the
//!   verified bytes and commits them to disk recoverably.
//!
//! Both halves are re-exported here so `installer::resolve_release`,
//! `installer::install_with_rollback`, etc. keep their existing paths.
//!
//! Trust model: every release binary is signed with the keyhog minisign
//! secret key in the `sign` job of `.github/workflows/release.yml`, and
//! `download_verified_asset` verifies the downloaded binary against the
//! embedded [`RELEASE_PUBLIC_KEY`] and the release's exact SHA-256 entry before
//! self-replacing. Missing proof files fail CLOSED (refuse to install) since a
//! forged 404 would otherwise bypass the gate. There is no opt-out: no ambient
//! setting can disable release verification.

use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};

mod execution_packs;
mod gpu_artifacts;
mod release;
pub(crate) use execution_packs::*;
pub(crate) use gpu_artifacts::*;
pub(crate) use release::*;

fn remove_installer_artifact_best_effort(path: &Path, context: &str) {
    if let Err(error) = std::fs::remove_file(path) {
        tracing::warn!(
            path = %path.display(),
            %error,
            %context,
            "failed to remove installer artifact; it may need manual cleanup"
        );
    }
}

/// Resolve the running binary, following symlinks so we replace the real file.
pub(crate) fn current_binary() -> Result<std::path::PathBuf> {
    let exe = std::env::current_exe().context("locate current executable")?;
    std::fs::canonicalize(&exe).with_context(|| {
        format!(
            "resolve current executable symlink target for {} before self-update",
            exe.display()
        )
    })
}

#[cfg(unix)]
fn require_trusted_install_directory(dir: &Path) -> Result<()> {
    use std::os::unix::fs::MetadataExt;

    let metadata = std::fs::metadata(dir)
        .with_context(|| format!("inspect install directory {}", dir.display()))?;
    if !metadata.is_dir() {
        anyhow::bail!("install parent {} is not a directory", dir.display());
    }
    // SAFETY: geteuid has no preconditions and cannot fail.
    let effective_uid = unsafe { libc::geteuid() };
    if metadata.uid() != effective_uid && metadata.uid() != 0 {
        anyhow::bail!(
            "refusing to update through install directory '{}' owned by uid {} while running as uid {}. \
             Fix: run KeyHog as the directory owner, or reinstall into a root-owned system directory.",
            dir.display(),
            metadata.uid(),
            effective_uid
        );
    }
    if metadata.mode() & 0o022 != 0 {
        anyhow::bail!(
            "refusing to update through group/world-writable install directory '{}' (mode {:04o}); \
             another user could replace update artifacts. Fix: remove group/world write permission \
             from the directory or reinstall KeyHog into a private directory.",
            dir.display(),
            metadata.mode() & 0o7777
        );
    }
    Ok(())
}

#[cfg(unix)]
fn create_installer_artifact(path: &Path, purpose: &str) -> Result<std::fs::File> {
    std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .with_context(|| {
            format!(
                "create {purpose} {} exclusively; an existing path is refused to prevent symlink replacement",
                path.display()
            )
        })
}

#[cfg(unix)]
pub(crate) fn install_binary(exe: &Path, bytes: &[u8]) -> Result<()> {
    use std::io::Write;
    use std::os::unix::fs::PermissionsExt;
    // Binary publication (stage + atomic rename), profiled as reporting.
    let _publish_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
    let dir = exe
        .parent()
        .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
    require_trusted_install_directory(dir)?;
    // Stage in the SAME directory so the final rename is atomic (same
    // filesystem). Unix lets you replace a running executable's file: the
    // running process keeps the old (now-unlinked) inode; the next run picks
    // up the new binary.
    let tmp = dir.join(format!(".keyhog-update-{}.tmp", std::process::id()));
    let cleanup = |e: std::io::Error| {
        remove_installer_artifact_best_effort(&tmp, "failed unix install_binary cleanup");
        e
    };
    // Exclusive creation refuses a pre-planted symlink. Keep chmod and writes
    // on the opened descriptor so a path replacement cannot redirect them.
    let mut staged = create_installer_artifact(&tmp, "update staging file")?;
    staged
        .write_all(bytes)
        .map_err(cleanup)
        .context("write candidate binary bytes")?;
    staged
        .set_permissions(std::fs::Permissions::from_mode(0o755))
        .map_err(cleanup)
        .context("chmod the new binary")?;
    staged
        .sync_all()
        .map_err(cleanup)
        .context("flush the new binary before atomic replacement")?;
    drop(staged);
    std::fs::rename(&tmp, exe)
        .map_err(cleanup)
        .with_context(|| format!("atomically replace {}", exe.display()))?;
    Ok(())
}

/// Where the prior binary is stashed during a rename-away replace. PID-scoped
/// so concurrent updates don't collide; hidden + beside `exe` so the restore is
/// an atomic same-filesystem rename.
fn stash_path(exe: &Path) -> PathBuf {
    let name = exe
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "keyhog".to_string()); // LAW10: absent name/label => display default; reporting-only, recall-safe
    let parent = exe.parent().unwrap_or_else(|| Path::new(".")); // LAW10: no parent/unresolved path => '.' (current dir), intended path default; recall-safe
    parent.join(format!(".{name}.keyhog-old-{}", std::process::id()))
}

/// Write `bytes` to `path` and (on unix) mark it executable.
fn write_executable(path: &Path, bytes: &[u8]) -> Result<()> {
    std::fs::write(path, bytes).with_context(|| {
        format!(
            "write new binary to {} (the install dir must be writable; re-run with \
             elevated permissions or reinstall if keyhog lives in a system path)",
            path.display()
        )
    })?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
            .context("chmod the new binary")?;
    }
    Ok(())
}

/// Replace `exe` with `bytes` using the rename-away dance, verifying before
/// committing and rolling back on failure. Returns the stash path of the prior
/// binary on success (the caller reaps it; on Windows the still-running image
/// stays locked until the process exits, so deletion is deferred).
///
/// Why rename-away: on Windows you cannot overwrite or delete the RUNNING
/// `.exe`, but you CAN rename it within its directory - the running process
/// keeps executing from the renamed file while the original name is freed for
/// the new binary (the same mechanism rustup and the `self-replace` crate use).
/// The dance is equally correct on Unix, so this single routine backs the
/// Windows path while being exercised by tests on the Linux host - the Windows
/// self-replace is NOT a separate, untested codepath.
pub(crate) fn replace_running_binary<F>(
    exe: &Path,
    bytes: &[u8],
    verify: F,
) -> Result<Option<PathBuf>>
where
    F: FnOnce(&Path) -> bool,
{
    replace_running_binary_checked(exe, bytes, bool_verify_as_result(verify))
}

fn bool_verify_as_result<F>(verify: F) -> impl FnOnce(&Path) -> Result<()>
where
    F: FnOnce(&Path) -> bool,
{
    move |path| {
        if verify(path) {
            Ok(())
        } else {
            Err(anyhow!("post-install verifier returned false"))
        }
    }
}

fn replace_running_binary_checked<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<Option<PathBuf>>
where
    F: FnOnce(&Path) -> Result<()>,
{
    let had_prior = exe.exists();
    let stash = stash_path(exe);

    if had_prior {
        std::fs::rename(exe, &stash).with_context(|| {
            format!(
                "stash the current binary to {} before replacing it (the install dir \
                 must be writable so a failed update can roll back)",
                stash.display()
            )
        })?;
    }

    if let Err(e) = write_executable(exe, bytes) {
        // Nothing new was committed. Put the original binary back under its real
        // name and bail with the write error. If the restore ALSO fails the
        // operator's working binary is stranded at `stash` with nothing at
        // `exe` - surface that loudly (mirroring the verify-fail rollback below)
        // instead of swallowing the rename error.
        if had_prior {
            if let Err(restore_err) = std::fs::rename(&stash, exe) {
                return Err(e).with_context(|| {
                    format!(
                        "ROLLBACK FAILED after a failed binary write: the original working binary \
                         could not be restored from {} to {} ({restore_err}). It is stranded at \
                         {}; restore it manually.",
                        stash.display(),
                        exe.display(),
                        stash.display()
                    )
                });
            }
        }
        return Err(e);
    }

    let verify_error = match verify(exe) {
        Ok(()) => return Ok(had_prior.then_some(stash)),
        Err(error) => error,
    };

    // The new binary doesn't work on this host. It is NOT the running image
    // (the prior one, now at `stash`, is), so remove it and restore the stash.
    let removed = std::fs::remove_file(exe);
    if had_prior {
        // Renaming the stash back over `exe` replaces the broken binary whether
        // or not the remove above succeeded, so its result is not surfaced here.
        std::fs::rename(&stash, exe).with_context(|| {
            format!(
                "ROLLBACK FAILED: the new binary failed its health check ({verify_error}) and the \
                 stashed working binary at {} could not be restored over {}. Restore it manually.",
                stash.display(),
                exe.display()
            )
        })?;
        return Err(anyhow!(
            "new binary failed its post-install health check: {verify_error}; rolled back to the previous \
             working binary. The release may be broken for this host (libc/GPU driver) - \
             try `keyhog update --version <older-tag>` or report the release."
        ));
    }
    // No prior binary to fall back to: removing the broken one is the only
    // cleanup, so report honestly whether it actually went away rather than
    // asserting "removed it" when `remove_file` may have failed.
    match removed {
        Ok(()) => Err(anyhow!(
            "installed binary failed its post-install health check: {verify_error}; removed it because no prior \
             binary to roll back to. The release may be broken for this host."
        )),
        Err(remove_err) => Err(anyhow!(
            "installed binary failed its post-install health check: {verify_error}; it could NOT be removed from \
             {} ({remove_err}) and there is no prior binary to roll back to - delete it manually. The release \
             may be broken for this host.",
            exe.display()
        )),
    }
}

/// Best-effort reap of the temp artifacts a prior `update`/`repair` may have
/// left beside the binary:
///
/// * `.<name>.keyhog-old-<PID>`: the rename-away STASH from
///   `replace_running_binary` (e.g. a Windows update whose old image stayed
///   locked until its process exited).
/// * `.<name>.keyhog-bak-<PID>`: the BACKUP `install_with_rollback` copies
///   before swapping. The success/rollback paths delete it, but a process
///   KILLED (SIGKILL, power loss, OOM) between the backup copy and its removal
///   leaves it orphaned forever; without this it accumulates one stale file
///   per crashed update.
/// * `.<name>-update-<PID>.tmp`: the in-flight staging file `install_binary`
///   writes before the atomic rename; orphaned the same way on a hard kill.
///
/// Called only from `update`/`repair`, never the hot scan path, so it adds no
/// per-scan cost. PID-scoped naming is honored by parsing the PID suffix and
/// skipping artifacts whose owner process is still alive, so a concurrent
/// update keeps its rollback backup until it finishes.
pub(crate) fn reap_stale_binaries(exe: &Path) {
    let Some(parent) = exe.parent() else { return };
    let name = exe
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "keyhog".to_string()); // LAW10: absent name/label => display default; reporting-only, recall-safe
                                                  // Hidden rename-away artifacts: `.<name>.keyhog-old-*` / `.<name>.keyhog-bak-*`.
    let stash_prefix = format!(".{name}.keyhog-old-");
    let backup_prefix = format!(".{name}.keyhog-bak-");
    // LAW10: stale installer artifact reap is best-effort and recall-safe;
    // read-dir failure preserves current install behavior and drops no scan coverage.
    let Ok(entries) = std::fs::read_dir(parent) else {
        return;
    };
    for entry in entries {
        let entry = match entry {
            Ok(entry) => entry,
            Err(error) => {
                tracing::warn!(
                    dir = %parent.display(),
                    %error,
                    "cannot read installer artifact directory entry while reaping stale binaries; skipping entry"
                );
                continue;
            }
        };
        let fname = entry.file_name();
        let fname = fname.to_string_lossy();
        if should_reap_installer_artifact(&fname, &stash_prefix, &backup_prefix) {
            remove_installer_artifact_best_effort(&entry.path(), "stale installer artifact reap");
        }
    }
}

fn should_reap_installer_artifact(fname: &str, stash_prefix: &str, backup_prefix: &str) -> bool {
    installer_artifact_pid(fname, stash_prefix, backup_prefix)
        .is_some_and(|pid| !process_is_running(pid))
}

fn installer_artifact_pid(fname: &str, stash_prefix: &str, backup_prefix: &str) -> Option<u32> {
    if let Some(raw_pid) = fname.strip_prefix(stash_prefix) {
        return parse_artifact_pid(raw_pid);
    }
    if let Some(raw_pid) = fname.strip_prefix(backup_prefix) {
        return parse_artifact_pid(raw_pid);
    }
    fname
        .strip_prefix(".keyhog-update-")
        .and_then(|rest| rest.strip_suffix(".tmp"))
        .and_then(parse_artifact_pid)
}

fn parse_artifact_pid(raw: &str) -> Option<u32> {
    if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    match raw.parse() {
        Ok(pid) => Some(pid),
        Err(error) => {
            tracing::warn!(
                pid = raw,
                %error,
                "installer artifact filename carries an invalid PID; treating it as stale"
            );
            Some(u32::MAX)
        }
    }
}

#[cfg(unix)]
pub(crate) fn process_is_running(pid: u32) -> bool {
    if pid == std::process::id() {
        return false;
    }
    let Ok(pid) = libc::pid_t::try_from(pid) else {
        return false;
    };
    if pid <= 0 {
        return false;
    }
    let rc = unsafe { libc::kill(pid, 0) };
    if rc == 0 {
        return true;
    }
    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

#[cfg(windows)]
pub(crate) fn process_is_running(pid: u32) -> bool {
    use std::ffi::c_void;

    if pid == std::process::id() {
        return false;
    }

    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;

    #[link(name = "kernel32")]
    extern "system" {
        fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> *mut c_void;
        fn CloseHandle(hObject: *mut c_void) -> i32;
        fn GetLastError() -> u32;
    }

    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
    if handle.is_null() {
        // ERROR_INVALID_PARAMETER is the normal "PID does not exist" result.
        // Access denied instead proves that a process occupies the PID but is
        // owned at a privilege boundary; deleting its rollback artifact would
        // race a live higher-privilege update.
        const ERROR_INVALID_PARAMETER: u32 = 87;
        return unsafe { GetLastError() } != ERROR_INVALID_PARAMETER;
    }
    unsafe {
        CloseHandle(handle);
    }
    true
}

#[cfg(not(any(unix, windows)))]
pub(crate) fn process_is_running(_pid: u32) -> bool {
    false
}

#[cfg(windows)]
pub(crate) fn install_binary(exe: &Path, bytes: &[u8]) -> Result<()> {
    // Binary publication, profiled as reporting.
    let _publish_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
    // Rename-away replace without a health gate (that is install_with_rollback's
    // job). Leaves the prior image stashed; reaped by `reap_stale_binaries` on
    // the next update/repair once this process has exited and unlocked it.
    let _ = replace_running_binary(exe, bytes, |_| true)?; // LAW10: unused-binding marker; no runtime effect, not a fallback
    Ok(())
}

/// Path beside `exe` where the pre-overwrite binary is stashed so a broken or
/// interrupted update/repair can roll back. PID-scoped so two concurrent
/// updates don't clobber each other's backup. Same directory as `exe` so the
/// restore is an atomic same-filesystem rename.
pub(crate) fn backup_path(exe: &Path) -> PathBuf {
    let name = exe
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "keyhog".to_string()); // LAW10: absent name/label => display default; reporting-only, recall-safe
    let parent = exe.parent().unwrap_or_else(|| Path::new(".")); // LAW10: no parent/unresolved path => '.' (current dir), intended path default; recall-safe
    parent.join(format!(".{name}.keyhog-bak-{}", std::process::id()))
}

/// Run the freshly-installed binary's own `doctor` as the post-install health
/// gate. Execs a separate process (not the in-proc self-test) so it catches a
/// binary that is signed and a valid executable but won't actually run on THIS
/// host (wrong glibc, missing shared lib). Inherits stdio so the user sees the
/// doctor report as the verification.
pub(crate) fn verify_via_doctor_checked(exe: &Path) -> Result<()> {
    let status = std::process::Command::new(exe)
        .arg("doctor")
        .status()
        .with_context(|| {
            format!(
                "run candidate binary health check: {} doctor",
                exe.display()
            )
        })?;
    if status.success() {
        Ok(())
    } else {
        Err(anyhow!(
            "candidate binary doctor exited with {status}; run `{}` doctor` for the full report",
            exe.display()
        ))
    }
}

fn extract_keyhog_version(stdout: &str) -> Option<String> {
    stdout.lines().find_map(|line| {
        line.trim_start()
            .strip_prefix("KeyHog v")
            .and_then(|rest| rest.split_whitespace().next())
            .filter(|version| !version.is_empty())
            .map(str::to_string)
    })
}

fn candidate_reported_version(exe: &Path) -> Result<String> {
    let output = std::process::Command::new(exe)
        .arg("--version")
        .output()
        .with_context(|| {
            format!(
                "run candidate binary version check: {} --version",
                exe.display()
            )
        })?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(anyhow!(
            "candidate binary --version exited with {}; stderr: {}",
            output.status,
            stderr.trim()
        ));
    }
    let stdout = String::from_utf8(output.stdout)
        .context("candidate binary --version wrote non-UTF-8 stdout")?;
    extract_keyhog_version(&stdout).ok_or_else(|| {
        anyhow!(
            "candidate binary --version did not print a `KeyHog v<semver>` line; stdout: {}",
            stdout.trim()
        )
    })
}

fn semver_version(label: &str, version: &str) -> Result<semver::Version> {
    release::parse_version(version)
        .ok_or_else(|| anyhow!("{label} `{version}` is not a parseable semver"))
}

/// Prove the candidate binary is both runnable and the binary that the release
/// metadata claimed. This closes the substitution-downgrade class where a
/// hostile release endpoint serves `{ tag_name: v99.0.0 }` but attaches an
/// older, correctly signed keyhog binary.
pub(crate) fn verify_candidate_release(
    exe: &Path,
    expected_release_tag: &str,
    current_version: &str,
    allow_explicit_downgrade: bool,
) -> Result<()> {
    // Candidate validation (doctor gate + version binding), profiled as
    // preprocessing.
    let _verify_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    verify_via_doctor_checked(exe)?;

    let observed_version = candidate_reported_version(exe)?;
    let observed = semver_version("candidate binary version", &observed_version)?;
    let expected = semver_version("release tag", expected_release_tag)?;
    if observed != expected {
        return Err(anyhow!(
            "candidate binary version does not match release tag: binary reports v{} but release metadata resolved {}; refusing to install a mismatched signed binary",
            observed_version,
            expected_release_tag
        ));
    }

    if !allow_explicit_downgrade {
        let current = semver_version("current binary version", current_version)?;
        if observed.cmp_precedence(&current).is_lt() {
            return Err(anyhow!(
                "candidate binary reports v{} which is older than the running keyhog v{}; refusing implicit downgrade",
                observed_version,
                current_version
            ));
        }
    }

    Ok(())
}

/// Install `bytes` over `exe` and prove the result works before committing to
/// it, with automatic rollback on failure. This is the recoverability
/// invariant in code: no update/repair may leave the machine without a working
/// binary.
///
/// 1. If `exe` already exists, copy it to [`backup_path`] FIRST. If that copy
///    fails (read-only dir, no space), abort before touching `exe` - the
///    working binary stays untouched.
/// 2. Atomically replace `exe` with the new bytes (via [`install_binary`]).
/// 3. Run `verify(exe)`. On success, delete the backup and return `Ok`.
/// 4. On verify failure, restore the backup over `exe` (atomic rename) and
///    return an error. If there was no prior binary (fresh install) and verify
///    fails, remove the broken binary rather than leave it in place.
///
/// `verify` is injected so tests can drive the rollback path deterministically
/// without execing a real binary; production callers pass
/// [`verify_via_doctor_checked`].
pub(crate) fn install_with_rollback<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
    F: FnOnce(&Path) -> bool,
{
    install_with_rollback_checked(exe, bytes, bool_verify_as_result(verify))
}

#[cfg(unix)]
pub(crate) fn install_with_rollback_checked<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
    F: FnOnce(&Path) -> Result<()>,
{
    use std::os::unix::fs::PermissionsExt;
    let had_prior = exe.exists();
    let backup = backup_path(exe);

    if had_prior {
        let dir = exe
            .parent()
            .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
        require_trusted_install_directory(dir)?;
        let mut source = std::fs::File::open(exe)
            .with_context(|| format!("open current binary {} for backup", exe.display()))?;
        let mut backup_file = create_installer_artifact(&backup, "rollback backup")?;
        if let Err(error) = std::io::copy(&mut source, &mut backup_file) {
            remove_installer_artifact_best_effort(
                &backup,
                "failed unix rollback backup cleanup after copy error",
            );
            return Err(error).with_context(|| {
                format!(
                    "copy current binary into rollback backup {}",
                    backup.display()
                )
            });
        }
        // Backup must itself be runnable for the rollback to restore a working
        // tool; mirror the 0755 we set on installs.
        backup_file
            .set_permissions(std::fs::Permissions::from_mode(0o755))
            .and_then(|()| backup_file.sync_all())
            .map_err(|error| {
                remove_installer_artifact_best_effort(
                    &backup,
                    "failed unix rollback backup cleanup after finalize error",
                );
                error
            })
            .with_context(|| {
                format!(
                    "finalize executable rollback backup {} before updating",
                    backup.display()
                )
            })?;
    }

    // Atomic replace. On error `exe` is untouched (write/rename either fully
    // succeed or leave the original), so just drop the backup and bail.
    if let Err(e) = install_binary(exe, bytes) {
        if had_prior {
            remove_installer_artifact_best_effort(
                &backup,
                "failed unix rollback backup cleanup after install error",
            );
        }
        return Err(e);
    }

    let verify_error = match verify(exe) {
        Ok(()) => {
            if had_prior {
                remove_installer_artifact_best_effort(
                    &backup,
                    "failed unix rollback backup cleanup after successful install",
                );
            }
            return Ok(());
        }
        Err(error) => error,
    };

    // Verify failed: the new binary does not work on this host. Restore.
    if had_prior {
        std::fs::rename(&backup, exe).with_context(|| {
            format!(
                "ROLLBACK FAILED: the new binary failed its health check ({verify_error}) and the \
                 backup at {} could not be restored over {}. Reinstall manually from {}",
                backup.display(),
                exe.display(),
                backup.display()
            )
        })?;
        Err(anyhow!(
            "new binary failed its post-install health check: {verify_error}; rolled back to the previous \
             working binary. The release may be broken for this host (libc/GPU driver) - \
             try `keyhog update --version <older-tag>` or report the release."
        ))
    } else {
        // Fresh install with no prior binary: removing the broken one is the
        // only cleanup, so report honestly whether it actually went away rather
        // than asserting "removed it" when `remove_file` may have failed.
        match std::fs::remove_file(exe) {
            Ok(()) => Err(anyhow!(
                "installed binary failed its post-install health check: {verify_error}; removed it because no prior \
                 binary to roll back to. The release may be broken for this host."
            )),
            Err(remove_err) => Err(anyhow!(
                "installed binary failed its post-install health check: {verify_error}; it could NOT be removed \
                 from {} ({remove_err}) and there is no prior binary to roll back to - delete it manually. The \
                 release may be broken for this host.",
                exe.display()
            )),
        }
    }
}

#[cfg(windows)]
pub(crate) fn install_with_rollback_checked<F>(exe: &Path, bytes: &[u8], verify: F) -> Result<()>
where
    F: FnOnce(&Path) -> Result<()>,
{
    // Rename-away self-replace with the new binary's `doctor` as the health
    // gate and automatic rollback - the same recoverability invariant as unix,
    // via the cross-platform `replace_running_binary` (covered by tests on the
    // Linux host). The prior binary is the still-running image, locked by
    // Windows until this process exits; best-effort reap now, then leave it for
    // the next `update`/`repair` to clear via `reap_stale_binaries`.
    let stash = replace_running_binary_checked(exe, bytes, verify)?;
    if let Some(stash) = stash {
        remove_installer_artifact_best_effort(
            &stash,
            "failed windows rename-away stash cleanup after successful install",
        );
    }
    Ok(())
}