Skip to main content

mkit_cli/commands/
self_update.rs

1//! `mkit self` — self-management of an installer-managed mkit binary.
2//!
3//! ```text
4//! mkit self update [--version <tag>] [--check] [--allow-downgrade]
5//!                  [--format human|json]
6//! ```
7//!
8//! Updates the running binary in place from GitHub Releases. The
9//! downloaded archive is checked against its sha256 sidecar asset
10//! when the release publishes one; verification is fully in-process:
11//! no `cosign`, no GitHub attestation API.
12//!
13//! Management contract (shared with `install.sh`):
14//!
15//! * The binary is "installer-managed" iff `<bin_dir>/.mkit-installed-tag`
16//!   exists next to the (canonicalized) executable. Homebrew, cargo,
17//!   and other package-manager installs don't have it — for those we
18//!   refuse with channel-specific guidance instead of fighting the
19//!   package manager.
20//! * Receipts: `<bin_dir>/.mkit-installed-tag` plus the global
21//!   `$MKIT_STATE_DIR/installed-tag` (default `~/.local/state/mkit`).
22//!   Both are re-written after a successful swap, in the installer's
23//!   exact format (`vX.Y.Z\n`, atomic `.new` + rename), so installer
24//!   and updater stay interchangeable.
25//! * Downgrade policy mirrors the installer: `latest` never
26//!   downgrades; an explicit `--version` may only with
27//!   `--allow-downgrade`, loudly.
28//!
29//! There is deliberately **no background update check** — this command
30//! only ever runs when invoked. Network egress: `api.github.com` and
31//! the release-asset host, HTTPS only, with an https→http redirect
32//! downgrade refused (mirrors `mkit-transport-http`, #223).
33//!
34//! Environment:
35//! * `GH_TOKEN` / `GITHUB_TOKEN` — bearer for the GitHub API. Optional;
36//!   raises the unauthenticated GitHub API rate limit.
37//! * `MKIT_STATE_DIR` — receipt state dir override (installer parity).
38//! * `MKIT_SELF_UPDATE_API_BASE` — override the API base URL
39//!   (`https://api.github.com/repos/officialunofficial/mkit`). For
40//!   tests and mirrors.
41
42use std::io::{Read, Write};
43use std::path::{Path, PathBuf};
44use std::time::Duration;
45
46use clap::Parser;
47use mkit_core::hash;
48use sha2::Digest as _;
49
50use crate::clap_shim;
51use crate::cli::CLI_VERSION;
52use crate::exit;
53use crate::format::json_escape;
54
55/// Target triple this binary was built for — release archives are
56/// named `mkit-<version>-<triple>.tar.gz`. Emitted by `build.rs`.
57const TARGET_TRIPLE: &str = env!("MKIT_TARGET_TRIPLE");
58
59/// Default GitHub API base for release resolution.
60const DEFAULT_API_BASE: &str = "https://api.github.com/repos/officialunofficial/mkit";
61
62/// Read caps, defense-in-depth against a hostile or broken origin.
63const MAX_JSON_BYTES: u64 = 4 * 1024 * 1024;
64const MAX_SHA256_BYTES: u64 = 4 * 1024;
65const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
66/// Cap on the extracted binary (the archive is ~4 MB compressed today;
67/// 512 MB leaves room without letting a gzip bomb fill the disk).
68const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024;
69
70/// Maximum redirects; https→http downgrades are refused outright
71/// (mirrors mkit-transport-http #223).
72const MAX_REDIRECTS: usize = 5;
73
74/// Per-request timeout. The archive is a few MB; 120 s tolerates slow
75/// links without letting a stalled connection wedge the command.
76const REQUEST_TIMEOUT: Duration = Duration::from_mins(2);
77const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
78
79#[derive(Debug, Parser)]
80#[command(
81    name = "mkit self update",
82    about = "Update the mkit binary in place from a release."
83)]
84pub struct Opts {
85    /// Pin to a specific release tag (e.g. v0.4.0). Default: latest.
86    #[arg(long, value_name = "TAG")]
87    pub version: Option<String>,
88    /// Only report whether an update is available; change nothing.
89    #[arg(long)]
90    pub check: bool,
91    /// Allow an explicit `--version` pin to downgrade. Never applies
92    /// to `latest`.
93    #[arg(long = "allow-downgrade")]
94    pub allow_downgrade: bool,
95    /// Output format: `human` (default) or `json`.
96    #[arg(long, value_name = "FMT", default_value = "human")]
97    pub format: String,
98}
99
100#[must_use]
101pub fn run(args: &[String]) -> u8 {
102    match args.first().map(String::as_str) {
103        Some("update") => run_update_cli(&args[1..]),
104        Some("-h" | "--help") | None => {
105            let mut stdout = std::io::stdout().lock();
106            let _ = writeln!(
107                stdout,
108                "usage: mkit self update [--version <tag>] [--check] [--allow-downgrade] [--format human|json]"
109            );
110            exit::OK
111        }
112        Some(other) => super::error(
113            &format!("unknown self subcommand '{other}' (expected: update)"),
114            exit::USAGE,
115        ),
116    }
117}
118
119fn run_update_cli(args: &[String]) -> u8 {
120    let opts = match clap_shim::parse::<Opts>("mkit self update", args) {
121        Ok(o) => o,
122        Err(code) => return code,
123    };
124    if !matches!(opts.format.as_str(), "human" | "json") {
125        return super::error(
126            &format!("unknown --format '{}' (expected: human, json)", opts.format),
127            exit::USAGE,
128        );
129    }
130    if opts.allow_downgrade && opts.version.is_none() {
131        return super::error(
132            "--allow-downgrade requires an explicit --version pin",
133            exit::USAGE,
134        );
135    }
136
137    let env = match UpdateEnv::production() {
138        Ok(e) => e,
139        Err((msg, code)) => return super::error(&msg, code),
140    };
141    match run_update(&opts, &env) {
142        Ok(outcome) => {
143            emit_outcome(&outcome, &opts.format);
144            exit::OK
145        }
146        Err((msg, code)) => super::error(&msg, code),
147    }
148}
149
150/// Everything `run_update` touches outside pure computation, so the
151/// integration tests can point the whole flow at a mock server, a
152/// temp install dir, and a test trust root. Production wiring is
153/// `UpdateEnv::production` (private).
154#[derive(Debug)]
155pub struct UpdateEnv {
156    /// Release-API base, no trailing slash.
157    pub api_base: String,
158    /// Bearer token for the API + asset downloads.
159    pub token: Option<String>,
160    /// Canonicalized path of the binary to replace.
161    pub exe_path: PathBuf,
162    /// Receipt state dir (`installed-tag` lives here).
163    pub state_dir: PathBuf,
164    /// Version currently running (bare, e.g. `0.3.0`).
165    pub current_version: String,
166    /// Archive-name target triple.
167    pub target: String,
168}
169
170impl UpdateEnv {
171    fn production() -> Result<Self, (String, u8)> {
172        let exe_path = std::env::current_exe()
173            .and_then(|p| p.canonicalize())
174            .map_err(|e| (format!("resolve current executable: {e}"), exit::NOINPUT))?;
175        let state_dir =
176            match std::env::var_os("MKIT_STATE_DIR") {
177                Some(d) => PathBuf::from(d),
178                None => match std::env::var_os("HOME") {
179                    Some(h) => Path::new(&h).join(".local/state/mkit"),
180                    None => return Err((
181                        "HOME is not set; cannot locate the receipt state dir (set MKIT_STATE_DIR)"
182                            .to_owned(),
183                        exit::CONFIG_ERROR,
184                    )),
185                },
186            };
187        let api_base = std::env::var("MKIT_SELF_UPDATE_API_BASE")
188            .unwrap_or_else(|_| DEFAULT_API_BASE.to_owned());
189        let token = std::env::var("GH_TOKEN")
190            .or_else(|_| std::env::var("GITHUB_TOKEN"))
191            .ok()
192            .filter(|t| !t.is_empty());
193        Ok(Self {
194            api_base: api_base.trim_end_matches('/').to_owned(),
195            token,
196            exe_path,
197            state_dir,
198            current_version: CLI_VERSION.to_owned(),
199            target: TARGET_TRIPLE.to_owned(),
200        })
201    }
202}
203
204/// What happened, for output rendering.
205#[derive(Debug, PartialEq, Eq)]
206pub enum Outcome {
207    UpToDate {
208        current: String,
209    },
210    UpdateAvailable {
211        current: String,
212        latest: String,
213    },
214    Updated {
215        from: String,
216        to: String,
217        exe: PathBuf,
218    },
219}
220
221fn emit_outcome(outcome: &Outcome, format: &str) {
222    let mut stdout = std::io::stdout().lock();
223    match (outcome, format) {
224        (Outcome::UpToDate { current }, "json") => {
225            let _ = writeln!(
226                stdout,
227                "{{\"status\":\"up-to-date\",\"current\":\"{}\"}}",
228                json_escape(current)
229            );
230        }
231        (Outcome::UpToDate { current }, _) => {
232            let _ = writeln!(stdout, "mkit {current} is up to date");
233        }
234        (Outcome::UpdateAvailable { current, latest }, "json") => {
235            let _ = writeln!(
236                stdout,
237                "{{\"status\":\"update-available\",\"current\":\"{}\",\"latest\":\"{}\"}}",
238                json_escape(current),
239                json_escape(latest)
240            );
241        }
242        (Outcome::UpdateAvailable { current, latest }, _) => {
243            let _ = writeln!(
244                stdout,
245                "update available: mkit {current} → {latest} (run `mkit self update`)"
246            );
247        }
248        (Outcome::Updated { from, to, exe }, "json") => {
249            let _ = writeln!(
250                stdout,
251                "{{\"status\":\"updated\",\"from\":\"{}\",\"to\":\"{}\",\"exe\":\"{}\"}}",
252                json_escape(from),
253                json_escape(to),
254                json_escape(&exe.display().to_string())
255            );
256        }
257        (Outcome::Updated { from, to, .. }, _) => {
258            let _ = writeln!(stdout, "updated mkit {from} → {to}");
259        }
260    }
261}
262
263/// The full update flow. Everything before the swap is read-only.
264///
265/// # Errors
266/// `(message, exit_code)` for every failure mode; the caller renders it.
267#[allow(clippy::too_many_lines)] // linear resolve→verify→swap pipeline; splitting would obscure the ordering invariants
268pub fn run_update(opts: &Opts, env: &UpdateEnv) -> Result<Outcome, (String, u8)> {
269    if let Some(tag) = opts.version.as_deref() {
270        validate_tag(tag).map_err(|e| (e, exit::USAGE))?;
271    }
272
273    let client = http_client(env)?;
274
275    // --- Resolve the target release tag. -----------------------------
276    let resolved_from_latest = opts.version.is_none();
277    let target_tag = match opts.version.clone() {
278        Some(t) => t,
279        None => resolve_latest_tag(&client, env)?,
280    };
281    let target_bare = target_tag.trim_start_matches('v').to_owned();
282
283    // --- `--check` is receipt-independent: compare against the running
284    // binary's own version so it is useful under any install method. --
285    if opts.check {
286        return Ok(
287            match cmp_versions(&env.current_version, &target_bare)
288                .map_err(|e| (e, exit::DATAERR))?
289            {
290                std::cmp::Ordering::Less => Outcome::UpdateAvailable {
291                    current: format!("v{}", env.current_version),
292                    latest: target_tag,
293                },
294                _ => Outcome::UpToDate {
295                    current: format!("v{}", env.current_version),
296                },
297            },
298        );
299    }
300
301    // --- Management + receipts. --------------------------------------
302    let bin_dir = env
303        .exe_path
304        .parent()
305        .ok_or_else(|| {
306            (
307                "executable has no parent directory".to_owned(),
308                exit::NOINPUT,
309            )
310        })?
311        .to_path_buf();
312    let local_receipt = bin_dir.join(".mkit-installed-tag");
313    let global_receipt = env.state_dir.join("installed-tag");
314
315    let local_tag = read_receipt(&local_receipt);
316    let Some(local_tag) = local_tag else {
317        return Err((unmanaged_guidance(&env.exe_path), exit::UNAVAILABLE));
318    };
319    let global_tag = read_receipt(&global_receipt);
320
321    // Both receipts must agree when both exist (installer parity).
322    if let Some(g) = &global_tag
323        && g != &local_tag
324    {
325        return Err((
326            format!(
327                "installed-tag mismatch: {} says '{g}' but {} says '{local_tag}'. \
328                 Refusing to update. Resolve manually.",
329                global_receipt.display(),
330                local_receipt.display()
331            ),
332            exit::DATAERR,
333        ));
334    }
335
336    let installed_tag = local_tag;
337    let installed_bare = installed_tag.trim_start_matches('v').to_owned();
338    if installed_bare != env.current_version {
339        eprintln!(
340            "warning: receipt says {installed_tag} but this binary reports v{} — \
341             receipts may have been edited; using the receipt for downgrade checks",
342            env.current_version
343        );
344    }
345
346    // --- Downgrade / no-op policy (installer parity). -----------------
347    match cmp_versions(&target_bare, &installed_bare).map_err(|e| (e, exit::DATAERR))? {
348        std::cmp::Ordering::Equal => {
349            return Ok(Outcome::UpToDate {
350                current: installed_tag,
351            });
352        }
353        std::cmp::Ordering::Less if resolved_from_latest => {
354            return Err((
355                format!(
356                    "refusing to silently downgrade from {installed_tag} to {target_tag} via \
357                     'latest'. Pin --version {installed_tag} or newer, or delete {} and {}.",
358                    global_receipt.display(),
359                    local_receipt.display()
360                ),
361                exit::DATAERR,
362            ));
363        }
364        std::cmp::Ordering::Less if !opts.allow_downgrade => {
365            return Err((
366                format!(
367                    "{target_tag} is a DOWNGRADE from {installed_tag}; pass --allow-downgrade \
368                     to proceed anyway"
369                ),
370                exit::USAGE,
371            ));
372        }
373        std::cmp::Ordering::Less => {
374            eprintln!(
375                "warning: downgrading from {installed_tag} to {target_tag} (--allow-downgrade)"
376            );
377        }
378        std::cmp::Ordering::Greater => {}
379    }
380
381    // --- Install-dir hardening (installer parity): a group- or world-
382    // writable bin dir lets a local attacker race a replacement binary
383    // into place between rename and first execution. ------------------
384    refuse_lax_dir_perms(&bin_dir)?;
385
386    // --- Fetch release metadata + assets. ----------------------------
387    let release = fetch_release_by_tag(&client, env, &target_tag)?;
388    let archive_name = format!("mkit-{target_bare}-{}.tar.gz", env.target);
389
390    let archive_url = asset_url(&release, &archive_name).ok_or_else(|| {
391        (
392            format!("release {target_tag} has no prebuilt binary for {} ({archive_name} not among its assets)", env.target),
393            exit::UNAVAILABLE,
394        )
395    })?;
396
397    eprintln!("downloading mkit {target_tag} ({})...", env.target);
398    let archive_bytes = download(&client, env, &archive_url, MAX_ARCHIVE_BYTES)?;
399
400    // --- Verify. ------------------------------------------------------
401    // sha256 sidecar — same origin as the archive, so this is
402    // defense-in-depth rather than a strong authenticity guarantee;
403    // absence is tolerated, mismatch is not.
404    if let Some(sha_url) = asset_url(&release, &format!("{archive_name}.sha256")) {
405        let sha_body = download(&client, env, &sha_url, MAX_SHA256_BYTES)?;
406        verify_sha256_sidecar(&archive_bytes, &sha_body, &archive_name)
407            .map_err(|e| (e, exit::DATAERR))?;
408    }
409
410    // --- Extract + pre-swap validation. -------------------------------
411    let binary = extract_binary(
412        &archive_bytes,
413        &format!("mkit-{target_bare}-{}", env.target),
414    )
415    .map_err(|e| (e, exit::DATAERR))?;
416
417    let staged = stage_binary(&bin_dir, &binary)?;
418    if let Err(e) = check_staged_version(&staged, &target_bare) {
419        let _ = std::fs::remove_file(&staged);
420        return Err((e, exit::DATAERR));
421    }
422
423    // --- Swap + receipts. ----------------------------------------------
424    std::fs::rename(&staged, &env.exe_path).map_err(|e| {
425        let _ = std::fs::remove_file(&staged);
426        (
427            format!("replace {}: {e}", env.exe_path.display()),
428            exit::CANTCREAT,
429        )
430    })?;
431
432    // Receipt failures after a successful swap are warnings, not
433    // errors: the binary IS updated, and failing the command here
434    // would misreport that. The downgrade guard degrades gracefully.
435    for receipt in [&local_receipt, &global_receipt] {
436        if let Err(e) = write_receipt(receipt, &target_tag) {
437            eprintln!(
438                "warning: binary updated, but writing receipt {} failed: {e} — \
439                 the silent-downgrade guard is weakened until it is restored",
440                receipt.display()
441            );
442        }
443    }
444
445    Ok(Outcome::Updated {
446        from: installed_tag,
447        to: target_tag,
448        exe: env.exe_path.clone(),
449    })
450}
451
452// ------------------------------------------------------------ receipts
453
454fn read_receipt(path: &Path) -> Option<String> {
455    let s = std::fs::read_to_string(path).ok()?;
456    let t = s.trim();
457    if t.is_empty() {
458        None
459    } else {
460        Some(t.to_owned())
461    }
462}
463
464fn write_receipt(path: &Path, tag: &str) -> std::io::Result<()> {
465    if let Some(dir) = path.parent() {
466        std::fs::create_dir_all(dir)?;
467    }
468    let tmp = path.with_extension("new");
469    std::fs::write(&tmp, format!("{tag}\n"))?;
470    std::fs::rename(&tmp, path)
471}
472
473fn unmanaged_guidance(exe: &Path) -> String {
474    let p = exe.to_string_lossy();
475    let hint = if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
476        "this looks like a Homebrew install — run `brew upgrade mkit` instead"
477    } else if p.contains("/.cargo/bin/") {
478        "this looks like a cargo install — run `cargo install --locked mkit-cli` \
479         (or `cargo binstall mkit-cli`) instead"
480    } else {
481        "reinstall via `curl mkit.sh | sh` to adopt it (the installer writes the receipt)"
482    };
483    format!(
484        "this mkit binary ({p}) is not installer-managed (no .mkit-installed-tag receipt \
485         next to it); {hint}"
486    )
487}
488
489// ------------------------------------------------------------- versions
490
491/// Strict-semver release tag, mirroring release.yml's regex.
492fn validate_tag(tag: &str) -> Result<(), String> {
493    let err = || format!("tag '{tag}' is not strict semver (vMAJOR.MINOR.PATCH[-suffix])");
494    let rest = tag.strip_prefix('v').ok_or_else(err)?;
495    parse_version(rest).map(|_| ()).map_err(|_| err())
496}
497
498type Parsed = (u64, u64, u64, Option<Vec<PreSeg>>);
499
500#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
501enum PreSeg {
502    /// Numeric segments order before and below alphanumeric ones
503    /// (semver §11.4).
504    Num(u64),
505    Alpha(String),
506}
507
508fn parse_version(bare: &str) -> Result<Parsed, String> {
509    let (core, pre) = match bare.split_once('-') {
510        Some((c, p)) => (c, Some(p)),
511        None => (bare, None),
512    };
513    let mut nums = core.split('.');
514    let mut next_num = |what: &str| -> Result<u64, String> {
515        nums.next()
516            .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
517            .and_then(|p| p.parse().ok())
518            .ok_or_else(|| format!("bad {what} in version '{bare}'"))
519    };
520    let (major, minor, patch) = (next_num("major")?, next_num("minor")?, next_num("patch")?);
521    if nums.next().is_some() {
522        return Err(format!("version '{bare}' has more than three components"));
523    }
524    let pre = match pre {
525        None => None,
526        Some(p) => {
527            if p.is_empty() {
528                return Err(format!("version '{bare}' has an empty prerelease"));
529            }
530            let mut segs = Vec::new();
531            for s in p.split('.') {
532                if s.is_empty() || !s.bytes().all(|b| b.is_ascii_alphanumeric()) {
533                    return Err(format!("bad prerelease segment '{s}' in '{bare}'"));
534                }
535                segs.push(if s.bytes().all(|b| b.is_ascii_digit()) {
536                    PreSeg::Num(
537                        s.parse()
538                            .map_err(|_| format!("prerelease number overflow in '{bare}'"))?,
539                    )
540                } else {
541                    PreSeg::Alpha(s.to_owned())
542                });
543            }
544            Some(segs)
545        }
546    };
547    Ok((major, minor, patch, pre))
548}
549
550/// Semver ordering on bare versions (`0.3.0`, `1.0.0-rc.1`). A
551/// prerelease orders below its release (semver §11.3).
552fn cmp_versions(a: &str, b: &str) -> Result<std::cmp::Ordering, String> {
553    let (amaj, amin, apat, apre) = parse_version(a)?;
554    let (bmaj, bmin, bpat, bpre) = parse_version(b)?;
555    Ok((amaj, amin, apat)
556        .cmp(&(bmaj, bmin, bpat))
557        .then_with(|| match (apre, bpre) {
558            (None, None) => std::cmp::Ordering::Equal,
559            (None, Some(_)) => std::cmp::Ordering::Greater,
560            (Some(_), None) => std::cmp::Ordering::Less,
561            (Some(x), Some(y)) => x.cmp(&y),
562        }))
563}
564
565// ----------------------------------------------------------------- HTTP
566
567fn http_client(env: &UpdateEnv) -> Result<reqwest::blocking::Client, (String, u8)> {
568    // Refuse https→http redirect downgrades: a downgrade would move
569    // the bearer token onto a plaintext channel (mirrors
570    // mkit-transport-http #223).
571    let policy = reqwest::redirect::Policy::custom(|attempt| {
572        if attempt.previous().len() >= MAX_REDIRECTS {
573            return attempt.error("too many redirects");
574        }
575        if let Some(prev) = attempt.previous().last()
576            && prev.scheme() == "https"
577            && attempt.url().scheme() != "https"
578        {
579            return attempt.error("refusing redirect that downgrades https to a weaker scheme");
580        }
581        attempt.follow()
582    });
583    // The self-updater uses the OS trust store and the release checksum when present; it does not read MKIT_SSL_CA_FILE / http.sslCAInfo.
584    reqwest::blocking::Client::builder()
585        .user_agent(format!("mkit/{} (self-update)", env.current_version))
586        .redirect(policy)
587        .timeout(REQUEST_TIMEOUT)
588        .connect_timeout(CONNECT_TIMEOUT)
589        .build()
590        .map_err(|e| (format!("build http client: {e}"), exit::GENERAL_ERROR))
591}
592
593fn get(
594    client: &reqwest::blocking::Client,
595    env: &UpdateEnv,
596    url: &str,
597    accept: &str,
598    cap: u64,
599) -> Result<Vec<u8>, (String, u8)> {
600    let mut req = client.get(url).header("Accept", accept);
601    // GitHub's API version header is harmless on non-GitHub mirrors.
602    req = req.header("X-GitHub-Api-Version", "2022-11-28");
603    if let Some(t) = &env.token {
604        req = req.header("Authorization", format!("Bearer {t}"));
605    }
606    let resp = req
607        .send()
608        .map_err(|e| (format!("GET {url}: {}", error_chain(&e)), exit::TEMPFAIL))?;
609    let status = resp.status();
610    if status == reqwest::StatusCode::NOT_FOUND {
611        return Err((
612            format!(
613                "GET {url}: 404 — release or asset not found (for a private repo, set \
614                 GH_TOKEN)"
615            ),
616            exit::UNAVAILABLE,
617        ));
618    }
619    if !status.is_success() {
620        return Err((format!("GET {url}: HTTP {status}"), exit::TEMPFAIL));
621    }
622    let mut body = Vec::new();
623    resp.take(cap + 1)
624        .read_to_end(&mut body)
625        .map_err(|e| (format!("read {url}: {e}"), exit::TEMPFAIL))?;
626    if body.len() as u64 > cap {
627        return Err((
628            format!("response from {url} exceeds the {cap}-byte cap"),
629            exit::DATAERR,
630        ));
631    }
632    Ok(body)
633}
634
635/// Render an error with its full `source()` chain — reqwest's Display
636/// alone says only "error sending request", hiding the DNS/TLS/socket
637/// cause the user actually needs.
638fn error_chain(e: &dyn std::error::Error) -> String {
639    let mut out = e.to_string();
640    let mut cur = e.source();
641    while let Some(src) = cur {
642        out.push_str(": ");
643        out.push_str(&src.to_string());
644        cur = src.source();
645    }
646    out
647}
648
649fn get_json(
650    client: &reqwest::blocking::Client,
651    env: &UpdateEnv,
652    url: &str,
653) -> Result<serde_json::Value, (String, u8)> {
654    let body = get(
655        client,
656        env,
657        url,
658        "application/vnd.github+json",
659        MAX_JSON_BYTES,
660    )?;
661    serde_json::from_slice(&body).map_err(|e| (format!("parse {url}: {e}"), exit::PROTOCOL_ERROR))
662}
663
664fn resolve_latest_tag(
665    client: &reqwest::blocking::Client,
666    env: &UpdateEnv,
667) -> Result<String, (String, u8)> {
668    let v = get_json(client, env, &format!("{}/releases/latest", env.api_base))?;
669    let tag = v["tag_name"]
670        .as_str()
671        .ok_or_else(|| {
672            (
673                "releases/latest has no tag_name".to_owned(),
674                exit::PROTOCOL_ERROR,
675            )
676        })?
677        .to_owned();
678    validate_tag(&tag).map_err(|e| (format!("latest release: {e}"), exit::PROTOCOL_ERROR))?;
679    Ok(tag)
680}
681
682fn fetch_release_by_tag(
683    client: &reqwest::blocking::Client,
684    env: &UpdateEnv,
685    tag: &str,
686) -> Result<serde_json::Value, (String, u8)> {
687    get_json(
688        client,
689        env,
690        &format!("{}/releases/tags/{tag}", env.api_base),
691    )
692}
693
694/// The API `url` of the named asset (NOT `browser_download_url`): with
695/// `Accept: application/octet-stream` it serves the bytes for public
696/// AND token-authenticated private repos alike.
697fn asset_url(release: &serde_json::Value, name: &str) -> Option<String> {
698    release["assets"].as_array()?.iter().find_map(|a| {
699        (a["name"].as_str() == Some(name)).then(|| a["url"].as_str().map(str::to_owned))?
700    })
701}
702
703fn download(
704    client: &reqwest::blocking::Client,
705    env: &UpdateEnv,
706    url: &str,
707    cap: u64,
708) -> Result<Vec<u8>, (String, u8)> {
709    get(client, env, url, "application/octet-stream", cap)
710}
711
712// --------------------------------------------------------- verification
713
714fn verify_sha256_sidecar(archive: &[u8], sidecar: &[u8], archive_name: &str) -> Result<(), String> {
715    let text =
716        core::str::from_utf8(sidecar).map_err(|_| format!("{archive_name}.sha256 is not UTF-8"))?;
717    let expected = text
718        .split_whitespace()
719        .next()
720        .ok_or_else(|| format!("{archive_name}.sha256 is empty"))?
721        .to_ascii_lowercase();
722    let actual = hash::to_hex_bytes(&sha2::Sha256::digest(archive));
723    if actual == expected {
724        Ok(())
725    } else {
726        Err(format!(
727            "sha256 mismatch for {archive_name}: sidecar says {expected}, archive is {actual}"
728        ))
729    }
730}
731
732// ------------------------------------------------------ extract + swap
733
734/// Pull `<stage_dir>/mkit` out of the tar.gz. Only that one entry is
735/// ever extracted — no full unpack, so hostile archive members
736/// (traversal paths, symlinks, device nodes) are never materialized.
737fn extract_binary(archive: &[u8], stage_dir: &str) -> Result<Vec<u8>, String> {
738    let want = format!("{stage_dir}/mkit");
739    let gz = flate2::read::GzDecoder::new(archive);
740    let mut tar = tar::Archive::new(gz);
741    let entries = tar.entries().map_err(|e| format!("read archive: {e}"))?;
742    for entry in entries {
743        let entry = entry.map_err(|e| format!("read archive entry: {e}"))?;
744        let path = entry
745            .path()
746            .map_err(|e| format!("archive entry path: {e}"))?;
747        if path.as_os_str() != want.as_str() {
748            continue;
749        }
750        if !entry.header().entry_type().is_file() {
751            return Err(format!("archive member {want} is not a regular file"));
752        }
753        let mut buf = Vec::new();
754        entry
755            .take(MAX_BINARY_BYTES + 1)
756            .read_to_end(&mut buf)
757            .map_err(|e| format!("extract {want}: {e}"))?;
758        if buf.len() as u64 > MAX_BINARY_BYTES {
759            return Err(format!("{want} exceeds the {MAX_BINARY_BYTES}-byte cap"));
760        }
761        return Ok(buf);
762    }
763    Err(format!("archive has no {want} member"))
764}
765
766/// Refuse group- or world-writable bin dirs (installer parity — see
767/// install.sh's rationale: a lax dir lets a local attacker race a
768/// replacement binary in between rename and first execution).
769#[cfg(unix)]
770fn refuse_lax_dir_perms(dir: &Path) -> Result<(), (String, u8)> {
771    use std::os::unix::fs::MetadataExt as _;
772    let meta = std::fs::metadata(dir)
773        .map_err(|e| (format!("stat {}: {e}", dir.display()), exit::NOINPUT))?;
774    let mode = meta.mode() & 0o777;
775    if mode & 0o020 != 0 {
776        return Err((
777            format!(
778                "install dir {} is group-writable (mode {mode:o}); refusing to update — \
779                 tighten permissions: chmod g-w {}",
780                dir.display(),
781                dir.display()
782            ),
783            exit::NOPERM,
784        ));
785    }
786    if mode & 0o002 != 0 {
787        return Err((
788            format!(
789                "install dir {} is world-writable (mode {mode:o}); refusing to update — \
790                 tighten permissions: chmod o-w {}",
791                dir.display(),
792                dir.display()
793            ),
794            exit::NOPERM,
795        ));
796    }
797    Ok(())
798}
799
800#[cfg(not(unix))]
801fn refuse_lax_dir_perms(_dir: &Path) -> Result<(), (String, u8)> {
802    Ok(())
803}
804
805/// Write the new binary to a same-directory temp path (same filesystem
806/// ⇒ the final rename is atomic), owner-only perms, exec bit set.
807fn stage_binary(bin_dir: &Path, binary: &[u8]) -> Result<PathBuf, (String, u8)> {
808    let staged = bin_dir.join(format!(".mkit-self-update.{}", std::process::id()));
809    std::fs::write(&staged, binary)
810        .map_err(|e| (format!("stage {}: {e}", staged.display()), exit::CANTCREAT))?;
811    #[cfg(unix)]
812    {
813        use std::os::unix::fs::PermissionsExt as _;
814        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
815            let _ = std::fs::remove_file(&staged);
816            (format!("chmod {}: {e}", staged.display()), exit::CANTCREAT)
817        })?;
818    }
819    Ok(staged)
820}
821
822/// Run the staged binary's `version` and require the byte-exact
823/// contract output for the target version — a truncated download or a
824/// wrong-tag archive fails here, BEFORE the swap.
825fn check_staged_version(staged: &Path, target_bare: &str) -> Result<(), String> {
826    let out = std::process::Command::new(staged)
827        .arg("version")
828        .output()
829        .map_err(|e| format!("run staged binary {}: {e}", staged.display()))?;
830    let expected = format!("mkit {target_bare}\n");
831    let got = String::from_utf8_lossy(&out.stdout);
832    if !out.status.success() || got != expected {
833        return Err(format!(
834            "staged binary self-check failed: `version` printed {:?} (exit {:?}), expected {:?}",
835            got,
836            out.status.code(),
837            expected
838        ));
839    }
840    Ok(())
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    // ---- version ordering ----
848
849    #[test]
850    fn cmp_versions_basic() {
851        use std::cmp::Ordering::{Equal, Greater, Less};
852        assert_eq!(cmp_versions("0.3.0", "0.4.0").unwrap(), Less);
853        assert_eq!(cmp_versions("0.4.0", "0.4.0").unwrap(), Equal);
854        assert_eq!(cmp_versions("0.10.0", "0.9.9").unwrap(), Greater);
855        assert_eq!(cmp_versions("1.0.0-rc.1", "1.0.0").unwrap(), Less);
856        assert_eq!(cmp_versions("1.0.0-rc.2", "1.0.0-rc.10").unwrap(), Less);
857        assert_eq!(cmp_versions("1.0.0-alpha", "1.0.0-beta").unwrap(), Less);
858        // Numeric prerelease segments order below alphanumeric (semver §11.4.3).
859        assert_eq!(cmp_versions("1.0.0-1", "1.0.0-alpha").unwrap(), Less);
860    }
861
862    #[test]
863    fn parse_version_rejects_garbage() {
864        for bad in [
865            "1.2",
866            "1.2.3.4",
867            "1.2.x",
868            "01a.2.3",
869            "1.2.3-",
870            "1.2.3-a..b",
871            "",
872        ] {
873            assert!(parse_version(bad).is_err(), "{bad} should be rejected");
874        }
875    }
876
877    #[test]
878    fn validate_tag_matrix() {
879        assert!(validate_tag("v0.4.0").is_ok());
880        assert!(validate_tag("v1.2.3-rc.1").is_ok());
881        assert!(validate_tag("0.4.0").is_err());
882        assert!(validate_tag("v1.2").is_err());
883    }
884
885    // ---- receipts ----
886
887    fn tmp_dir(name: &str) -> PathBuf {
888        let d =
889            std::env::temp_dir().join(format!("mkit-self-update-{name}-{}", std::process::id()));
890        let _ = std::fs::remove_dir_all(&d);
891        std::fs::create_dir_all(&d).unwrap();
892        d
893    }
894
895    #[test]
896    fn receipt_roundtrip() {
897        let d = tmp_dir("receipt");
898        let p = d.join("installed-tag");
899        write_receipt(&p, "v0.4.0").unwrap();
900        assert_eq!(read_receipt(&p).as_deref(), Some("v0.4.0"));
901        assert_eq!(std::fs::read_to_string(&p).unwrap(), "v0.4.0\n");
902    }
903
904    #[test]
905    fn read_receipt_missing_or_empty_is_none() {
906        let d = tmp_dir("receipt-empty");
907        assert_eq!(read_receipt(&d.join("nope")), None);
908        std::fs::write(d.join("empty"), "\n").unwrap();
909        assert_eq!(read_receipt(&d.join("empty")), None);
910    }
911
912    // ---- guidance ----
913
914    #[test]
915    fn unmanaged_guidance_recognizes_channels() {
916        let brew = unmanaged_guidance(Path::new("/opt/homebrew/Cellar/mkit/0.3.0/bin/mkit"));
917        assert!(brew.contains("brew upgrade"), "{brew}");
918        let cargo = unmanaged_guidance(Path::new("/home/u/.cargo/bin/mkit"));
919        assert!(cargo.contains("cargo install --locked mkit-cli"), "{cargo}");
920        let other = unmanaged_guidance(Path::new("/usr/local/bin/mkit"));
921        assert!(other.contains("curl mkit.sh"), "{other}");
922    }
923
924    // ---- sha256 sidecar ----
925
926    #[test]
927    fn sha256_sidecar_matches() {
928        let body = b"archive bytes";
929        let hex = hash::to_hex_bytes(&sha2::Sha256::digest(body));
930        let sidecar = format!("{hex}  mkit-0.4.0-x.tar.gz\n");
931        verify_sha256_sidecar(body, sidecar.as_bytes(), "mkit-0.4.0-x.tar.gz").unwrap();
932        let e = verify_sha256_sidecar(b"tampered", sidecar.as_bytes(), "mkit-0.4.0-x.tar.gz")
933            .unwrap_err();
934        assert!(e.contains("sha256 mismatch"), "{e}");
935    }
936
937    // ---- extraction ----
938
939    fn tgz_with(entries: &[(&str, &[u8])]) -> Vec<u8> {
940        let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
941            Vec::new(),
942            flate2::Compression::fast(),
943        ));
944        for (path, body) in entries {
945            let mut h = tar::Header::new_gnu();
946            h.set_size(body.len() as u64);
947            h.set_mode(0o755);
948            h.set_cksum();
949            builder.append_data(&mut h, path, *body).unwrap();
950        }
951        builder.into_inner().unwrap().finish().unwrap()
952    }
953
954    #[test]
955    fn extract_binary_finds_only_the_binary() {
956        let tgz = tgz_with(&[
957            ("mkit-0.4.0-x/README.md", b"readme"),
958            ("mkit-0.4.0-x/mkit", b"#!/bin/sh\necho hi\n"),
959        ]);
960        let bin = extract_binary(&tgz, "mkit-0.4.0-x").unwrap();
961        assert_eq!(bin, b"#!/bin/sh\necho hi\n");
962    }
963
964    #[test]
965    fn extract_binary_missing_member_errors() {
966        let tgz = tgz_with(&[("mkit-0.4.0-x/README.md", b"readme")]);
967        let e = extract_binary(&tgz, "mkit-0.4.0-x").unwrap_err();
968        assert!(e.contains("no mkit-0.4.0-x/mkit member"), "{e}");
969    }
970
971    // ---- staged-binary check + perms (unix) ----
972
973    #[cfg(unix)]
974    #[test]
975    fn staged_version_check_enforces_contract() {
976        let d = tmp_dir("staged");
977        let ok = stage_binary(&d, b"#!/bin/sh\nprintf 'mkit 9.9.9\\n'\n").unwrap();
978        check_staged_version(&ok, "9.9.9").unwrap();
979        let e = check_staged_version(&ok, "9.9.8").unwrap_err();
980        assert!(e.contains("self-check failed"), "{e}");
981    }
982
983    #[cfg(unix)]
984    #[test]
985    fn lax_dir_perms_refused() {
986        use std::os::unix::fs::PermissionsExt as _;
987        let d = tmp_dir("perms");
988        std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o777)).unwrap();
989        let (msg, code) = refuse_lax_dir_perms(&d).unwrap_err();
990        assert_eq!(code, exit::NOPERM);
991        assert!(msg.contains("writable"), "{msg}");
992        std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap();
993        refuse_lax_dir_perms(&d).unwrap();
994    }
995}