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-musl"),
("linux", "aarch64") => Ok("aarch64-unknown-linux-musl"),
("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, triple: &str) -> String {
match decision {
Decision::UpToDate => {
format!("mind {current} is up to date (latest is {target}) -- target {triple}")
}
Decision::Update => {
format!(
"mind {current} -> {target} available; run `mind evolve` to update -- target {triple}"
)
}
Decision::PinnedBelowCurrent => {
format!(
"pinned {target} is below the running {current}; not downgrading -- target {triple}"
)
}
}
}
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()?;
let policy_pin_active =
if let Some(pin) = check_policy_for_evolve(policy.as_ref(), version.as_deref())? {
version = Some(pin);
true
} else {
false
};
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, target);
}
let marker = match d {
Decision::UpToDate | Decision::PinnedBelowCurrent => out.ok(),
Decision::Update => out.warn(),
};
println!(
"{marker} {}",
check_report(current, &target_version, &d, target)
);
if matches!(d, Decision::PinnedBelowCurrent) && policy_pin_active {
println!(
"warning: running {current} differs from the managed policy pin \
{target_version}; the policy pin is an upper bound and does not downgrade"
);
}
return Ok(());
}
match d {
Decision::UpToDate => {
if out.json {
return print_evolve_json(&target_version, "up-to-date", target);
}
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", target);
}
println!(
"{} {}",
out.ok(),
check_report(current, &target_version, &d, target)
);
if policy_pin_active {
println!(
"warning: running {current} differs from the managed policy pin \
{target_version}; the policy pin is an upper bound and does not downgrade"
);
}
return Ok(());
}
Decision::Update => {}
}
if !yes {
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: format!("updating mind to {target_version}"),
});
}
if !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, target)
}
fn print_evolve_json(version: &str, outcome: &str, target_triple: &str) -> Result<()> {
crate::render::print_json(&serde_json::json!({
"action": "evolve",
"target": version,
"target_triple": target_triple,
"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()
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AttestationOutcome {
Verified,
ToolingError(String),
GenuineFailure(String),
}
pub(crate) fn gh_attestation_verify_args(archive_path: &str, repo: &str) -> Vec<String> {
vec![
"attestation".into(),
"verify".into(),
archive_path.into(),
"--repo".into(),
repo.into(),
]
}
pub(crate) fn is_gh_tooling_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
const TOOLING_MARKERS: &[&str] = &[
"unknown command",
"unknown flag",
"dial tcp",
"no such host",
"connection refused",
"i/o timeout",
"context deadline exceeded",
"tls handshake",
"certificate signed by unknown authority",
"gh auth login",
];
TOOLING_MARKERS.iter().any(|m| lower.contains(m))
}
fn attestation_step(gh_cmd: &str, archive: &Path) -> Option<AttestationOutcome> {
if !have(gh_cmd) {
return None;
}
let archive_str = archive.to_string_lossy();
let args = gh_attestation_verify_args(&archive_str, REPO);
let output = match Command::new(gh_cmd).args(&args).output() {
Ok(o) => o,
Err(e) => return Some(AttestationOutcome::ToolingError(e.to_string())),
};
if output.status.success() {
return Some(AttestationOutcome::Verified);
}
let stderr = crate::sanitize::strip_ansi(String::from_utf8_lossy(&output.stderr).trim());
if is_gh_tooling_error(&stderr) {
Some(AttestationOutcome::ToolingError(stderr))
} else {
Some(AttestationOutcome::GenuineFailure(stderr))
}
}
fn download_and_swap(
url: &str,
current: &str,
target_version: &str,
target_triple: &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,
});
}
match attestation_step("gh", &archive) {
None => {}
Some(AttestationOutcome::Verified) => {
if !out.json {
println!("{} build provenance verified", out.ok());
}
}
Some(AttestationOutcome::ToolingError(reason)) => {
if !out.json {
println!(
"{} build provenance could not be verified ({reason}); continuing",
out.warn()
);
}
}
Some(AttestationOutcome::GenuineFailure(reason)) => {
let _ = std::fs::remove_dir_all(&tmp);
return Err(MindError::AttestationVerificationFailed { reason });
}
}
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", target_triple);
}
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> {
mktemp_dir_prefixed("mind-evolve")
}
pub(crate) fn mktemp_dir_prefixed(prefix: &str) -> 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!("{prefix}-{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)
}
pub(crate) fn clamp_http_timeout(raw: Option<u64>) -> u64 {
match raw {
None | Some(0) => 15,
Some(n) => n,
}
}
fn http_timeout_secs() -> u64 {
clamp_http_timeout(
std::env::var("MIND_HTTP_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok()),
)
}
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(),
"--tries=1".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(),
"--tries=1".into(),
"-qO".into(),
dest.into(),
format!("--timeout={timeout_secs}"),
url.into(),
]
}
fn is_github_api_url(url: &str) -> bool {
url.starts_with("https://api.github.com/")
}
pub(crate) fn is_safe_token(token: &str) -> bool {
!token.chars().any(|c| c.is_control()) && !token.contains('"') && !token.contains('\\')
}
fn github_token() -> Option<String> {
for var in ["GITHUB_TOKEN", "GH_TOKEN"] {
if let Ok(v) = std::env::var(var) {
let v = v.trim();
if v.is_empty() {
continue;
}
if !is_safe_token(v) {
eprintln!(
"warning: ${var} contains characters unsafe for evolve's authenticated \
GitHub API request (a control character, '\"', or '\\'); proceeding \
without authentication"
);
continue;
}
return Some(v.to_string());
}
}
None
}
pub(crate) fn curl_auth_config_content(url: &str, token: Option<&str>) -> Option<String> {
match token {
Some(t) if !t.is_empty() && is_github_api_url(url) => {
Some(format!("header = \"Authorization: Bearer {t}\"\n"))
}
_ => None,
}
}
pub(crate) fn curl_auth_args(config_path: Option<&str>) -> Vec<String> {
match config_path {
Some(p) => vec!["--config".into(), p.into()],
None => vec![],
}
}
fn write_curl_auth_config(content: &str) -> Result<std::path::PathBuf> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let dir = mktemp_dir()?;
let path = dir.join("curl-auth.cfg");
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(|e| MindError::io(&path, e))?;
f.write_all(content.as_bytes())
.map_err(|e| MindError::io(&path, e))?;
Ok(path)
}
fn write_curl_auth_config_or_warn(content: &str) -> Option<std::path::PathBuf> {
match write_curl_auth_config(content) {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"warning: could not write the temporary auth config file for the \
authenticated GitHub API request ({e}); proceeding without authentication"
);
None
}
}
}
fn remove_curl_auth_config(path: &Path) {
let _ = std::fs::remove_file(path);
if let Some(dir) = path.parent() {
let _ = std::fs::remove_dir(dir);
}
}
pub(crate) fn wget_auth_args(url: &str, token: Option<&str>) -> Vec<String> {
match token {
Some(t) if !t.is_empty() && is_github_api_url(url) => {
vec![format!("--header=Authorization: Bearer {t}")]
}
_ => vec![],
}
}
fn maybe_proxy_hint(reason: &str) -> String {
let reason = crate::sanitize::strip_ansi(reason);
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 HTTPS_PROXY or HTTP_PROXY \
(e.g. export HTTPS_PROXY=http://proxy.example.com:8080); \
for NTLM or Kerberos proxies, configure proxy settings in ~/.curlrc \
(proxy-negotiate)"
)
} else {
reason
}
}
fn fetch_to_string(url: &str) -> Result<String> {
let timeout = http_timeout_secs();
let token = github_token();
let output = if have("curl") {
let mut args = curl_string_args(url, timeout);
let auth_cfg = curl_auth_config_content(url, token.as_deref())
.and_then(|content| write_curl_auth_config_or_warn(&content));
if let Some(ref p) = auth_cfg {
args.extend(curl_auth_args(Some(&p.to_string_lossy())));
}
let result = Command::new("curl")
.args(args)
.output()
.map_err(|e| MindError::io("curl", e));
if let Some(ref p) = auth_cfg {
remove_curl_auth_config(p);
}
result?
} else if have("wget") {
let mut args = wget_string_args(url, timeout);
args.extend(wget_auth_args(url, token.as_deref()));
Command::new("wget")
.args(args)
.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 token = github_token();
let status = if have("curl") {
let mut args = curl_file_args(url, &dest_str, timeout);
let auth_cfg = curl_auth_config_content(url, token.as_deref())
.and_then(|content| write_curl_auth_config_or_warn(&content));
if let Some(ref p) = auth_cfg {
args.extend(curl_auth_args(Some(&p.to_string_lossy())));
}
let result = Command::new("curl")
.args(args)
.status()
.map_err(|e| MindError::io("curl", e));
if let Some(ref p) = auth_cfg {
remove_curl_auth_config(p);
}
result?
} else if have("wget") {
let mut args = wget_file_args(url, &dest_str, timeout);
args.extend(wget_auth_args(url, token.as_deref()));
Command::new("wget")
.args(args)
.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 auth_config_content_for_api_host_and_wget_header() {
let url = "https://api.github.com/repos/jaemk/mind/releases/latest";
let content = curl_auth_config_content(url, Some("tok123"))
.expect("curl config content must be produced on the API host");
assert_eq!(
content, "header = \"Authorization: Bearer tok123\"\n",
"curl config content must carry the bearer header in curl config syntax: {content:?}"
);
let wget = wget_auth_args(url, Some("tok123"));
assert_eq!(
wget,
vec!["--header=Authorization: Bearer tok123".to_string()],
"wget must send the bearer header on the API host (argv form): {wget:?}"
);
}
#[test]
fn auth_never_leaks_token_to_non_api_hosts() {
let url = "https://github.com/jaemk/mind/releases/download/v1.2.3/mind-1.2.3-x.tar.gz";
assert!(
curl_auth_config_content(url, Some("tok123")).is_none(),
"curl must not build an auth config for github.com"
);
assert!(
wget_auth_args(url, Some("tok123")).is_empty(),
"wget must not send a token to github.com"
);
}
#[test]
fn auth_empty_without_a_token() {
let url = "https://api.github.com/repos/jaemk/mind/releases/latest";
assert!(
curl_auth_config_content(url, None).is_none(),
"no token -> no curl auth config"
);
assert!(
wget_auth_args(url, None).is_empty(),
"no token -> no wget header"
);
assert!(
curl_auth_config_content(url, Some("")).is_none(),
"empty token -> no curl auth config"
);
assert!(
wget_auth_args(url, Some("")).is_empty(),
"empty token -> no wget header"
);
}
#[test]
fn is_safe_token_rejects_control_chars_and_quote_backslash() {
assert!(is_safe_token("ghp_abcDEF1234567890"));
assert!(!is_safe_token("tok123\noutput = /tmp/pwned"));
assert!(!is_safe_token("tok123\r\nurl = https://evil.example/"));
assert!(!is_safe_token("tok\x00123"));
assert!(!is_safe_token("tok\x1b[31m123"));
assert!(!is_safe_token("tok\"123"));
assert!(!is_safe_token("tok\\123"));
assert!(is_safe_token(""));
}
#[test]
fn github_token_rejects_unsafe_env_value_and_falls_back() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig_gh = std::env::var("GITHUB_TOKEN").ok();
let orig_gh2 = std::env::var("GH_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", "tok123\noutput = /tmp/pwned");
std::env::remove_var("GH_TOKEN");
}
assert_eq!(
github_token(),
None,
"an unsafe GITHUB_TOKEN with no valid fallback must yield no token, not an error"
);
unsafe {
std::env::set_var("GITHUB_TOKEN", "tok123\noutput = /tmp/pwned");
std::env::set_var("GH_TOKEN", "goodtoken456");
}
assert_eq!(
github_token(),
Some("goodtoken456".to_string()),
"an unsafe GITHUB_TOKEN must fall through to a safe GH_TOKEN"
);
unsafe {
std::env::set_var("GITHUB_TOKEN", "cleantoken789");
std::env::remove_var("GH_TOKEN");
}
assert_eq!(
github_token(),
Some("cleantoken789".to_string()),
"a safe token must still be returned unchanged"
);
unsafe {
match orig_gh {
Some(v) => std::env::set_var("GITHUB_TOKEN", v),
None => std::env::remove_var("GITHUB_TOKEN"),
}
match orig_gh2 {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
drop(guard);
}
#[test]
fn write_curl_auth_config_or_warn_degrades_on_write_failure() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig_tmpdir = std::env::var("TMPDIR").ok();
let n = MKTEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let blocked_path = std::env::temp_dir().join(format!(
"mind-evolve-blocked-tmpdir-{}-{n}",
std::process::id()
));
std::fs::write(&blocked_path, b"not a directory").expect("seed blocking file");
unsafe {
std::env::set_var("TMPDIR", &blocked_path);
}
let result = write_curl_auth_config_or_warn("header = \"Authorization: Bearer x\"\n");
unsafe {
match &orig_tmpdir {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
}
drop(guard);
let _ = std::fs::remove_file(&blocked_path);
assert!(
result.is_none(),
"a temp-dir write failure must degrade to None (unauthenticated), not panic/error: {result:?}"
);
}
#[test]
fn curl_argv_uses_config_flag_and_never_carries_the_token() {
let path = "/run/mind-evolve-x/curl-auth.cfg";
let mut args = curl_string_args(
"https://api.github.com/repos/jaemk/mind/releases/latest",
15,
);
args.extend(curl_auth_args(Some(path)));
let cfg = args
.iter()
.position(|a| a == "--config")
.expect("curl args must include --config");
assert_eq!(
args[cfg + 1],
path,
"the config-file path must follow --config"
);
assert!(
!args.iter().any(|a| a.contains("tok123")),
"the token must never appear on curl argv: {args:?}"
);
assert!(
!args
.iter()
.any(|a| a == "-H" || a.starts_with("Authorization:")),
"curl argv must not carry an -H Authorization header: {args:?}"
);
assert!(
curl_auth_args(None).is_empty(),
"no config path -> no --config arg"
);
}
#[test]
fn curl_auth_config_file_is_owner_only_0600() {
let content = curl_auth_config_content(
"https://api.github.com/repos/jaemk/mind/releases/latest",
Some("tok123"),
)
.expect("content must be produced");
let path = write_curl_auth_config(&content).expect("must write the auth config");
let meta = std::fs::metadata(&path).expect("config file must exist");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"auth config file must be mode 0600, got {mode:o}"
);
let dir = path.parent().expect("config file must have a parent dir");
let dir_meta = std::fs::metadata(dir).expect("temp dir must exist");
assert!(
dir_meta.is_dir(),
"the auth config's parent must be a directory"
);
let dir_mode = dir_meta.permissions().mode() & 0o777;
assert_eq!(
dir_mode, 0o700,
"the auth config's temp directory must be mode 0700, got {dir_mode:o}"
);
let on_disk = std::fs::read_to_string(&path).expect("must read config back");
assert!(
on_disk.contains("Authorization: Bearer tok123"),
"the auth config file must carry the bearer header: {on_disk:?}"
);
remove_curl_auth_config(&path);
assert!(
!path.exists(),
"remove_curl_auth_config must delete the file"
);
assert!(
!dir.exists(),
"remove_curl_auth_config must also remove the temp directory"
);
}
#[test]
fn curl_file_argv_uses_config_flag_and_never_carries_the_token() {
let path = "/run/mind-evolve-y/curl-auth.cfg";
let mut args = curl_file_args(
"https://api.github.com/repos/jaemk/mind/releases/latest",
"/tmp/dest-file",
15,
);
args.extend(curl_auth_args(Some(path)));
let cfg = args
.iter()
.position(|a| a == "--config")
.expect("curl file-fetch args must include --config");
assert_eq!(
args[cfg + 1],
path,
"the config-file path must follow --config"
);
assert!(
!args.iter().any(|a| a.contains("tok123")),
"the token must never appear on curl file-fetch argv: {args:?}"
);
assert!(
!args
.iter()
.any(|a| a == "-H" || a.starts_with("Authorization:")),
"curl file-fetch argv must not carry an -H Authorization header: {args:?}"
);
}
use crate::paths::ENV_LOCK;
fn write_fake_failing_curl(dir: &Path, capture_path: &Path) {
let script_path = dir.join("curl");
let script = format!(
"#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done > {:?}\nexit 7\n",
capture_path
);
std::fs::write(&script_path, script).expect("write fake curl");
let mut perms = std::fs::metadata(&script_path)
.expect("stat fake curl")
.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&script_path, perms).expect("chmod fake curl");
}
#[test]
fn fetch_to_string_removes_auth_config_file_even_when_curl_fails() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = MKTEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let scratch =
std::env::temp_dir().join(format!("mind-evolve-fake-curl-{}-{n}", std::process::id()));
std::fs::create_dir_all(&scratch).expect("create scratch dir");
let capture_path = scratch.join("argv-capture.txt");
write_fake_failing_curl(&scratch, &capture_path);
let orig_path = std::env::var("PATH").unwrap_or_default();
let orig_gh_token = std::env::var("GITHUB_TOKEN").ok();
let orig_gh_token2 = std::env::var("GH_TOKEN").ok();
let new_path = format!("{}:{orig_path}", scratch.display());
unsafe {
std::env::set_var("PATH", &new_path);
std::env::set_var("GITHUB_TOKEN", "tok123");
std::env::remove_var("GH_TOKEN");
}
let result = fetch_to_string("https://api.github.com/repos/jaemk/mind/releases/latest");
unsafe {
std::env::set_var("PATH", &orig_path);
match orig_gh_token {
Some(v) => std::env::set_var("GITHUB_TOKEN", v),
None => std::env::remove_var("GITHUB_TOKEN"),
}
match orig_gh_token2 {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
drop(guard);
assert!(
result.is_err(),
"the fake curl exits non-zero, so fetch_to_string must report failure"
);
let argv = std::fs::read_to_string(&capture_path).expect("read captured argv");
let lines: Vec<&str> = argv.lines().collect();
let cfg_idx = lines
.iter()
.position(|a| *a == "--config")
.expect("curl must have been invoked with --config: {lines:?}");
let cfg_path = std::path::PathBuf::from(lines[cfg_idx + 1]);
assert!(
!cfg_path.exists(),
"the auth config file must be removed even though curl failed: {cfg_path:?}"
);
assert!(
!lines.iter().any(|a| a.contains("tok123")),
"the token must never appear on curl's argv: {lines:?}"
);
let _ = std::fs::remove_dir_all(&scratch);
}
#[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:?}"
);
}
#[test]
fn http_timeout_zero_clamped_to_default() {
assert_eq!(
clamp_http_timeout(Some(0)),
15,
"a zero value must clamp to the 15-second default"
);
assert_eq!(
clamp_http_timeout(None),
15,
"a missing value must default to 15"
);
assert_eq!(
clamp_http_timeout(Some(30)),
30,
"a non-zero value must pass through unchanged"
);
assert_eq!(
clamp_http_timeout(Some(1)),
1,
"the minimum non-zero value (1) must not be altered"
);
}
#[test]
fn wget_args_include_tries_1() {
let str_args = wget_string_args("https://example.com/data", 15);
assert!(
str_args.contains(&"--tries=1".to_string()),
"wget string-fetch must include --tries=1: {str_args:?}"
);
let file_args = wget_file_args("https://example.com/file.tar.gz", "/tmp/dest.tar.gz", 30);
assert!(
file_args.contains(&"--tries=1".to_string()),
"wget file-fetch must include --tries=1: {file_args:?}"
);
}
#[test]
fn maybe_proxy_hint_strips_ansi_and_bidi_from_reason() {
let ansi_reason = "download error \x1b[31mred\x1b[0m text";
let result = maybe_proxy_hint(ansi_reason);
assert!(
!result.contains('\x1b'),
"ANSI escape sequences must be stripped from the reason: {result:?}"
);
assert!(
result.contains("download error"),
"visible text must be preserved after stripping: {result:?}"
);
let bidi_reason = "pay \u{202E}oot";
let result = maybe_proxy_hint(bidi_reason);
assert!(
!result.contains('\u{202E}'),
"bidi override (U+202E) must be stripped: {result:?}"
);
let hostile_407 = "\x1b[1m407 Proxy Auth Required\x1b[0m \u{202E}spoofed";
let result = maybe_proxy_hint(hostile_407);
assert!(
!result.contains('\x1b'),
"ANSI must be stripped even in the proxy-hint branch: {result:?}"
);
assert!(
!result.contains('\u{202E}'),
"bidi must be stripped even in the proxy-hint branch: {result:?}"
);
assert!(
result.contains("HTTPS_PROXY"),
"proxy hint must still be appended when 407 is present: {result:?}"
);
}
#[test]
fn maybe_proxy_hint_curlrc_mention_no_git_proxy() {
let reason_407 = "Received HTTP code 407 from proxy";
let hint = maybe_proxy_hint(reason_407);
assert!(
!hint.contains("http.proxy"),
"hint must not mention git's http.proxy (ineffective for curl/wget): {hint:?}"
);
assert!(
hint.contains("curlrc") || hint.contains(".curlrc"),
"hint must mention ~/.curlrc as the NTLM/Kerberos escape hatch: {hint:?}"
);
assert!(
hint.contains("HTTPS_PROXY") || hint.contains("HTTP_PROXY"),
"hint must name HTTPS_PROXY or HTTP_PROXY: {hint:?}"
);
}
#[test]
fn maybe_proxy_hint_407_in_escape_sequence_does_not_trigger_hint() {
let colored = "\x1b[407mdownload timed out\x1b[0m";
let result = maybe_proxy_hint(colored);
assert!(
!result.contains('\x1b'),
"escape must be stripped: {result:?}"
);
assert!(
!result.contains("407"),
"the `407` ANSI parameter must be stripped, not surface as text: {result:?}"
);
assert!(
!result.contains("HTTPS_PROXY"),
"an ANSI-parameter 407 must NOT append the proxy hint: {result:?}"
);
assert_eq!(
result, "download timed out",
"only the visible message survives: {result:?}"
);
}
#[test]
fn maybe_proxy_hint_real_407_split_across_escapes_is_detected() {
let split = "HTTP 4\x1b[0m07 from proxy";
let result = maybe_proxy_hint(split);
assert!(
!result.contains('\x1b'),
"escape must be stripped: {result:?}"
);
assert!(
result.contains("407"),
"digits must rejoin into 407 after stripping the interior escape: {result:?}"
);
assert!(
result.contains("HTTPS_PROXY"),
"a real 407 (revealed after sanitization) must append the proxy hint: {result:?}"
);
}
#[test]
fn maybe_proxy_hint_could_not_resolve_proxy_branch() {
let reason = "wget: \x1b[31mCould not resolve proxy\x1b[0m: proxy.local";
let result = maybe_proxy_hint(reason);
assert!(
!result.contains('\x1b'),
"ANSI must be stripped in the resolve-proxy branch: {result:?}"
);
assert!(
result.contains("Could not resolve proxy"),
"the recognized phrase must survive sanitization: {result:?}"
);
assert!(
result.contains("HTTPS_PROXY"),
"the resolve-proxy phrase must trigger the proxy hint: {result:?}"
);
}
#[test]
fn maybe_proxy_hint_non_proxy_reason_is_returned_verbatim_after_sanitizing() {
let reason = "server returned HTTP 500";
let result = maybe_proxy_hint(reason);
assert_eq!(
result, "server returned HTTP 500",
"a non-proxy reason must pass through unchanged (already ASCII): {result:?}"
);
assert!(
!result.contains("HTTPS_PROXY"),
"a non-proxy reason must NOT append the proxy hint: {result:?}"
);
}
fn install_sh() -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/install.sh");
std::fs::read_to_string(&path).expect("resources/install.sh must exist and be readable")
}
#[test]
fn install_sh_every_wget_invocation_passes_tries_1() {
let script = install_sh();
let wget_lines: Vec<&str> = script
.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with("wget "))
.collect();
assert_eq!(
wget_lines.len(),
2,
"install.sh must invoke wget exactly twice (fetch + fetch_to); found: {wget_lines:?}"
);
for line in &wget_lines {
assert!(
line.contains("--tries=1"),
"every wget invocation in install.sh must pass --tries=1: {line:?}"
);
}
}
#[test]
fn install_sh_wget_invocations_carry_a_timeout() {
let script = install_sh();
for line in script
.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with("wget "))
{
assert!(
line.contains("--timeout="),
"every wget invocation in install.sh must pass --timeout=: {line:?}"
);
}
}
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-musl"
);
assert_eq!(
target_triple("linux", "aarch64").unwrap(),
"aarch64-unknown-linux-musl"
);
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, "x86_64-unknown-linux-musl");
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, "x86_64-unknown-linux-musl");
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, "x86_64-unknown-linux-musl");
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, "x86_64-unknown-linux-musl");
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"
);
}
#[test]
fn check_report_includes_the_target_triple_in_every_branch() {
let triple = "x86_64-unknown-linux-musl";
let update = decision("0.2.0", "0.3.0", false);
let report = check_report("0.2.0", "0.3.0", &update, triple);
assert!(
report.contains(triple),
"Update report must include the target triple: {report}"
);
assert!(
report.contains("available"),
"Update report must keep the existing wording: {report}"
);
let up_to_date = decision("0.3.0", "0.3.0", false);
let report = check_report("0.3.0", "0.3.0", &up_to_date, triple);
assert!(
report.contains(triple),
"UpToDate report must include the target triple: {report}"
);
assert!(
report.contains("up to date"),
"UpToDate report must keep the existing wording: {report}"
);
let pinned_below = Decision::PinnedBelowCurrent;
let report = check_report("0.3.0", "0.1.0", &pinned_below, triple);
assert!(
report.contains(triple),
"PinnedBelowCurrent report must include the target triple: {report}"
);
assert!(
report.contains("not downgrading"),
"PinnedBelowCurrent report must keep the existing wording: {report}"
);
}
#[test]
fn check_report_target_triple_distinguishes_musl_from_gnu() {
let musl_report = check_report(
"0.2.0",
"0.3.0",
&Decision::Update,
"x86_64-unknown-linux-musl",
);
assert!(musl_report.contains("musl"), "{musl_report}");
assert!(!musl_report.contains("gnu"), "{musl_report}");
let gnu_report = check_report(
"0.2.0",
"0.3.0",
&Decision::Update,
"x86_64-unknown-linux-gnu",
);
assert!(gnu_report.contains("gnu"), "{gnu_report}");
}
#[test]
fn gh_attestation_verify_args_builds_expected_argv() {
let args = gh_attestation_verify_args("/tmp/mind-0.3.0-x86_64.tar.gz", "jaemk/mind");
assert_eq!(
args,
vec![
"attestation".to_string(),
"verify".to_string(),
"/tmp/mind-0.3.0-x86_64.tar.gz".to_string(),
"--repo".to_string(),
"jaemk/mind".to_string(),
],
"argv must match `gh attestation verify <archive> --repo <repo>`: {args:?}"
);
}
#[test]
fn is_gh_tooling_error_classifies_known_tooling_markers() {
assert!(
is_gh_tooling_error("Error: unknown command \"attestation\" for \"gh\""),
"an unsupported subcommand (old gh) must classify as a tooling error"
);
assert!(
is_gh_tooling_error(
"Get \"https://api.github.com/...\": dial tcp: lookup api.github.com: no such host"
),
"a DNS/network failure must classify as a tooling error"
);
assert!(
is_gh_tooling_error("Error: connection refused"),
"a connection-refused failure must classify as a tooling error"
);
assert!(
is_gh_tooling_error(
"To use GitHub CLI in a GitHub Actions workflow, run: gh auth login"
),
"an auth-required message must classify as a tooling error"
);
assert!(is_gh_tooling_error("DIAL TCP: CONNECTION REFUSED"));
}
#[test]
fn is_gh_tooling_error_does_not_classify_no_attestations_found_as_tooling() {
assert!(
!is_gh_tooling_error("Error: no attestations found"),
"'no attestations found' must be a genuine failure, not a tooling error"
);
assert!(
!is_gh_tooling_error(
"Error: HTTP 404: Not Found (https://api.github.com/repos/jaemk/mind/attestations/sha256:deadbeef)"
),
"an HTTP 404 from the attestations endpoint must be a genuine failure, not a tooling error"
);
}
fn write_fake_gh(dir: &Path, capture_path: &Path, exit_code: i32, stderr_msg: &str) {
let script_path = dir.join("gh");
let script = format!(
"#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done > {:?}\nprintf '%s' {:?} >&2\nexit {exit_code}\n",
capture_path, stderr_msg
);
std::fs::write(&script_path, script).expect("write fake gh");
let mut perms = std::fs::metadata(&script_path)
.expect("stat fake gh")
.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&script_path, perms).expect("chmod fake gh");
}
#[test]
fn attestation_step_returns_none_when_gh_is_absent() {
let archive = std::env::temp_dir().join("mind-evolve-attest-none-does-not-exist.tar.gz");
assert_eq!(
attestation_step("mind-no-such-gh-xyzzy", &archive),
None,
"attestation_step must return None (skip silently) when gh is absent"
);
}
#[test]
fn attestation_step_verified_with_fake_gh_success() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = MKTEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let scratch =
std::env::temp_dir().join(format!("mind-evolve-fake-gh-ok-{}-{n}", std::process::id()));
std::fs::create_dir_all(&scratch).expect("create scratch dir");
let capture_path = scratch.join("argv-capture.txt");
write_fake_gh(&scratch, &capture_path, 0, "");
let orig_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{orig_path}", scratch.display());
unsafe {
std::env::set_var("PATH", &new_path);
}
let archive = scratch.join("mind-0.3.0-x86_64.tar.gz");
std::fs::write(&archive, b"fake archive").expect("seed fake archive");
let result = attestation_step("gh", &archive);
unsafe {
std::env::set_var("PATH", &orig_path);
}
drop(guard);
assert_eq!(
result,
Some(AttestationOutcome::Verified),
"an exit-0 gh must yield Verified"
);
let argv = std::fs::read_to_string(&capture_path).expect("read captured argv");
let lines: Vec<&str> = argv.lines().collect();
assert_eq!(
lines,
vec![
"attestation",
"verify",
archive.to_string_lossy().as_ref(),
"--repo",
"jaemk/mind",
],
"gh must be invoked with the expected argv: {lines:?}"
);
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn attestation_step_tooling_error_with_fake_gh_network_failure() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = MKTEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let scratch = std::env::temp_dir().join(format!(
"mind-evolve-fake-gh-tooling-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&scratch).expect("create scratch dir");
let capture_path = scratch.join("argv-capture.txt");
write_fake_gh(
&scratch,
&capture_path,
1,
"dial tcp: lookup api.github.com: no such host",
);
let orig_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{orig_path}", scratch.display());
unsafe {
std::env::set_var("PATH", &new_path);
}
let archive = scratch.join("mind-0.3.0-x86_64.tar.gz");
std::fs::write(&archive, b"fake archive").expect("seed fake archive");
let result = attestation_step("gh", &archive);
unsafe {
std::env::set_var("PATH", &orig_path);
}
drop(guard);
match result {
Some(AttestationOutcome::ToolingError(reason)) => {
assert!(
reason.contains("no such host"),
"reason must carry gh's stderr: {reason}"
);
}
other => panic!("expected Some(ToolingError(_)), got {other:?}"),
}
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn attestation_step_genuine_failure_with_fake_gh_no_attestations_aborts() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = MKTEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let scratch = std::env::temp_dir().join(format!(
"mind-evolve-fake-gh-fail-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&scratch).expect("create scratch dir");
let capture_path = scratch.join("argv-capture.txt");
write_fake_gh(&scratch, &capture_path, 1, "Error: no attestations found");
let orig_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{orig_path}", scratch.display());
unsafe {
std::env::set_var("PATH", &new_path);
}
let archive = scratch.join("mind-0.3.0-x86_64.tar.gz");
std::fs::write(&archive, b"fake archive").expect("seed fake archive");
let result = attestation_step("gh", &archive);
unsafe {
std::env::set_var("PATH", &orig_path);
}
drop(guard);
match result {
Some(AttestationOutcome::GenuineFailure(reason)) => {
assert!(
reason.contains("no attestations found"),
"reason must carry gh's stderr: {reason}"
);
let mapped = MindError::AttestationVerificationFailed { reason };
assert_eq!(mapped.kind(), "attestation-verification-failed");
}
other => panic!("expected Some(GenuineFailure(_)), got {other:?}"),
}
let _ = std::fs::remove_dir_all(&scratch);
}
}