1use 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
60const TARGET_TRIPLE: &str = env!("MKIT_TARGET_TRIPLE");
63
64const DEFAULT_API_BASE: &str = "https://api.github.com/repos/officialunofficial/mkit";
66
67const MAX_JSON_BYTES: u64 = 4 * 1024 * 1024;
69const MAX_SHA256_BYTES: u64 = 4 * 1024;
70const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
71const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024;
74
75const MAX_REDIRECTS: usize = 5;
78
79const 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 #[arg(long, value_name = "TAG")]
92 pub version: Option<String>,
93 #[arg(long)]
95 pub check: bool,
96 #[arg(long = "allow-downgrade")]
99 pub allow_downgrade: bool,
100 #[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#[derive(Debug)]
168pub struct UpdateEnv {
169 pub api_base: String,
171 pub token: Option<String>,
173 pub exe_path: PathBuf,
175 pub state_dir: PathBuf,
177 pub current_version: String,
179 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#[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#[allow(clippy::too_many_lines)] pub 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 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 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 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 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 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 refuse_lax_dir_perms(&bin_dir)?;
398
399 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 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 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 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 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
465fn 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
502fn 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 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
563fn 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
578fn http_client(env: &UpdateEnv) -> Result<reqwest::blocking::Client, (String, u8)> {
581 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 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
647fn 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
706fn 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
724fn 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
744fn 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#[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
817fn 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
834fn 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 #[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 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 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 #[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 #[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 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 #[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}