cctop 0.3.0

An htop-like terminal monitor for AI coding agent sessions (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, Windsurf)
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
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
//! Self-update against the project's GitHub releases.
//!
//! A downloaded binary has no package manager behind it, so without this it
//! stays on whatever version it was fetched at. Installs that *do* have a
//! package manager (`cargo install`, a distro package) must not be overwritten
//! behind the manager's back, so replacing the executable only ever happens
//! when the user asks for it with `--update`. The passive check just reports.

use crate::config;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::{Path, PathBuf};

const RELEASES_URL: &str = "https://api.github.com/repos/flolep2607/cctop/releases/latest";
/// GitHub rejects API requests without one.
const USER_AGENT: &str = concat!("cctop/", env!("CARGO_PKG_VERSION"));
/// How long a release check stays good for.
///
/// An hour, which is far cheaper than it sounds: the check is one unauthenticated
/// call to GitHub's releases API, the cache file lives in `CACHE_DIR` and is
/// shared by every cctop on the machine, and unauthenticated GitHub allows 60
/// requests an hour per IP — so this spends about 2% of that budget. Hourly is
/// the difference between hearing about a release the day after it lands and
/// hearing about it while it is still the thing that was just fixed.
const CHECK_MAX_AGE_SECS: u64 = 60 * 60;

pub fn current_version() -> &'static str {
    env!("CARGO_PKG_VERSION")
}

/// The release archive built for the running platform.
///
/// Releases are cut for a fixed set of targets, so this maps to one of those
/// rather than reporting the exact triple the binary was compiled for: a
/// `linux-gnu` build is served the static musl archive, which runs anywhere.
fn asset_target() -> Option<&'static str> {
    Some(match (std::env::consts::OS, std::env::consts::ARCH) {
        ("linux", "x86_64") => "x86_64-unknown-linux-musl",
        ("linux", "aarch64") => "aarch64-unknown-linux-musl",
        ("macos", "x86_64") => "x86_64-apple-darwin",
        ("macos", "aarch64") => "aarch64-apple-darwin",
        ("windows", "x86_64") => "x86_64-pc-windows-msvc",
        _ => return None,
    })
}

#[derive(Deserialize)]
struct Release {
    tag_name: String,
    #[serde(default)]
    assets: Vec<Asset>,
}

#[derive(Deserialize)]
struct Asset {
    name: String,
    browser_download_url: String,
}

#[derive(Serialize, Deserialize)]
struct CheckCache {
    checked_at: u64,
    latest: String,
}

fn unix_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

fn agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(std::time::Duration::from_secs(15)))
        .user_agent(USER_AGENT)
        .build()
        .into()
}

/// Compare dotted numeric versions. Anything unparseable sorts as zero, so a
/// malformed tag can never masquerade as an upgrade.
fn is_newer(candidate: &str, current: &str) -> bool {
    fn parts(v: &str) -> Vec<u64> {
        v.trim()
            .trim_start_matches('v')
            // Drop any pre-release or build suffix before comparing.
            .split(['-', '+'])
            .next()
            .unwrap_or_default()
            .split('.')
            .map(|p| p.parse().unwrap_or(0))
            .collect()
    }
    let (a, b) = (parts(candidate), parts(current));
    let len = a.len().max(b.len());
    for i in 0..len {
        let (x, y) = (
            a.get(i).copied().unwrap_or(0),
            b.get(i).copied().unwrap_or(0),
        );
        if x != y {
            return x > y;
        }
    }
    false
}

fn fetch_latest() -> Result<Release> {
    let text = agent()
        .get(RELEASES_URL)
        .call()
        .context("could not reach GitHub")?
        .body_mut()
        .read_to_string()
        .context("could not read the release response")?;
    serde_json::from_str(&text).context("could not parse the release response")
}

