use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use sha2::Digest;
use crate::error::{MindError, Result};
use crate::mindfile::version_at_least;
const REPO: &str = "jaemk/mind";
#[derive(Debug, PartialEq, Eq)]
pub enum Decision {
UpToDate,
Update,
PinnedBelowCurrent,
}
pub fn target_triple(os: &str, arch: &str) -> Result<&'static str> {
match (os, arch) {
("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
_ => Err(MindError::UnsupportedPlatform {
os: os.to_string(),
arch: arch.to_string(),
}),
}
}
pub fn asset_url(version: &str, target: &str) -> String {
format!("https://github.com/{REPO}/releases/download/v{version}/mind-{version}-{target}.tar.gz")
}
fn latest_release_api() -> String {
format!("https://api.github.com/repos/{REPO}/releases/latest")
}
pub fn parse_latest_tag(json: &str) -> Result<String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| MindError::json("github release", e))?;
let tag = value
.get("tag_name")
.and_then(|t| t.as_str())
.ok_or_else(|| MindError::DownloadFailed {
url: latest_release_api(),
reason: "release JSON has no 'tag_name' field".to_string(),
})?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
pub fn decision(current: &str, target: &str, explicit: bool) -> Decision {
if version_at_least(current, target) {
if explicit && !version_at_least(target, current) {
Decision::PinnedBelowCurrent
} else {
Decision::UpToDate
}
} else {
Decision::Update
}
}
fn check_report(current: &str, target: &str, decision: &Decision) -> String {
match decision {
Decision::UpToDate => {
format!("mind {current} is up to date (latest is {target})")
}
Decision::Update => {
format!("mind {current} -> {target} available; run `mind evolve` to update")
}
Decision::PinnedBelowCurrent => {
format!("pinned {target} is below the running {current}; not downgrading")
}
}
}
pub(crate) fn check_policy_for_evolve(
policy: Option<&crate::policy::Policy>,
user_version: Option<&str>,
) -> Result<Option<String>> {
use crate::policy::SelfUpdateControl;
let Some(pol) = policy else {
return Ok(None);
};
match pol.self_update_control() {
SelfUpdateControl::Allowed => Ok(None),
SelfUpdateControl::Disabled => Err(MindError::SelfUpdatePolicy {
detail: "self-update is disabled by the managed policy".to_string(),
}),
SelfUpdateControl::Pinned(pin) => {
if let Some(uv) = user_version {
let uv_clean = uv.strip_prefix('v').unwrap_or(uv);
if uv_clean != pin {
return Err(MindError::SelfUpdatePolicy {
detail: format!(
"managed policy pins self-update to {pin}; \
--version {uv_clean} conflicts with the pin"
),
});
}
}
Ok(Some(pin.clone()))
}
}
}
pub fn run(check: bool, yes: bool, mut version: Option<String>) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let target = target_triple(os, arch)?;
let policy = crate::policy::Policy::load()?;
if let Some(pin) = check_policy_for_evolve(policy.as_ref(), version.as_deref())? {
version = Some(pin);
}
let explicit = version.is_some();
let target_version = match version {
Some(v) => v.strip_prefix('v').unwrap_or(&v).to_string(),
None => {
let json = fetch_to_string(&latest_release_api())?;
parse_latest_tag(&json)?
}
};
let d = decision(current, &target_version, explicit);
let out = crate::render::ctx();
if check {
if out.json {
let outcome = match d {
Decision::UpToDate => "up-to-date",
Decision::Update => "available",
Decision::PinnedBelowCurrent => "not-downgrading",
};
return print_evolve_json(&target_version, outcome);
}
let marker = match d {
Decision::UpToDate | Decision::PinnedBelowCurrent => out.ok(),
Decision::Update => out.warn(),
};
println!("{marker} {}", check_report(current, &target_version, &d));
return Ok(());
}
match d {
Decision::UpToDate => {
if out.json {
return print_evolve_json(&target_version, "up-to-date");
}
println!("{} mind {current} is already up to date", out.ok());
return Ok(());
}
Decision::PinnedBelowCurrent => {
if out.json {
return print_evolve_json(&target_version, "not-downgrading");
}
println!(
"{} {}",
out.ok(),
check_report(current, &target_version, &d)
);
return Ok(());
}
Decision::Update => {}
}
if !yes && !out.json && !crate::commands::confirm(&format!("update mind to {target_version}?"))?
{
println!("aborted; nothing changed");
return Ok(());
}
let url = asset_url(&target_version, target);
download_and_swap(&url, current, &target_version)
}
fn print_evolve_json(version: &str, outcome: &str) -> Result<()> {
crate::render::print_json(&serde_json::json!({
"action": "evolve",
"target": version,
"outcome": outcome,
}))
}
pub fn sha256sums_url(version: &str) -> String {
format!("https://github.com/{REPO}/releases/download/v{version}/SHA256SUMS")
}
pub fn parse_sha256sums(text: &str, filename: &str) -> Option<String> {
for line in text.lines() {
if let Some((digest, name)) = line.split_once(" ") {
let name = name.trim();
if name == filename && digest.len() == 64 {
return Some(digest.to_ascii_lowercase());
}
}
}
None
}
pub fn sha256_hex(data: &[u8]) -> String {
sha2::Sha256::digest(data)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn download_and_swap(url: &str, current: &str, target_version: &str) -> Result<()> {
let paths = crate::paths::Paths::resolve()?;
let mut lock = crate::lock::open(&paths)?;
let _guard = lock.write()?;
let out = crate::render::ctx();
let tmp = mktemp_dir()?;
let archive = tmp.join("mind.tar.gz");
if !out.json {
println!(
"{} downloading mind {target_version} ({})",
out.bullet(),
out.dim(url)
);
}
let sums_url = sha256sums_url(target_version);
let sums_text = fetch_to_string(&sums_url)?;
let archive_filename = url.rsplit('/').next().unwrap_or("");
fetch_to_file(url, &archive)?;
let archive_bytes = std::fs::read(&archive).map_err(|e| MindError::io(&archive, e))?;
let actual = sha256_hex(&archive_bytes);
let expected = parse_sha256sums(&sums_text, archive_filename).ok_or_else(|| {
MindError::DigestMismatch {
url: url.to_string(),
expected: "(not found in SHA256SUMS)".to_string(),
actual: actual.clone(),
}
})?;
if actual != expected {
let _ = std::fs::remove_dir_all(&tmp);
return Err(MindError::DigestMismatch {
url: url.to_string(),
expected,
actual,
});
}
let status = Command::new("tar")
.arg("-xzf")
.arg(&archive)
.arg("-C")
.arg(&tmp)
.status()
.map_err(|e| MindError::io("tar", e))?;
if !status.success() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "could not extract the release archive".to_string(),
});
}
let new_bin = tmp.join("mind");
if !new_bin.is_file() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(MindError::ReleaseAssetEmpty);
}
let current_exe = std::env::current_exe().map_err(|e| MindError::io("<current-exe>", e))?;
let result = swap_in_place(&new_bin, ¤t_exe);
let _ = std::fs::remove_dir_all(&tmp);
result?;
if out.json {
return print_evolve_json(target_version, "updated");
}
println!("{} updated mind {current} -> {target_version}", out.ok());
Ok(())
}
fn swap_in_place(new_bin: &Path, current_exe: &Path) -> Result<()> {
let dir = current_exe
.parent()
.ok_or_else(|| MindError::TargetNotWritable {
path: current_exe.display().to_string(),
})?;
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let staged = dir.join(format!(".mind-update.{pid}.{nanos}"));
if staged.exists() {
return Err(MindError::io(
&staged,
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"staged path already exists; possible pre-creation race",
),
));
}
if let Err(e) = std::fs::copy(new_bin, &staged) {
return Err(swap_error(e, current_exe, &staged));
}
if let Err(e) = std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)) {
let _ = std::fs::remove_file(&staged);
return Err(MindError::io(&staged, e));
}
if let Err(e) = std::fs::rename(&staged, current_exe) {
let _ = std::fs::remove_file(&staged);
return Err(swap_error(e, current_exe, current_exe));
}
Ok(())
}
fn swap_error(e: std::io::Error, current_exe: &Path, at: &Path) -> MindError {
if e.kind() == std::io::ErrorKind::PermissionDenied {
MindError::TargetNotWritable {
path: current_exe.display().to_string(),
}
} else {
MindError::io(at, e)
}
}
static MKTEMP_SEQ: AtomicU64 = AtomicU64::new(0);
fn mktemp_dir() -> Result<std::path::PathBuf> {
let pid = std::process::id();
let seq = MKTEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let base = std::env::temp_dir().join(format!("mind-evolve-{pid}-{nanos}-{seq}"));
std::fs::create_dir(&base).map_err(|e| MindError::io(&base, e))?;
#[cfg(unix)]
std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700))
.map_err(|e| MindError::io(&base, e))?;
Ok(base)
}
fn http_timeout_secs() -> u64 {
std::env::var("MIND_HTTP_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(15)
}
pub(crate) fn curl_string_args(url: &str, timeout_secs: u64) -> Vec<String> {
vec![
"--proto".into(),
"=https".into(),
"--proto-redir".into(),
"=https".into(),
"--tlsv1.2".into(),
"-fsSL".into(),
"--connect-timeout".into(),
timeout_secs.to_string(),
"--max-time".into(),
"600".into(),
url.into(),
]
}
pub(crate) fn wget_string_args(url: &str, timeout_secs: u64) -> Vec<String> {
vec![
"--https-only".into(),
"-O-".into(),
format!("--timeout={timeout_secs}"),
url.into(),
]
}
pub(crate) fn curl_file_args(url: &str, dest: &str, timeout_secs: u64) -> Vec<String> {
vec![
"--proto".into(),
"=https".into(),
"--proto-redir".into(),
"=https".into(),
"--tlsv1.2".into(),
"-fsSL".into(),
"--connect-timeout".into(),
timeout_secs.to_string(),
"--max-time".into(),
"600".into(),
url.into(),
"-o".into(),
dest.into(),
]
}
pub(crate) fn wget_file_args(url: &str, dest: &str, timeout_secs: u64) -> Vec<String> {
vec![
"--https-only".into(),
"-qO".into(),
dest.into(),
format!("--timeout={timeout_secs}"),
url.into(),
]
}
fn maybe_proxy_hint(reason: &str) -> String {
if reason.contains("407")
|| reason.contains("Could not resolve proxy")
|| reason.contains("Received HTTP code 407 from proxy")
{
format!(
"{reason}\nhint: if you are behind a proxy, set the HTTPS_PROXY environment variable \
(e.g. export HTTPS_PROXY=http://proxy.example.com:8080) or configure git's http.proxy setting"
)
} else {
reason.to_string()
}
}
fn fetch_to_string(url: &str) -> Result<String> {
let timeout = http_timeout_secs();
let output = if have("curl") {
Command::new("curl")
.args(curl_string_args(url, timeout))
.output()
.map_err(|e| MindError::io("curl", e))?
} else if have("wget") {
Command::new("wget")
.args(wget_string_args(url, timeout))
.output()
.map_err(|e| MindError::io("wget", e))?
} else {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "need curl or wget on PATH".to_string(),
});
};
if !output.status.success() {
let reason = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: maybe_proxy_hint(&reason),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn fetch_to_file(url: &str, dest: &Path) -> Result<()> {
let timeout = http_timeout_secs();
let dest_str = dest.to_string_lossy();
let status = if have("curl") {
Command::new("curl")
.args(curl_file_args(url, &dest_str, timeout))
.status()
.map_err(|e| MindError::io("curl", e))?
} else if have("wget") {
Command::new("wget")
.args(wget_file_args(url, &dest_str, timeout))
.status()
.map_err(|e| MindError::io("wget", e))?
} else {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "need curl or wget on PATH".to_string(),
});
};
if !status.success() {
return Err(MindError::DownloadFailed {
url: url.to_string(),
reason: "downloader exited non-zero".to_string(),
});
}
Ok(())
}
fn have(cmd: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {cmd}"))
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curl_string_args_includes_connect_timeout_and_max_time() {
let args = curl_string_args("https://example.com/data", 15);
let ct = args
.iter()
.position(|a| a == "--connect-timeout")
.expect("--connect-timeout must be present");
assert_eq!(
args[ct + 1],
"15",
"connect-timeout value must follow --connect-timeout"
);
let mt = args
.iter()
.position(|a| a == "--max-time")
.expect("--max-time must be present");
assert_eq!(args[mt + 1], "600", "max-time must be 600 seconds");
assert!(
args.contains(&"https://example.com/data".to_string()),
"URL must be in the arg list"
);
}
#[test]
fn wget_string_args_includes_timeout_and_no_quiet_flag() {
let args = wget_string_args("https://example.com/data", 15);
assert!(
args.contains(&"--timeout=15".to_string()),
"wget args must include --timeout=15: {args:?}"
);
assert!(
args.contains(&"https://example.com/data".to_string()),
"wget args must include the URL: {args:?}"
);
assert!(
!args.iter().any(|a| a == "-q" || a.contains('q')),
"wget string-fetch must not include -q (stderr must be visible): {args:?}"
);
}
#[test]
fn curl_file_args_includes_connect_timeout_and_dest() {
let args = curl_file_args("https://example.com/file.tar.gz", "/tmp/dest.tar.gz", 30);
let ct = args
.iter()
.position(|a| a == "--connect-timeout")
.expect("--connect-timeout must be present");
assert_eq!(
args[ct + 1],
"30",
"custom connect-timeout value must be 30"
);
assert!(
args.contains(&"--max-time".to_string()),
"must include --max-time: {args:?}"
);
assert!(
args.contains(&"/tmp/dest.tar.gz".to_string()),
"dest path must be in arg list: {args:?}"
);
assert!(
args.contains(&"https://example.com/file.tar.gz".to_string()),
"URL must be in arg list: {args:?}"
);
}
#[test]
fn wget_file_args_includes_timeout_and_dest() {
let args = wget_file_args("https://example.com/file.tar.gz", "/tmp/dest.tar.gz", 30);
assert!(
args.contains(&"--timeout=30".to_string()),
"wget file args must include --timeout=30: {args:?}"
);
assert!(
args.contains(&"/tmp/dest.tar.gz".to_string()),
"dest must be in file-fetch args: {args:?}"
);
assert!(
args.contains(&"https://example.com/file.tar.gz".to_string()),
"URL must be in file-fetch args: {args:?}"
);
}
#[test]
fn timeout_param_flows_through_arg_builders() {
let args = curl_string_args("https://example.com/", 42);
let ct = args.iter().position(|a| a == "--connect-timeout").unwrap();
assert_eq!(
args[ct + 1],
"42",
"custom timeout must appear in curl args"
);
let args = wget_string_args("https://example.com/", 42);
assert!(
args.contains(&"--timeout=42".to_string()),
"custom timeout must appear in wget args: {args:?}"
);
}
fn policy_from_toml(toml: &str) -> crate::policy::Policy {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"mind-selfupdate-pol-{}-{}.toml",
std::process::id(),
MKTEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::write(&path, toml).unwrap();
let p = crate::policy::load_file(&path).unwrap();
std::fs::remove_file(&path).ok();
p
}
#[test]
fn policy_absent_allows_evolve_with_no_pin() {
let result = check_policy_for_evolve(None, None);
assert_eq!(
result.unwrap(),
None,
"absent policy must return Ok(None): unrestricted evolve"
);
}
#[test]
fn policy_self_update_true_allows_evolve() {
let pol = policy_from_toml("[binary]\nself-update = true\n");
let result = check_policy_for_evolve(Some(&pol), None);
assert_eq!(
result.unwrap(),
None,
"self-update = true must return Ok(None): unrestricted evolve"
);
}
#[test]
fn policy_disabled_denies_evolve_check_and_run() {
let pol = policy_from_toml("[binary]\nself-update = false\n");
let err = check_policy_for_evolve(Some(&pol), None).unwrap_err();
match err {
MindError::SelfUpdatePolicy { detail } => {
assert!(
detail.contains("disabled by the managed policy"),
"disabled detail must say the policy disabled it: {detail}"
);
}
other => panic!("expected SelfUpdatePolicy, got {other:?}"),
}
let err = check_policy_for_evolve(Some(&pol), Some("9.9.9")).unwrap_err();
assert!(
matches!(err, MindError::SelfUpdatePolicy { .. }),
"disabled policy must error even with a --version arg: {err:?}"
);
}
#[test]
fn policy_pinned_no_version_arg_returns_pin() {
let pol = policy_from_toml("[binary]\nself-update = \"0.14.0\"\n");
let pin = check_policy_for_evolve(Some(&pol), None).unwrap();
assert_eq!(
pin,
Some("0.14.0".to_string()),
"pinned policy with no --version must return the pin"
);
}
#[test]
fn policy_pinned_matching_version_arg_returns_pin() {
let pol = policy_from_toml("[binary]\nself-update = \"0.14.0\"\n");
let pin = check_policy_for_evolve(Some(&pol), Some("0.14.0")).unwrap();
assert_eq!(
pin,
Some("0.14.0".to_string()),
"matching --version must succeed with a pinned policy"
);
}
#[test]
fn policy_pinned_mismatched_version_arg_errors() {
let pol = policy_from_toml("[binary]\nself-update = \"0.14.0\"\n");
let result = check_policy_for_evolve(Some(&pol), Some("0.15.0"));
match result.unwrap_err() {
MindError::SelfUpdatePolicy { detail } => {
assert!(detail.contains("0.14.0"), "must name the pin: {detail}");
assert!(
detail.contains("0.15.0"),
"must name the conflicting version: {detail}"
);
assert!(
detail.contains("conflicts"),
"must say 'conflicts': {detail}"
);
}
other => panic!("expected SelfUpdatePolicy, got {other:?}"),
}
}
#[test]
fn have_detects_present_and_absent_commands() {
assert!(have("sh"), "`sh` must be detected on PATH");
assert!(
!have("mind-no-such-binary-xyzzy"),
"a nonexistent command must not be detected"
);
}
#[test]
fn target_triple_maps_supported_platforms() {
assert_eq!(
target_triple("linux", "x86_64").unwrap(),
"x86_64-unknown-linux-gnu"
);
assert_eq!(
target_triple("linux", "aarch64").unwrap(),
"aarch64-unknown-linux-gnu"
);
assert_eq!(
target_triple("macos", "aarch64").unwrap(),
"aarch64-apple-darwin"
);
}
#[test]
fn target_triple_rejects_intel_macos_and_unknown_arch() {
match target_triple("macos", "x86_64") {
Err(MindError::UnsupportedPlatform { os, arch }) => {
assert_eq!(os, "macos");
assert_eq!(arch, "x86_64");
}
other => panic!("expected UnsupportedPlatform, got {other:?}"),
}
assert!(matches!(
target_triple("linux", "riscv64"),
Err(MindError::UnsupportedPlatform { .. })
));
assert!(matches!(
target_triple("windows", "x86_64"),
Err(MindError::UnsupportedPlatform { .. })
));
}
#[test]
fn asset_url_matches_install_sh_shape() {
assert_eq!(
asset_url("0.3.0", "x86_64-unknown-linux-gnu"),
"https://github.com/jaemk/mind/releases/download/v0.3.0/mind-0.3.0-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn parse_latest_tag_strips_leading_v() {
let json = r#"{"tag_name":"v0.3.0","name":"0.3.0"}"#;
assert_eq!(parse_latest_tag(json).unwrap(), "0.3.0");
let json = r#"{"tag_name":"1.2.3"}"#;
assert_eq!(parse_latest_tag(json).unwrap(), "1.2.3");
}
#[test]
fn parse_latest_tag_missing_field_is_an_error() {
let json = r#"{"name":"0.3.0"}"#;
match parse_latest_tag(json) {
Err(MindError::DownloadFailed { reason, .. }) => {
assert!(reason.contains("tag_name"), "reason: {reason}");
}
other => panic!("expected DownloadFailed, got {other:?}"),
}
}
#[test]
fn decision_compares_versions() {
assert_eq!(decision("0.3.0", "0.3.0", false), Decision::UpToDate);
assert_eq!(decision("0.3.0", "0.3.0", true), Decision::UpToDate);
assert_eq!(decision("0.2.0", "0.3.0", false), Decision::Update);
assert_eq!(decision("0.4.0", "0.3.0", false), Decision::UpToDate);
}
#[test]
fn decision_explicit_pinned_below_current_yields_pinned_below() {
assert_eq!(
decision("0.3.0", "0.1.0", true),
Decision::PinnedBelowCurrent
);
assert_eq!(
decision("1.0.0", "0.9.9", true),
Decision::PinnedBelowCurrent
);
assert_eq!(decision("0.4.0", "0.3.0", false), Decision::UpToDate);
}
#[test]
fn decision_explicit_equal_to_current_is_up_to_date() {
assert_eq!(decision("0.3.0", "0.3.0", true), Decision::UpToDate);
}
#[test]
fn decision_explicit_above_current_is_update() {
assert_eq!(decision("0.2.0", "0.3.0", true), Decision::Update);
}
#[test]
fn check_report_reflects_the_decision_without_network() {
let pending = decision("0.2.0", "0.3.0", false);
assert_eq!(pending, Decision::Update);
let report = check_report("0.2.0", "0.3.0", &pending);
assert!(report.contains("0.2.0"), "report: {report}");
assert!(report.contains("0.3.0"), "report: {report}");
assert!(report.contains("available"), "report: {report}");
let current = decision("0.3.0", "0.3.0", false);
assert_eq!(current, Decision::UpToDate);
let report = check_report("0.3.0", "0.3.0", ¤t);
assert!(report.contains("up to date"), "report: {report}");
}
#[test]
fn check_report_pinned_below_says_not_downgrading() {
let d = Decision::PinnedBelowCurrent;
let report = check_report("0.3.0", "0.1.0", &d);
assert!(report.contains("0.1.0"), "pinned version missing: {report}");
assert!(
report.contains("0.3.0"),
"running version missing: {report}"
);
assert!(
report.contains("not downgrading"),
"must say 'not downgrading': {report}"
);
assert!(
!report.contains("up to date"),
"must NOT say 'up to date': {report}"
);
}
#[test]
fn check_report_up_to_date_when_equal() {
let d = Decision::UpToDate;
let report = check_report("0.3.0", "0.3.0", &d);
assert!(report.contains("up to date"), "report: {report}");
assert!(
!report.contains("not downgrading"),
"must NOT say 'not downgrading': {report}"
);
}
#[cfg(unix)]
#[test]
fn swap_in_place_uses_pid_nanos_staged_name() {
use std::sync::atomic::{AtomicU32, Ordering};
static SWP_N: AtomicU32 = AtomicU32::new(0);
let n = SWP_N.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-swap45-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let new_bin = base.join("new_mind");
let cur = base.join("mind");
std::fs::write(&new_bin, b"#!/bin/sh\necho new\n").unwrap();
std::fs::write(&cur, b"#!/bin/sh\necho old\n").unwrap();
std::fs::set_permissions(&cur, std::fs::Permissions::from_mode(0o755)).unwrap();
swap_in_place(&new_bin, &cur).unwrap();
assert_eq!(
std::fs::read(&cur).unwrap(),
b"#!/bin/sh\necho new\n",
"swap_in_place must replace the current executable with the new binary"
);
let residue: Vec<_> = std::fs::read_dir(&base)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".mind-update."))
.collect();
assert!(
residue.is_empty(),
"staged file must not remain after a successful swap: {residue:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn mktemp_dir_creates_a_fresh_directory() {
let dir = mktemp_dir().expect("mktemp_dir");
let exists = dir.is_dir();
let _ = std::fs::remove_dir_all(&dir);
assert!(exists, "mktemp_dir must create the directory: {dir:?}");
}
#[test]
fn mktemp_dir_yields_distinct_paths() {
let a = mktemp_dir().expect("first mktemp_dir");
let b = mktemp_dir().expect("second mktemp_dir");
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
assert_ne!(
a, b,
"successive mktemp_dir calls must yield distinct paths"
);
}
#[test]
fn parse_sha256sums_finds_matching_filename() {
let sums = concat!(
"aabbccdd00112233445566778899aabbccddeeff0011223344556677889900aa mind-1.0.0-x86_64-unknown-linux-gnu.tar.gz\n",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef mind-1.0.0-aarch64-apple-darwin.tar.gz\n",
);
let got = parse_sha256sums(sums, "mind-1.0.0-aarch64-apple-darwin.tar.gz");
assert_eq!(
got.as_deref(),
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
"must return the digest for the matching filename"
);
}
#[test]
fn parse_sha256sums_returns_none_when_filename_absent() {
let sums =
"aabbccdd00112233445566778899aabbccddeeff0011223344556677889900aa other.tar.gz\n";
let got = parse_sha256sums(sums, "mind-1.0.0-x86_64-unknown-linux-gnu.tar.gz");
assert!(got.is_none(), "must return None for an absent filename");
}
#[test]
fn sha256_hex_matches_known_vector() {
let digest = sha256_hex(b"abc");
assert_eq!(
digest.len(),
64,
"sha256_hex output must be 64 hex chars (32 bytes): got {digest}"
);
assert!(
digest
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"sha256_hex output must be all lowercase hex: {digest}"
);
let expected = sha2::Sha256::digest(b"abc")
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
assert_eq!(
digest, expected,
"sha256_hex must be consistent with sha2::Sha256::digest"
);
}
#[test]
fn composed_digest_verify_happy_path() {
let archive_bytes = b"fake-archive-content-for-testing";
let filename = "mind-1.0.0-x86_64-unknown-linux-gnu.tar.gz";
let actual = sha256_hex(archive_bytes);
let sums_text = format!("{actual} {filename}\n");
let expected = parse_sha256sums(&sums_text, filename)
.expect("must find the filename in a correctly built sums file");
assert_eq!(
actual, expected,
"computed digest must match the sums entry for the happy path"
);
}
#[test]
fn composed_digest_verify_mismatch_branch() {
let archive_bytes = b"fake-archive-content-for-testing";
let filename = "mind-1.0.0-x86_64-unknown-linux-gnu.tar.gz";
let actual = sha256_hex(archive_bytes);
let tampered: String = {
let first_byte = &actual[0..2];
let replacement = if first_byte == "00" { "ff" } else { "00" };
format!("{replacement}{}", &actual[2..])
};
assert_ne!(actual, tampered, "tampered digest must differ from actual");
let sums_text = format!("{tampered} {filename}\n");
let expected =
parse_sha256sums(&sums_text, filename).expect("must find the tampered entry");
assert_ne!(
actual, expected,
"tampered sums must not match actual digest (mismatch branch must trigger)"
);
}
#[test]
fn composed_digest_verify_missing_entry_branch() {
let filename = "mind-1.0.0-x86_64-unknown-linux-gnu.tar.gz";
let sums_text =
"aabbccdd00112233445566778899aabbccddeeff0011223344556677889900aa other.tar.gz\n";
let got = parse_sha256sums(sums_text, filename);
assert!(
got.is_none(),
"missing filename must return None (fail closed, no extraction)"
);
}
#[test]
fn sha256sums_url_matches_expected_shape() {
let url = sha256sums_url("1.2.3");
assert_eq!(
url,
"https://github.com/jaemk/mind/releases/download/v1.2.3/SHA256SUMS"
);
}
}