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