/// Newest published version, refreshed at most once an hour.
///
/// Returns the cached answer without touching the network when it is fresh, so
/// this is cheap to call on every start. Failures are silent: a monitor that
/// cannot reach GitHub should still run.
pub fn cached_latest_version() -> Option<String> {
    let path = config::CACHE_DIR.join("update-check.json");
    if let Ok(text) = std::fs::read_to_string(&path)
        && let Ok(cache) = serde_json::from_str::<CheckCache>(&text)
        && unix_secs().saturating_sub(cache.checked_at) < CHECK_MAX_AGE_SECS
    {
        return Some(cache.latest);
    }

    let latest = fetch_latest()
        .ok()?
        .tag_name
        .trim_start_matches('v')
        .to_string();
    let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
    if let Ok(text) = serde_json::to_string(&CheckCache {
        checked_at: unix_secs(),
        latest: latest.clone(),
    }) {
        let _ = std::fs::write(&path, text);
    }
    Some(latest)
}

/// The newer version available, or `None` when already current.
pub fn available_update() -> Option<String> {
    let latest = cached_latest_version()?;
    is_newer(&latest, current_version()).then_some(latest)
}

/// Pull the `cctop` executable out of a release archive.
///
/// Only the executable is taken, and only by exact file name: an archive is
/// attacker-controlled input in the general case, and honouring arbitrary paths
/// inside one is how extraction escapes its destination directory.
fn unpack(archive: &[u8], target: &str, into: &Path) -> Result<PathBuf> {
    // Both the container format and the executable's name are properties of the
    // archive's target, not of the host reading it. Deriving the name from
    // `cfg!(windows)` instead made them disagree whenever the two differ.
    let binary_name = if target.contains("windows") {
        "cctop.exe"
    } else {
        "cctop"
    };
    let out = into.join(binary_name);

    if target.contains("windows") {
        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive))
            .context("release archive is not a valid zip")?;
        for i in 0..zip.len() {
            let mut entry = zip.by_index(i)?;
            let is_binary = Path::new(entry.name())
                .file_name()
                .is_some_and(|n| n == binary_name);
            if is_binary {
                let mut file = std::fs::File::create(&out)?;
                std::io::copy(&mut entry, &mut file)?;
                return Ok(out);
            }
        }
    } else {
        let decoder = flate2::read::GzDecoder::new(archive);
        let mut tar = tar::Archive::new(decoder);
        for entry in tar
            .entries()
            .context("release archive is not a valid tar")?
        {
            let mut entry = entry?;
            let is_binary = entry.path()?.file_name().is_some_and(|n| n == binary_name);
            if is_binary {
                let mut file = std::fs::File::create(&out)?;
                std::io::copy(&mut entry, &mut file)?;
                #[cfg(unix)]
                {
                    use std::os::unix::fs::PermissionsExt;
                    std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?;
                }
                return Ok(out);
            }
        }
    }
    bail!("the release archive contains no {binary_name}")
}

/// Replace the running executable with the newest release.
///
/// Integrity rests on the TLS connection to github.com. The published `.sha256`
/// sidecars are served from that same origin, so verifying against them would
/// only catch corruption that TLS already rules out — it would not defend
/// against a compromised release.
pub fn run(force: bool) -> Result<()> {
    let current = current_version();
    // Before the network and before `force`, because this is not a check that a
    // newer release or a determined user can settle: the objection is to cctop
    // replacing the file at all, and it holds whatever the versions say.
    if managed_by_cargo() {
        return Err(cargo_managed());
    }
    let target =
        asset_target().ok_or_else(|| anyhow!("no release is published for this platform"))?;

    println!("Current version {current}; checking for updates…");
    let release = fetch_latest()?;
    let latest = release.tag_name.trim_start_matches('v');

    if !is_newer(latest, current) && !force {
        println!("Already on the newest version ({current}).");
        return Ok(());
    }

    let asset = release
        .assets
        .iter()
        // The checksum sidecars share the archive's prefix, so match on the
        // archive extensions rather than on the target alone.
        .find(|a| {
            a.name.contains(target) && (a.name.ends_with(".tar.gz") || a.name.ends_with(".zip"))
        })
        .ok_or_else(|| anyhow!("release {latest} has no archive for {target}"))?;

    // Claim the staging directory before downloading: whether the new binary can
    // be put in place is a permission question with an answer already available,
    // and finding out afterwards means having spent the download for nothing.
    let staging = staging_dir()?;

    println!("Downloading {}", asset.name);
    let mut body = Vec::new();
    agent()
        .get(&asset.browser_download_url)
        .call()
        .context("could not download the release archive")?
        .body_mut()
        .as_reader()
        .read_to_end(&mut body)
        .context("could not read the release archive")?;

    let new_binary = unpack(&body, target, staging.path())?;
    self_replace::self_replace(&new_binary).context("could not replace the running executable")?;

    println!("Updated {current} -> {latest}.");
    Ok(())
}

