1use 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
55const TARGET_TRIPLE: &str = env!("MKIT_TARGET_TRIPLE");
58
59const DEFAULT_API_BASE: &str = "https://api.github.com/repos/officialunofficial/mkit";
61
62const MAX_JSON_BYTES: u64 = 4 * 1024 * 1024;
64const MAX_SHA256_BYTES: u64 = 4 * 1024;
65const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
66const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024;
69
70const MAX_REDIRECTS: usize = 5;
73
74const 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 #[arg(long, value_name = "TAG")]
87 pub version: Option<String>,
88 #[arg(long)]
90 pub check: bool,
91 #[arg(long = "allow-downgrade")]
94 pub allow_downgrade: bool,
95 #[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#[derive(Debug)]
155pub struct UpdateEnv {
156 pub api_base: String,
158 pub token: Option<String>,
160 pub exe_path: PathBuf,
162 pub state_dir: PathBuf,
164 pub current_version: String,
166 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#[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#[allow(clippy::too_many_lines)] pub 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 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 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 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 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 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 refuse_lax_dir_perms(&bin_dir)?;
385
386 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 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 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 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 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
452fn 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
489fn 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 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
550fn 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
565fn http_client(env: &UpdateEnv) -> Result<reqwest::blocking::Client, (String, u8)> {
568 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 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 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
635fn 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
694fn 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
712fn 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
732fn 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#[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
805fn 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
822fn 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 #[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 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 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 #[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 #[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 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 #[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}