1use mj_core::hex::lower_hex;
14use std::ffi::OsString;
15use std::io::{self, BufRead, Cursor, IsTerminal, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::time::Duration;
19
20use anyhow::{Context, Result, bail, ensure};
21use flate2::read::GzDecoder;
22use semver::Version;
23use serde::Deserialize;
24use sha2::{Digest, Sha256};
25
26const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/BrokkAi/mjolnir/releases/latest";
27const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@brokkai%2Fmjolnir/latest";
28const HOMEBREW_FORMULA_URL: &str =
29 "https://raw.githubusercontent.com/BrokkAi/homebrew-tap/main/Formula/mjolnir.rb";
30const BIN_NAME: &str = "mj";
31const WINDOWS_BIN_NAME: &str = "mj.exe";
32const VOICE_WORKER_NAME: &str = "mj-voice-worker";
33const NPM_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPM";
34const NPX_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_NPX";
35const HOMEBREW_MANAGED_ENV: &str = "MJOLNIR_MANAGED_BY_HOMEBREW";
36const NO_UPDATE_CHECK_ENV: &str = "MJOLNIR_NO_UPDATE_CHECK";
37
38#[derive(Debug, Clone)]
41struct UpdateSources {
42 latest_release: String,
43 npm_latest: String,
44 homebrew_formula: String,
45 cargo_index: String,
46}
47
48impl Default for UpdateSources {
49 fn default() -> Self {
50 Self {
51 latest_release: LATEST_RELEASE_URL.to_string(),
52 npm_latest: NPM_LATEST_URL.to_string(),
53 homebrew_formula: HOMEBREW_FORMULA_URL.to_string(),
54 cargo_index: CARGO_INDEX_URL.to_string(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum InstallMethod {
65 Npm,
66 Npx,
67 Homebrew,
68 Cargo { voice_worker: bool },
69 Direct,
70}
71
72impl InstallMethod {
73 fn current() -> Self {
77 Self::detect(
78 |name| std::env::var_os(name),
79 std::env::current_exe().ok().as_deref(),
80 )
81 }
82
83 fn detect<F>(env: F, exe: Option<&Path>) -> Self
84 where
85 F: Fn(&str) -> Option<OsString>,
86 {
87 if env(NPX_MANAGED_ENV).is_some() {
88 return Self::Npx;
89 }
90 if env(NPM_MANAGED_ENV).is_some() {
91 return Self::Npm;
92 }
93 if env(HOMEBREW_MANAGED_ENV).is_some() {
94 return Self::Homebrew;
95 }
96 exe.map_or(Self::Direct, |exe| {
97 install_method_from_exe(exe, env!("CARGO_PKG_VERSION"))
98 })
99 }
100
101 fn update_command(&self) -> Option<String> {
105 match self {
106 Self::Npm => Some("npm install -g @brokkai/mjolnir@latest".to_string()),
107 Self::Npx => Some("npx -y @brokkai/mjolnir@latest".to_string()),
108 Self::Homebrew => Some("brew upgrade --formula brokkai/tap/mjolnir".to_string()),
109 Self::Cargo { voice_worker: true } => {
110 Some("cargo install --locked brokk-mjolnir brokk-mj-voice-worker".to_string())
111 }
112 Self::Cargo {
113 voice_worker: false,
114 } => Some("cargo install --locked brokk-mjolnir".to_string()),
115 Self::Direct => None,
116 }
117 }
118
119 fn channel_name(&self) -> &'static str {
120 match self {
121 Self::Npm | Self::Npx => "npm",
122 Self::Homebrew => "Homebrew",
123 Self::Cargo { .. } => "crates.io",
124 Self::Direct => "GitHub Releases",
125 }
126 }
127}
128
129fn install_method_from_exe(exe_path: &Path, current_version: &str) -> InstallMethod {
130 if is_homebrew_executable(exe_path) {
131 return InstallMethod::Homebrew;
132 }
133 if is_npm_bundle_executable(exe_path) {
134 return InstallMethod::Npm;
135 }
136
137 let Some(install_root) = cargo_install_root(exe_path, current_version) else {
138 return InstallMethod::Direct;
139 };
140 InstallMethod::Cargo {
141 voice_worker: cargo_install_recorded(
142 &install_root,
143 "brokk-mj-voice-worker",
144 None,
145 VOICE_WORKER_NAME,
146 ),
147 }
148}
149
150fn is_homebrew_executable(exe_path: &Path) -> bool {
151 let components = path_text_components(exe_path);
152 components
153 .windows(2)
154 .any(|pair| pair == ["Cellar", "mjolnir"])
155}
156
157fn is_npm_bundle_executable(exe_path: &Path) -> bool {
162 let components = path_text_components(exe_path);
163 components
164 .windows(2)
165 .any(|pair| pair == ["node_modules", "@brokkai"])
166}
167
168fn path_text_components(exe_path: &Path) -> Vec<&str> {
169 exe_path
170 .components()
171 .filter_map(|component| component.as_os_str().to_str())
172 .collect()
173}
174
175fn cargo_install_root(exe_path: &Path, current_version: &str) -> Option<PathBuf> {
176 let canonical_exe = exe_path.canonicalize().ok()?;
177 let bin_dir = canonical_exe.parent()?;
178 if bin_dir.file_name()? != "bin" {
179 return None;
180 }
181 let install_root = bin_dir.parent()?;
182 cargo_install_recorded(
183 install_root,
184 "brokk-mjolnir",
185 Some(current_version),
186 BIN_NAME,
187 )
188 .then(|| install_root.to_path_buf())
189}
190
191fn cargo_install_recorded(
192 install_root: &Path,
193 package: &str,
194 version: Option<&str>,
195 binary: &str,
196) -> bool {
197 cargo_json_install_recorded(install_root, package, version, binary)
198 || cargo_toml_install_recorded(install_root, package, version, binary)
199}
200
201fn cargo_json_install_recorded(
202 install_root: &Path,
203 package: &str,
204 version: Option<&str>,
205 binary: &str,
206) -> bool {
207 let Ok(raw) = std::fs::read_to_string(install_root.join(".crates2.json")) else {
208 return false;
209 };
210 let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&raw) else {
211 return false;
212 };
213 manifest
214 .get("installs")
215 .and_then(serde_json::Value::as_object)
216 .is_some_and(|installs| {
217 installs.iter().any(|(source, record)| {
218 cargo_source_matches(source, package, version)
219 && record
220 .get("bins")
221 .and_then(serde_json::Value::as_array)
222 .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
223 })
224 })
225}
226
227fn cargo_toml_install_recorded(
228 install_root: &Path,
229 package: &str,
230 version: Option<&str>,
231 binary: &str,
232) -> bool {
233 let Ok(raw) = std::fs::read_to_string(install_root.join(".crates.toml")) else {
234 return false;
235 };
236 let Ok(manifest) = raw.parse::<toml::Value>() else {
237 return false;
238 };
239 manifest
240 .get("v1")
241 .and_then(toml::Value::as_table)
242 .is_some_and(|installs| {
243 installs.iter().any(|(source, bins)| {
244 cargo_source_matches(source, package, version)
245 && bins
246 .as_array()
247 .is_some_and(|bins| bins.iter().any(|name| name.as_str() == Some(binary)))
248 })
249 })
250}
251
252fn cargo_source_matches(source: &str, package: &str, version: Option<&str>) -> bool {
253 let Some(rest) = source
254 .strip_prefix(package)
255 .and_then(|rest| rest.strip_prefix(' '))
256 else {
257 return false;
258 };
259 let Some(recorded_version) = rest.split_whitespace().next() else {
260 return false;
261 };
262 version.is_none_or(|expected| recorded_version == expected)
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
268struct UpdateInfo {
269 version: Version,
270 tag: String,
271 asset: ReleaseAsset,
272 checksum_asset: ReleaseAsset,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
279enum AvailableUpdate {
280 Managed {
281 version: Version,
282 method: InstallMethod,
283 },
284 Direct(UpdateInfo),
285}
286
287#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
288struct GitHubRelease {
289 tag_name: String,
290 #[serde(default)]
291 assets: Vec<ReleaseAsset>,
292}
293
294#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
295struct ReleaseAsset {
296 name: String,
297 browser_download_url: String,
298}
299
300#[derive(Debug, Deserialize)]
301struct NpmLatest {
302 version: String,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306struct Platform {
307 os_family: &'static str,
308 arch: &'static str,
309 rust_target: String,
310}
311
312async fn latest_update(
315 sources: &UpdateSources,
316 method: &InstallMethod,
317) -> Result<Option<AvailableUpdate>> {
318 let current = parse_version(env!("CARGO_PKG_VERSION"))
319 .with_context(|| format!("parse current version {}", env!("CARGO_PKG_VERSION")))?;
320 if *method == InstallMethod::Direct {
321 let release = fetch_latest_release(sources)
322 .await
323 .context("fetch latest mj release")?;
324 return update_info_from_release(&release, ¤t, ¤t_platform()?)
325 .map(|update| update.map(AvailableUpdate::Direct));
326 }
327
328 let latest = fetch_latest_managed_version(sources, method).await?;
329 Ok((latest > current).then(|| AvailableUpdate::Managed {
330 version: latest,
331 method: method.clone(),
332 }))
333}
334
335async fn fetch_latest_release(sources: &UpdateSources) -> Result<GitHubRelease> {
336 let body = fetch_text(&sources.latest_release).await?;
337 serde_json::from_str(&body).context("parse release body")
338}
339
340async fn fetch_latest_managed_version(
341 sources: &UpdateSources,
342 method: &InstallMethod,
343) -> Result<Version> {
344 match method {
345 InstallMethod::Npm | InstallMethod::Npx => {
346 let body = fetch_text(&sources.npm_latest)
347 .await
348 .context("fetch latest npm package")?;
349 let latest: NpmLatest = serde_json::from_str(&body).context("parse npm metadata")?;
350 parse_version(&latest.version).context("parse latest npm version")
351 }
352 InstallMethod::Homebrew => {
353 let body = fetch_text(&sources.homebrew_formula)
354 .await
355 .context("fetch Homebrew formula")?;
356 parse_homebrew_formula_version(&body)
357 }
358 InstallMethod::Cargo { .. } => {
359 let body = fetch_text(&sources.cargo_index)
363 .await
364 .context("fetch crates.io index entry")?;
365 parse_cargo_index_version(&body)
366 }
367 InstallMethod::Direct => anyhow::bail!("direct installs use GitHub release metadata"),
368 }
369}
370
371const CARGO_INDEX_URL: &str = "https://index.crates.io/br/ok/brokk-mjolnir";
372
373async fn fetch_text(url: &str) -> Result<String> {
374 let client = reqwest::Client::builder()
375 .timeout(Duration::from_secs(5))
376 .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
377 .build()
378 .context("build http client")?;
379 let resp = client
380 .get(url)
381 .send()
382 .await
383 .with_context(|| format!("GET {url}"))?;
384 let status = resp.status();
385 if !status.is_success() {
386 anyhow::bail!("GET {url}: HTTP {status}");
387 }
388 resp.text().await.with_context(|| format!("read {url}"))
389}
390
391fn parse_homebrew_formula_version(formula: &str) -> Result<Version> {
392 let raw = formula
393 .lines()
394 .map(str::trim)
395 .find_map(|line| line.strip_prefix("version \"")?.strip_suffix('"'))
396 .ok_or_else(|| anyhow::anyhow!("Homebrew formula has no version"))?;
397 parse_version(raw).context("parse Homebrew formula version")
398}
399
400fn parse_cargo_index_version(index: &str) -> Result<Version> {
401 let mut latest: Option<Version> = None;
402 for line in index.lines().filter(|line| !line.trim().is_empty()) {
403 let entry: CargoIndexEntry =
404 serde_json::from_str(line).context("parse crates.io index entry")?;
405 if entry.yanked {
406 continue;
407 }
408 let version = parse_version(&entry.vers).context("parse crates.io package version")?;
409 if latest.as_ref().is_none_or(|current| version > *current) {
410 latest = Some(version);
411 }
412 }
413 latest.ok_or_else(|| anyhow::anyhow!("crates.io index has no published versions"))
414}
415
416#[derive(Debug, Deserialize)]
417struct CargoIndexEntry {
418 vers: String,
419 #[serde(default)]
420 yanked: bool,
421}
422
423fn update_info_from_release(
424 release: &GitHubRelease,
425 current: &Version,
426 platform: &Platform,
427) -> Result<Option<UpdateInfo>> {
428 let latest = parse_version(&release.tag_name)
429 .with_context(|| format!("parse release tag {}", release.tag_name))?;
430 if latest <= *current {
431 return Ok(None);
432 }
433
434 let asset = select_mj_asset(&release.assets, platform)
435 .with_context(|| format!("find mj asset for {}/{}", platform.os_family, platform.arch))?;
436 let checksum_name = format!("{}.sha256", asset.name);
437 let checksum_asset = release
438 .assets
439 .iter()
440 .find(|candidate| candidate.name == checksum_name)
441 .cloned()
442 .ok_or_else(|| {
443 anyhow::anyhow!(
444 "release {} is missing required checksum asset {}",
445 release.tag_name,
446 checksum_name
447 )
448 })?;
449
450 Ok(Some(UpdateInfo {
451 version: latest,
452 tag: release.tag_name.clone(),
453 asset,
454 checksum_asset,
455 }))
456}
457
458fn select_mj_asset(assets: &[ReleaseAsset], platform: &Platform) -> Result<ReleaseAsset> {
459 let target_suffix = format!(
460 "-{}{}",
461 platform.rust_target,
462 platform_archive_ext(platform)
463 );
464 if platform.os_family == "macos"
465 && let Some(asset) = assets.iter().find(|asset| {
466 is_mj_archive(&asset.name) && asset.name.ends_with("-universal-apple-darwin.tar.gz")
467 })
468 {
469 return Ok(asset.clone());
470 }
471 assets
472 .iter()
473 .find(|asset| is_mj_archive(&asset.name) && asset.name.ends_with(&target_suffix))
474 .cloned()
475 .ok_or_else(|| {
476 anyhow::anyhow!(
477 "no mj archive found for target {}; available assets: {}",
478 platform.rust_target,
479 assets
480 .iter()
481 .filter(|asset| !asset.name.ends_with(".sha256"))
482 .map(|asset| asset.name.as_str())
483 .collect::<Vec<_>>()
484 .join(", ")
485 )
486 })
487}
488
489fn is_mj_archive(name: &str) -> bool {
490 name.starts_with("brokk-mjolnir-") && (name.ends_with(".tar.gz") || name.ends_with(".zip"))
491}
492
493fn platform_archive_ext(platform: &Platform) -> &'static str {
494 if platform.os_family == "windows" {
495 ".zip"
496 } else {
497 ".tar.gz"
498 }
499}
500
501fn current_platform() -> Result<Platform> {
502 let arch = match std::env::consts::ARCH {
503 "x86_64" => "x86_64",
504 "aarch64" | "arm64" => "aarch64",
505 other => anyhow::bail!("unsupported CPU architecture: {other}"),
506 };
507 let (os_family, rust_os) = match std::env::consts::OS {
508 "android" => ("android", "linux-android"),
509 "macos" => ("macos", "apple-darwin"),
510 "linux" => ("linux", "unknown-linux-gnu"),
511 "windows" => ("windows", "pc-windows-msvc"),
512 other => anyhow::bail!("unsupported OS: {other}"),
513 };
514
515 Ok(Platform {
516 os_family,
517 arch,
518 rust_target: format!("{arch}-{rust_os}"),
519 })
520}
521
522fn parse_version(raw: &str) -> Result<Version> {
523 Version::parse(raw.trim_start_matches('v')).with_context(|| format!("parse version {raw}"))
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
529pub enum StartupUpdateOutcome {
530 Skipped,
533 UpToDate,
534 Notified,
537 Declined,
538}
539
540pub async fn check_prompt_and_apply() -> StartupUpdateOutcome {
557 if cfg!(windows)
561 || cfg!(debug_assertions)
562 || !io::stdin().is_terminal()
563 || !io::stdout().is_terminal()
564 || std::env::var_os(NO_UPDATE_CHECK_ENV).is_some()
565 {
566 return StartupUpdateOutcome::Skipped;
567 }
568
569 let method = InstallMethod::current();
570
571 let update = match latest_update(&UpdateSources::default(), &method).await {
572 Ok(Some(update)) => update,
573 Ok(None) => return StartupUpdateOutcome::UpToDate,
574 Err(error) => {
575 eprintln!("mj: update check failed: {error:#}");
577 return StartupUpdateOutcome::Skipped;
578 }
579 };
580
581 match update {
582 AvailableUpdate::Direct(update) => {
583 if !prompt_for_update(&update.version, &InstallMethod::Direct).unwrap_or(false) {
584 return StartupUpdateOutcome::Declined;
585 }
586 if let Err(error) = download_apply_and_restart(&update).await {
587 eprintln!("mj: upgrade failed: {error:#}");
588 eprintln!("mj: continuing with {}", env!("CARGO_PKG_VERSION"));
589 }
590 StartupUpdateOutcome::Skipped
593 }
594 AvailableUpdate::Managed { version, method } => match method {
595 InstallMethod::Npm | InstallMethod::Homebrew => {
596 if !prompt_for_update(&version, &method).unwrap_or(false) {
597 return StartupUpdateOutcome::Declined;
598 }
599 let upgraded =
600 run_managed_upgrade(&version, &method).and_then(restart_current_process);
601 if let Err(error) = upgraded {
602 eprintln!("mj: upgrade failed: {error:#}");
603 eprintln!("mj: continuing with {}", env!("CARGO_PKG_VERSION"));
604 }
605 StartupUpdateOutcome::Skipped
606 }
607 InstallMethod::Npx | InstallMethod::Cargo { .. } => {
608 let notice = managed_update_notice(&version, &method, env!("CARGO_PKG_VERSION"))
609 .expect("notice-only channels have an update command");
610 println!("{notice}");
611 StartupUpdateOutcome::Notified
612 }
613 InstallMethod::Direct => {
614 unreachable!("direct installs never report managed updates")
615 }
616 },
617 }
618}
619
620fn prompt_for_update(version: &Version, method: &InstallMethod) -> Result<bool> {
621 print!(
622 "mj {version} is available through {}; current version is {}. Upgrade now? [Y/n] ",
623 method.channel_name(),
624 env!("CARGO_PKG_VERSION")
625 );
626 io::stdout().flush().context("flush update prompt")?;
627
628 read_update_answer(&mut io::stdin().lock())
629}
630
631fn read_update_answer(input: &mut impl BufRead) -> Result<bool> {
632 let mut answer = String::new();
633 let bytes_read = input
634 .read_line(&mut answer)
635 .context("read update prompt answer")?;
636 Ok(bytes_read != 0 && prompt_answer_is_yes(&answer))
637}
638
639fn prompt_answer_is_yes(answer: &str) -> bool {
641 matches!(answer.trim(), "" | "y" | "Y" | "yes" | "YES")
642}
643
644fn managed_update_notice(
645 version: &Version,
646 method: &InstallMethod,
647 current_version: &str,
648) -> Option<String> {
649 Some(format!(
650 "mj {version} is available through {}; current version is {current_version}. Run: {}",
651 method.channel_name(),
652 method.update_command()?
653 ))
654}
655
656fn npm_upgrade_command() -> Command {
657 let mut command = Command::new("npm");
658 command.args(["install", "-g", "@brokkai/mjolnir@latest"]);
659 command
660}
661
662fn brew_update_command() -> Command {
663 let mut command = Command::new("brew");
664 command.arg("update");
665 command
666}
667
668fn brew_upgrade_command() -> Command {
669 let mut command = Command::new("brew");
670 command.args(["upgrade", "--formula", "brokkai/tap/mjolnir"]);
673 command
674}
675
676fn run_managed_upgrade(version: &Version, method: &InstallMethod) -> Result<RestartTarget> {
680 let current_exe = std::env::current_exe().context("resolve current executable")?;
683 let restart = managed_restart_target(method, ¤t_exe)?;
684 match method {
685 InstallMethod::Npm => {
686 println!("mj: running npm install -g @brokkai/mjolnir@latest");
687 let status = mj_core::subprocess::run_inherited(&mut npm_upgrade_command())
688 .context("run npm install -g @brokkai/mjolnir@latest")?;
689 ensure!(
690 status.success(),
691 "npm install exited with {status}; npm usually explains why above"
692 );
693 }
694 InstallMethod::Homebrew => {
695 println!("mj: running brew update");
700 let status = mj_core::subprocess::run_inherited(&mut brew_update_command())
701 .context("run brew update")?;
702 ensure!(status.success(), "brew update exited with {status}");
703 println!("mj: running brew upgrade --formula brokkai/tap/mjolnir");
704 let status = mj_core::subprocess::run_inherited(&mut brew_upgrade_command())
705 .context("run brew upgrade --formula brokkai/tap/mjolnir")?;
706 ensure!(status.success(), "brew upgrade exited with {status}");
707 }
708 other => bail!("{other:?} installs do not support delegated upgrades"),
709 }
710 println!("mj: upgraded to {version}; restarting");
711 Ok(restart)
712}
713
714#[derive(Debug, Clone, PartialEq, Eq)]
716enum RestartTarget {
717 SameExe(PathBuf),
721 Wrapper,
726}
727
728fn managed_restart_target(method: &InstallMethod, current_exe: &Path) -> Result<RestartTarget> {
729 match method {
730 InstallMethod::Npm => Ok(RestartTarget::SameExe(current_exe.to_path_buf())),
731 InstallMethod::Homebrew => Ok(RestartTarget::Wrapper),
732 other => bail!("{other:?} installs do not support delegated upgrades"),
733 }
734}
735
736#[cfg(unix)]
737fn restart_current_process(target: RestartTarget) -> Result<()> {
738 use std::os::unix::process::CommandExt;
739
740 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
741 let mut command = match target {
742 RestartTarget::SameExe(exe) => Command::new(exe),
743 RestartTarget::Wrapper => Command::new("mj"),
744 };
745 let error = command.args(args).exec();
746 Err(error).context("exec replacement mj")
747}
748
749#[cfg(not(unix))]
750fn restart_current_process(_target: RestartTarget) -> Result<()> {
751 bail!("automatic restart is only supported on Unix platforms")
752}
753
754async fn download_apply_and_restart(update: &UpdateInfo) -> Result<()> {
755 println!("mj: downloading {} ({})", update.tag, update.asset.name);
756 let archive = download_bytes(&update.asset.browser_download_url)
757 .await
758 .with_context(|| format!("download {}", update.asset.name))?;
759 verify_checksum(update, &archive).await?;
760
761 let current_exe = std::env::current_exe().context("resolve current executable")?;
762 let replacement = install_release_archive(¤t_exe, &update.asset.name, &archive)
763 .context("install release bundle")?;
764
765 println!("mj: upgraded to {}; restarting", update.tag);
766 restart_current_process(RestartTarget::SameExe(replacement))
767}
768
769async fn download_bytes(url: &str) -> Result<Vec<u8>> {
770 let client = reqwest::Client::builder()
771 .timeout(Duration::from_secs(120))
772 .user_agent(concat!("mj/", env!("CARGO_PKG_VERSION")))
773 .build()
774 .context("build http client")?;
775 let resp = client
776 .get(url)
777 .send()
778 .await
779 .with_context(|| format!("GET {url}"))?;
780 let status = resp.status();
781 if !status.is_success() {
782 anyhow::bail!("GET {url}: HTTP {status}");
783 }
784 resp.bytes()
785 .await
786 .map(|bytes| bytes.to_vec())
787 .context("read response body")
788}
789
790async fn verify_checksum(update: &UpdateInfo, archive: &[u8]) -> Result<()> {
791 let body = download_bytes(&update.checksum_asset.browser_download_url)
792 .await
793 .with_context(|| format!("download {}", update.checksum_asset.name))?;
794 let body = String::from_utf8(body).context("checksum file is not utf-8")?;
795 let expected = body
796 .split_whitespace()
797 .next()
798 .ok_or_else(|| anyhow::anyhow!("empty checksum file {}", update.checksum_asset.name))?;
799 let actual = sha256_hex(archive);
800 if expected != actual {
801 bail!(
802 "checksum mismatch for {}: expected {expected}, got {actual}",
803 update.asset.name
804 );
805 }
806 Ok(())
807}
808
809fn sha256_hex(bytes: &[u8]) -> String {
810 let mut hasher = Sha256::new();
811 hasher.update(bytes);
812 lower_hex(hasher.finalize())
813}
814
815fn install_release_archive(
818 current_exe: &Path,
819 archive_name: &str,
820 archive_bytes: &[u8],
821) -> Result<PathBuf> {
822 ensure!(
823 cfg!(unix),
824 "self-update replacement is only supported on Unix platforms"
825 );
826 let target_exe = current_exe
827 .canonicalize()
828 .with_context(|| format!("resolve executable target {}", current_exe.display()))?;
829 let parent = target_exe
830 .parent()
831 .ok_or_else(|| anyhow::anyhow!("executable has no parent: {}", target_exe.display()))?;
832 let staging = tempfile::Builder::new()
835 .prefix(".mj-self-update-")
836 .tempdir_in(parent)
837 .context("create update staging directory")?;
838 let mut binaries = stage_release_archive(archive_name, archive_bytes, staging.path())?;
839 let executable_name = if archive_name.ends_with(".zip") {
840 WINDOWS_BIN_NAME
841 } else {
842 BIN_NAME
843 };
844 ensure!(
845 staging.path().join(executable_name).is_file(),
846 "archive did not contain expected binary: {executable_name}"
847 );
848 binaries.sort_by_key(|path| path.file_name() == Some(executable_name.as_ref()));
850 strip_quarantine(staging.path());
851 for binary in binaries {
852 let name = binary
853 .file_name()
854 .context("staged binary has no file name")?;
855 let target = if name == executable_name {
856 target_exe.clone()
857 } else {
858 parent.join(name)
859 };
860 std::fs::rename(&binary, &target)
861 .with_context(|| format!("install {}", target.display()))?;
862 }
863 Ok(target_exe)
864}
865
866fn stage_release_archive(
867 archive_name: &str,
868 archive_bytes: &[u8],
869 directory: &Path,
870) -> Result<Vec<PathBuf>> {
871 let mut binaries = Vec::new();
872 if archive_name.ends_with(".zip") {
873 let mut archive =
874 zip::ZipArchive::new(Cursor::new(archive_bytes)).context("open zip archive")?;
875 for index in 0..archive.len() {
876 let mut entry = archive.by_index(index).context("read zip entry")?;
877 let path = entry.enclosed_name().ok_or_else(|| {
878 anyhow::anyhow!("zip entry escapes destination: {}", entry.name())
879 })?;
880 let is_file = entry.is_file() && !entry.is_symlink();
881 if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
882 binaries.push(binary);
883 }
884 }
885 } else {
886 let mut archive = tar::Archive::new(GzDecoder::new(archive_bytes));
887 for entry in archive.entries().context("read tar entries")? {
888 let mut entry = entry.context("read tar entry")?;
889 let path = entry.path().context("read tar entry path")?.into_owned();
890 let is_file = entry.header().entry_type().is_file();
891 if let Some(binary) = stage_archive_binary(directory, &path, is_file, &mut entry)? {
892 binaries.push(binary);
893 }
894 }
895 }
896 Ok(binaries)
897}
898
899fn stage_archive_binary(
900 directory: &Path,
901 path: &Path,
902 is_file: bool,
903 mut contents: impl Read,
904) -> Result<Option<PathBuf>> {
905 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
906 return Ok(None);
907 };
908 let stem = name.strip_suffix(".exe").unwrap_or(name);
909 if !matches!(
910 stem,
911 BIN_NAME | "mj-desktop" | VOICE_WORKER_NAME | "mj-worker"
912 ) && !stem.starts_with("mj-worker-")
913 {
914 return Ok(None);
915 }
916 ensure!(
917 is_file,
918 "archive binary is not a regular file: {}",
919 path.display()
920 );
921 let target = directory.join(name);
922 let mut output = std::fs::File::create_new(&target)
923 .with_context(|| format!("stage {name}; each binary must appear only once"))?;
924 let size = io::copy(&mut contents, &mut output).with_context(|| format!("extract {name}"))?;
925 ensure!(size != 0, "archive contained an empty {name} binary");
926 #[cfg(unix)]
927 {
928 use std::os::unix::fs::PermissionsExt;
929 output
930 .set_permissions(std::fs::Permissions::from_mode(0o755))
931 .with_context(|| format!("chmod {name}"))?;
932 }
933 Ok(Some(target))
934}
935
936#[cfg(unix)]
937fn strip_quarantine(path: &Path) {
938 #[cfg(target_os = "macos")]
939 {
940 let _ = Command::new("xattr")
943 .arg("-dr")
944 .arg("com.apple.quarantine")
945 .arg(path)
946 .status();
947 }
948 #[cfg(not(target_os = "macos"))]
949 {
950 let _ = path;
951 }
952}
953
954#[cfg(not(unix))]
955fn strip_quarantine(_path: &Path) {}
956
957#[cfg(test)]
958mod tests;