/// How to retry with the privileges this platform needs.
#[cfg(unix)]
const ELEVATE: &str = "re-run it as `sudo cctop --update`";
#[cfg(not(unix))]
const ELEVATE: &str = "re-run `cctop --update` from an elevated prompt";

/// A scratch directory beside the running binary, to stage the replacement in.
///
/// Replacing an executable is a rename and a rename cannot cross a filesystem
/// boundary, so this has to sit next to the current binary rather than in a temp
/// dir — which makes it a permission question wherever that binary lives.
fn staging_dir() -> Result<tempfile::TempDir> {
    let exe = std::env::current_exe().context("could not locate the running executable")?;
    let dir = exe
        .parent()
        .ok_or_else(|| anyhow!("the running executable has no parent directory"))?;
    match raw_stage_in(dir) {
        Ok(staged) => Ok(staged),
        // The one failure the user can do something about without going back to
        // the shell, so it is worth handling rather than only reporting.
        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => Err(elevate(dir)),
        Err(error) => Err(anyhow::Error::new(error)
            .context(format!("could not stage an update in {}", dir.display()))),
    }
}

/// The attempt itself, with the io error kept intact: whether this failed
/// because of permissions is the question the whole path above turns on, and an
/// error already wrapped in prose can no longer answer it.
fn raw_stage_in(dir: &Path) -> std::io::Result<tempfile::TempDir> {
    tempfile::Builder::new()
        .prefix(".cctop-update-")
        .tempdir_in(dir)
}

/// What to say when the install directory cannot be written and cctop has no
/// way to change that from here.
///
/// The documented install puts cctop in /usr/local/bin with sudo, so a
/// user-owned process being unable to replace it is the ordinary case, not an
/// exotic one. Saying so beats reporting a bare EACCES.
fn unwritable(dir: &Path) -> anyhow::Error {
    anyhow!(
        "{} is not writable by this user, so the new binary cannot replace the old one: {ELEVATE}. \
         If a package manager installed cctop, update it with that instead.",
        dir.display()
    )
}

/// What to say when even root cannot write there.
///
/// Root and still refused is not a permission problem anyone can grant their way
/// out of, so this must not mention sudo: pointing at it would only send the user
/// round the same loop a second time.
fn read_only(dir: &Path) -> anyhow::Error {
    anyhow!(
        "{} is not writable even as root, so the new binary cannot replace the old one — \
         the filesystem is mounted read-only, or the binary is immutable. \
         If a package manager installed cctop, update it with that instead.",
        dir.display()
    )
}

/// Where cargo puts the binaries it installs.
///
/// `CARGO_HOME` when it is set, because a user who moved it did so precisely so
/// that this is not `~/.cargo`, and the default otherwise.
fn cargo_bin() -> Option<PathBuf> {
    let home = match std::env::var_os("CARGO_HOME") {
        Some(dir) => PathBuf::from(dir),
        None => dirs::home_dir()?.join(".cargo"),
    };
    Some(home.join("bin"))
}

/// Whether `exe` is a file cargo installed into `bin`.
///
/// Both sides are resolved before they are compared. `~/.cargo/bin` is on `PATH`
/// through a symlink often enough — a home directory that is one, a toolchain
/// managed somewhere else and linked back — that comparing the paths as written
/// would answer "no" for an install that cargo plainly owns. Resolution failing
/// is itself an answer: a path that cannot be resolved is not one cargo is
/// managing, and guessing "yes" would refuse an update nobody could then perform.
fn under(exe: &Path, bin: &Path) -> bool {
    let (Ok(exe), Ok(bin)) = (exe.canonicalize(), bin.canonicalize()) else {
        return false;
    };
    exe.parent() == Some(bin.as_path())
}

/// Whether cargo, rather than a download, owns the running executable.
fn managed_by_cargo() -> bool {
    let (Ok(exe), Some(bin)) = (std::env::current_exe(), cargo_bin()) else {
        return false;
    };
    under(&exe, &bin)
}

/// What to say to a user whose cctop came from `cargo install`.
///
/// This is the case the permission check cannot catch, and the reason it needs
/// catching separately: `~/.cargo/bin` *is* writable, so nothing would refuse and
/// the replacement would simply happen. What breaks is not the binary but the
/// bookkeeping — cargo keeps its own record of what it installed, and a file
/// swapped underneath it leaves that record describing a version that is no
/// longer there.
fn cargo_managed() -> anyhow::Error {
    anyhow!(
        "cctop was installed by cargo, so replacing the binary here would put it out of step \
         with what cargo has recorded: `cargo install --list` would go on reporting {}, and the \
         next `cargo install-update` would undo the update. Run `cargo install cctop --force` \
         instead.",
        current_version()
    )
}

/// What cctop can offer a user whose install directory it cannot write to.
///
/// Kept as a decision separate from acting on it, because the interesting part
/// is the table of cases and none of it is testable once it has re-executed the
/// process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Recourse {
    /// Ask on the terminal, and re-run under sudo if the user agrees.
    Ask,
    /// Nothing to offer: report the failure and the manual fix.
    Explain,
    /// Already privileged, so the permission error is about the filesystem
    /// rather than about who is asking.
    Privileged,
}

/// Which of those applies, from facts the caller has already gathered.
///
/// `elevated` is the recursion guard, and it is deliberately wider than "am I
/// root": the child cctop re-runs under sudo must never be able to prompt and
/// elevate again, and a `sudo -u someone-else` that is not root would otherwise
/// slip past the root check and start the loop.
///
/// `interactive` covers CI, pipelines and hooks. Elevating unattended is not a
/// thing cctop may do — an unanswerable prompt on a detached stderr is a hang at
/// best, and a silent privilege escalation at worst — so those keep exactly the
/// behaviour they had before any of this existed.
fn recourse(root: bool, elevated: bool, sudo: bool, interactive: bool) -> Recourse {
    match (root, elevated, sudo && interactive) {
        (true, _, _) => Recourse::Privileged,
        (false, false, true) => Recourse::Ask,
        _ => Recourse::Explain,
    }
}

/// Whether cctop is already running as root.
#[cfg(unix)]
fn is_root() -> bool {
    // Safe: geteuid reads a process property and cannot fail.
    unsafe { libc::geteuid() == 0 }
}

#[cfg(not(unix))]
fn is_root() -> bool {
    false
}

/// Whether this process was itself started through sudo.
///
/// sudo puts `SUDO_USER` in the environment it hands the command, and unlike the
/// euid it survives a target user who is not root. Together with [`is_root`] it
/// is what stops the elevated child from offering to elevate again.
fn already_elevated() -> bool {
    std::env::var_os("SUDO_USER").is_some()
}

/// The command that re-runs this exact binary as root.
///
/// The resolved path from `current_exe`, never argv[0]: sudo resets `PATH` to
/// its own `secure_path`, so a bare `cctop` would be looked up somewhere else or
/// nowhere at all — and the binary to replace is the one at *this* path, which is
/// the same one `self_replace` will go for on the other side. `--` so a path that
/// begins with a dash can never be read as an option of sudo's.
fn sudo_argv(exe: &Path) -> Vec<String> {
    vec![
        "sudo".to_string(),
        "--".to_string(),
        exe.to_string_lossy().into_owned(),
        "--update".to_string(),
    ]
}

/// Handle an install directory this process cannot write into, elevating if the
/// user asks for it.
///
/// Returns the error to fail with. On the one path where it does not fail it
/// does not return at all: cctop hands the terminal to sudo, waits for the
/// privileged run to finish, and exits with whatever that run made of it — there
/// is nothing sensible left for this process to do afterwards, since the update
/// it was asked for has either already happened or already been reported.
fn elevate(dir: &Path) -> anyhow::Error {
    match recourse(
        is_root(),
        already_elevated(),
        crate::shim::is_command("sudo"),
        interactive(),
    ) {
        Recourse::Privileged => return read_only(dir),
        Recourse::Explain => return unwritable(dir),
        Recourse::Ask => {}
    }

    let exe = match std::env::current_exe() {
        Ok(exe) => exe,
        Err(_) => return unwritable(dir),
    };
    if !confirm(dir, &exe) {
        return anyhow!(
            "Not updated: {} is not writable by this user. \
             If a package manager installed cctop, update it with that instead.",
            dir.display()
        );
    }

    let argv = sudo_argv(&exe);
    // Inherited stdio, which is the whole reason this is a child process and not
    // an exec of something quieter: sudo asks for a password on the terminal, and
    // a captured stderr is a prompt the user never sees.
    match std::process::Command::new(&argv[0])
        .args(&argv[1..])
        .status()
    {
        // The privileged run has already printed everything there is to say,
        // including its own failures, so this adds nothing and only forwards how
        // it went.
        Ok(status) => std::process::exit(status.code().unwrap_or(1)),
        Err(error) => anyhow!("could not run sudo ({error}): {ELEVATE}."),
    }
}

/// Whether there is a user at the other end to answer a question.
///
/// stdin because the answer has to come from somewhere, and stderr because that
/// is where the question goes — stdout is left alone so `--update` stays usable
/// in a pipeline, which is also a place this must never prompt.
fn interactive() -> bool {
    use std::io::IsTerminal;
    std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}

/// Ask before running anything as root.
///
/// Explicit consent, defaulting to no: this re-runs a binary with full
/// privileges, and a user who typed `--update` asked to be updated, not to hand
/// root to whatever cctop decides to do next. Anything but a plain yes is a no,
/// including a closed stdin.
fn confirm(dir: &Path, exe: &Path) -> bool {
    use std::io::Write;

    let mut err = std::io::stderr();
    let _ = write!(
        err,
        "{} is not writable by this user.\nRe-run as root to replace {}? [y/N] ",
        dir.display(),
        exe.display()
    );
    let _ = err.flush();

    let mut answer = String::new();
    if std::io::stdin().read_line(&mut answer).is_err() {
        return false;
    }
    matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A cargo install is the case the permission check cannot see, because the
    /// directory it would test is writable. Only the executable's location
    /// separates it from a downloaded binary.
    #[test]
    fn cargo_owns_only_what_sits_directly_in_its_bin() {
        let home = tempfile::tempdir().unwrap();
        let bin = home.path().join("bin");
        std::fs::create_dir(&bin).unwrap();
        let exe = bin.join("cctop");
        std::fs::write(&exe, b"").unwrap();
        assert!(under(&exe, &bin));

        // A directory below it is not cargo's: nothing cargo installs lands
        // there, so a binary there is somebody else's to replace.
        let nested = bin.join("vendor");
        std::fs::create_dir(&nested).unwrap();
        let deep = nested.join("cctop");
        std::fs::write(&deep, b"").unwrap();
        assert!(!under(&deep, &bin));

        // The ordinary install, which must stay updatable.
        let elsewhere = home.path().join("cctop");
        std::fs::write(&elsewhere, b"").unwrap();
        assert!(!under(&elsewhere, &bin));
    }

    /// Regression: `~/.cargo/bin` reaches `PATH` through a symlink often enough
    /// that comparing the paths as written would call a cargo install a download
    /// and overwrite it.
    #[cfg(unix)]
    #[test]
    fn a_symlinked_cargo_bin_is_still_cargo() {
        let home = tempfile::tempdir().unwrap();
        let real = home.path().join("real-bin");
        std::fs::create_dir(&real).unwrap();
        let exe = real.join("cctop");
        std::fs::write(&exe, b"").unwrap();

        let linked = home.path().join("bin");
        std::os::unix::fs::symlink(&real, &linked).unwrap();
        assert!(under(&exe, &linked), "the link and its target disagreed");
    }

    /// A path that cannot be resolved is not one cargo is managing. Answering
    /// "yes" here would refuse an update that nothing could then perform.
    #[test]
    fn an_unresolvable_path_is_not_a_cargo_install() {
        let home = tempfile::tempdir().unwrap();
        let bin = home.path().join("bin");
        assert!(!under(&bin.join("cctop"), &bin));
    }

    /// The message has to name the command that does work, not only refuse.
    #[test]
    fn the_cargo_message_names_the_command_that_replaces_it() {
        let error = cargo_managed().to_string();
        assert!(
            error.contains("cargo install cctop --force"),
            "got: {error}"
        );
        assert!(error.contains(current_version()), "got: {error}");
    }

    /// The failure every user of the documented install hits, and the one place
    /// the message has to name `sudo` rather than report a bare EACCES.
    ///
    /// Two halves, because the live path between them re-executes the process:
    /// an install directory the user cannot write to really does fail with
    /// `PermissionDenied` — which is what routes it to [`elevate`] — and the
    /// message [`elevate`] falls back to when it has nothing to offer names both
    /// the directory and the command that would work.
    #[cfg(unix)]
    #[test]
    fn an_unwritable_install_directory_names_the_fix() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
        let kind = match raw_stage_in(dir.path()) {
            Err(error) => error.kind(),
            // Running as root, where the mode bits don't apply.
            Ok(_) => return,
        };
        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
        assert_eq!(kind, std::io::ErrorKind::PermissionDenied);

        let error = format!("{:#}", unwritable(dir.path()));
        assert!(error.contains("sudo cctop --update"), "got: {error}");
        assert!(error.contains("package manager"), "got: {error}");
        assert!(
            error.contains(&dir.path().display().to_string()),
            "got: {error}"
        );
    }

    /// Elevating is something the user asks for, never something that happens
    /// to them. Every case here is a case where cctop must *not* run sudo.
    #[test]
    fn sudo_is_only_ever_offered_to_someone_who_can_answer() {
        // The offer, and the only combination that produces it.
        assert_eq!(recourse(false, false, true, true), Recourse::Ask);

        // No sudo to run, and no terminal to ask on: CI, a pipeline, a hook.
        // Both keep the message the user has always got.
        assert_eq!(recourse(false, false, false, true), Recourse::Explain);
        assert_eq!(recourse(false, false, true, false), Recourse::Explain);
        assert_eq!(recourse(false, false, false, false), Recourse::Explain);

        // The recursion guard: the child cctop started under sudo finds the same
        // unwritable directory, and must not offer to elevate a second time —
        // whether or not sudo made it root.
        assert_eq!(recourse(false, true, true, true), Recourse::Explain);
        assert_eq!(recourse(true, true, true, true), Recourse::Privileged);

        // Root already, so the refusal is the filesystem's and not a question of
        // who is asking.
        assert_eq!(recourse(true, false, true, true), Recourse::Privileged);
    }

    /// What gets run as root has to be this binary at this path, since that is
    /// the file being replaced — and sudo's `secure_path` means a bare `cctop`
    /// is not a way to name it.
    #[test]
    fn the_elevated_command_names_the_running_binary_by_path() {
        let argv = sudo_argv(Path::new("/usr/local/bin/cctop"));
        assert_eq!(argv, ["sudo", "--", "/usr/local/bin/cctop", "--update"]);
    }

    /// Root has no fix to suggest, so it must not send the user back to sudo —
    /// and it still has to name the one thing that might explain it.
    #[test]
    fn a_root_failure_does_not_point_at_sudo() {
        let error = format!("{:#}", read_only(Path::new("/usr/local/bin")));
        assert!(!error.contains("sudo"), "got: {error}");
        assert!(error.contains("/usr/local/bin"), "got: {error}");
        assert!(error.contains("read-only"), "got: {error}");
        assert!(error.contains("package manager"), "got: {error}");
    }

    #[test]
    fn version_ordering_only_moves_forward() {
        assert!(is_newer("0.1.8", "0.1.7"));
        assert!(is_newer("v0.2.0", "0.1.9"));
        assert!(is_newer("1.0.0", "0.9.9"));
        assert!(!is_newer("0.1.7", "0.1.7"));
        assert!(!is_newer("0.1.6", "0.1.7"));
        // Differing lengths compare on the shared prefix, then on the extra parts.
        assert!(is_newer("0.1.7.1", "0.1.7"));
        assert!(!is_newer("0.1.7", "0.1.7.1"));
        // A pre-release of the current version is not an upgrade.
        assert!(!is_newer("0.1.7-rc1", "0.1.7"));
        // Garbage must never read as newer.
        assert!(!is_newer("not-a-version", "0.1.7"));
        assert!(!is_newer("", "0.1.7"));
    }

    #[test]
    fn every_released_target_is_reachable() {
        // The running platform must map to a published asset, or `--update`
        // could never work on the machines CI builds for.
        if matches!(std::env::consts::ARCH, "x86_64" | "aarch64") {
            assert!(asset_target().is_some(), "no asset for this platform");
        }
    }

    #[test]
    fn unpack_takes_only_the_executable_from_a_tarball() {
        let mut tar = tar::Builder::new(Vec::new());
        let payload = b"#!/bin/sh\necho hi\n";
        // A decoy that must be ignored, and the binary under a nested path: the
        // destination comes from the staging directory, never from the archive.
        for name in ["README.md", "dist/nested/cctop"] {
            let mut header = tar::Header::new_gnu();
            header.set_size(payload.len() as u64);
            header.set_mode(0o755);
            header.set_cksum();
            tar.append_data(&mut header.clone(), name, &payload[..])
                .unwrap();
        }
        let raw = tar.into_inner().unwrap();
        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
        std::io::Write::write_all(&mut gz, &raw).unwrap();
        let archive = gz.finish().unwrap();

        let dir = tempfile::tempdir().unwrap();
        let out = unpack(&archive, "x86_64-unknown-linux-musl", dir.path()).unwrap();

        // Flat in the staging directory, ignoring the archive's own path.
        assert_eq!(out, dir.path().join("cctop"));
        assert_eq!(std::fs::read(&out).unwrap(), payload);
        assert!(!dir.path().join("dist").exists());
        assert!(!dir.path().join("README.md").exists());
    }

    #[test]
    fn unpack_reports_an_archive_without_the_binary() {
        let mut tar = tar::Builder::new(Vec::new());
        let mut header = tar::Header::new_gnu();
        header.set_size(3);
        header.set_mode(0o644);
        header.set_cksum();
        tar.append_data(&mut header, "README.md", &b"hi\n"[..])
            .unwrap();
        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
        std::io::Write::write_all(&mut gz, &tar.into_inner().unwrap()).unwrap();
        let archive = gz.finish().unwrap();

        let dir = tempfile::tempdir().unwrap();
        assert!(unpack(&archive, "x86_64-unknown-linux-musl", dir.path()).is_err());
    }
}