use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use self_update::backends::github;
use crate::error::{MindError, Result};
use crate::mindfile::version_at_least;
const REPO_OWNER: &str = "jaemk";
const REPO_NAME: &str = "mind";
const REPO: &str = "jaemk/mind";
const SHA256SUMS_ASSET: &str = "SHA256SUMS";
#[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 decision(current: &str, target: &str, explicit: bool) -> Decision {
if version_at_least(current, target) {
if version_at_least(target, current) {
return tie_break(current, target, explicit);
}
if explicit {
Decision::PinnedBelowCurrent
} else {
Decision::UpToDate
}
} else {
Decision::Update
}
}
fn tie_break(current: &str, target: &str, explicit: bool) -> Decision {
if current == target {
return Decision::UpToDate;
}
match (is_prerelease(current), is_prerelease(target)) {
(false, false) => Decision::UpToDate,
(true, false) => Decision::Update,
(false, true) => {
if explicit {
Decision::PinnedBelowCurrent
} else {
Decision::UpToDate
}
}
(true, true) => {
let cp = prerelease_suffix(current).unwrap_or_default();
let tp = prerelease_suffix(target).unwrap_or_default();
match prerelease_cmp(cp, tp) {
std::cmp::Ordering::Less => Decision::Update,
std::cmp::Ordering::Equal => Decision::UpToDate,
std::cmp::Ordering::Greater => {
if explicit {
Decision::PinnedBelowCurrent
} else {
Decision::UpToDate
}
}
}
}
}
}
fn is_prerelease(v: &str) -> bool {
v.split_once('+').map_or(v, |(base, _)| base).contains('-')
}
fn prerelease_suffix(v: &str) -> Option<&str> {
let base_and_pre = v.split_once('+').map_or(v, |(base, _)| base);
base_and_pre.split_once('-').map(|(_, pre)| pre)
}
fn semver_identifier_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let a_numeric = !a.is_empty() && a.bytes().all(|c| c.is_ascii_digit());
let b_numeric = !b.is_empty() && b.bytes().all(|c| c.is_ascii_digit());
match (a_numeric, b_numeric) {
(true, true) => {
let an: u128 = a.parse().unwrap_or(u128::MAX);
let bn: u128 = b.parse().unwrap_or(u128::MAX);
an.cmp(&bn)
}
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(false, false) => a.cmp(b),
}
}
fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let mut ai = a.split('.');
let mut bi = b.split('.');
loop {
match (ai.next(), bi.next()) {
(None, None) => return std::cmp::Ordering::Equal,
(None, Some(_)) => return std::cmp::Ordering::Less,
(Some(_), None) => return std::cmp::Ordering::Greater,
(Some(x), Some(y)) => {
let c = semver_identifier_cmp(x, y);
if c != std::cmp::Ordering::Equal {
return c;
}
}
}
}
}
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}; \
--to {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.as_deref() {
Some(v) => validated_target(v)?,
None => validated_target(&latest_version(&updater(target, None, lookup_timeout())?)?)?,
};
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 upd = updater(target, Some(&target_version), download_timeout())?;
download_and_swap(upd, 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,
}))
}
#[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 updater(
target: &str,
tag: Option<&str>,
timeout: std::time::Duration,
) -> Result<github::Update> {
let out = crate::render::ctx();
let mut builder = github::Update::configure();
builder
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name("mind")
.target(target)
.current_version(env!("CARGO_PKG_VERSION"))
.auth_token_from_env()
.timeout(timeout)
.no_confirm(true)
.show_output(false)
.show_download_progress(!out.json)
.check_install_path_writable(true)
.checksum_from_asset(SHA256SUMS_ASSET)
.verify_archive(|archive: &Path| attestation_gate(archive));
if let Some(tag) = tag {
builder.release_tag(format!("v{tag}"));
}
builder.build().map_err(map_update_error)
}
fn validated_target(raw: &str) -> Result<String> {
let value = raw.strip_prefix('v').unwrap_or(raw).to_string();
if !crate::mindfile::is_plausible_release_tag(&value) {
return Err(MindError::SelfUpdateInvalidTarget { value });
}
Ok(value)
}
fn latest_version(upd: &github::Update) -> Result<String> {
let releases = upd.get_latest_release().map_err(map_update_error)?;
let release = releases.latest().ok_or_else(|| MindError::DownloadFailed {
url: format!("https://api.github.com/repos/{REPO}/releases/latest"),
reason: "the release listing is empty".to_string(),
})?;
Ok(release.version().to_string())
}
fn attestation_gate(archive: &Path) -> std::result::Result<(), self_update::Error> {
let out = crate::render::ctx();
match attestation_step("gh", archive) {
None => Ok(()),
Some(AttestationOutcome::Verified) => {
if !out.json {
println!("{} build provenance verified", out.ok());
}
Ok(())
}
Some(AttestationOutcome::ToolingError(reason)) => {
if !out.json {
println!(
"{} build provenance could not be verified ({reason}); continuing",
out.warn()
);
}
Ok(())
}
Some(AttestationOutcome::GenuineFailure(reason)) => {
Err(self_update::Error::archive_verification_rejected(reason))
}
}
}
fn download_and_swap(
upd: github::Update,
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();
if let Some(line) = download_banner(out.json, target_version, target_triple) {
println!("{line}");
}
upd.update().map_err(map_update_error)?;
if out.json {
return print_evolve_json(target_version, "updated", target_triple);
}
println!("{} updated mind {current} -> {target_version}", out.ok());
Ok(())
}
fn download_banner(json: bool, target_version: &str, target_triple: &str) -> Option<String> {
if json {
return None;
}
let out = crate::render::ctx();
Some(format!(
"{} downloading mind {target_version} ({})",
out.bullet(),
out.dim(&format!("{target_triple} from {REPO}"))
))
}
fn map_update_error(e: self_update::Error) -> MindError {
let release_url = format!("https://github.com/{REPO}/releases");
match e {
self_update::Error::ArchiveVerificationRejected { reason, .. } => {
MindError::AttestationVerificationFailed {
reason: crate::sanitize::strip_ansi(&reason.unwrap_or_default()),
}
}
self_update::Error::ChecksumMismatch {
expected, computed, ..
} => MindError::DigestMismatch {
url: release_url,
expected,
actual: computed,
},
self_update::Error::ChecksumSourceInvalid { asset, reason, .. } => {
MindError::DigestMismatch {
url: release_url,
expected: format!(
"(from {SHA256SUMS_ASSET}: {})",
crate::sanitize::strip_ansi(&reason)
),
actual: crate::sanitize::strip_ansi(&asset),
}
}
self_update::Error::InstallPathNotWritable { path, .. } => MindError::TargetNotWritable {
path: path.display().to_string(),
},
self_update::Error::NoReleaseFound { .. } => MindError::ReleaseAssetEmpty,
ref rate_limited @ self_update::Error::RateLimited { .. } => {
auth_refusal(rate_limited, true)
}
ref unauthorized @ self_update::Error::Unauthorized { .. } => {
auth_refusal(unauthorized, false)
}
other => MindError::DownloadFailed {
url: release_url,
reason: maybe_proxy_hint(&other.to_string()),
},
}
}
const CERT_MARKERS: &[&str] = &[
"invalid peer certificate",
"certificate verify",
"unknownissuer",
"self-signed certificate",
];
fn maybe_proxy_hint(reason: &str) -> String {
let reason = crate::sanitize::strip_ansi(reason);
let lower = reason.to_ascii_lowercase();
if reason.contains("407") || lower.contains("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)"
)
} else if CERT_MARKERS.iter().any(|m| lower.contains(m)) {
format!(
"{reason}\nhint: mind verifies TLS against your machine's certificate store. \
If your network intercepts HTTPS with a company CA, install that CA in the \
system store, or point mind at a bundle containing it \
(e.g. export SSL_CERT_FILE=/path/to/corporate-ca.pem)"
)
} else {
reason
}
}
const AUTH_TOKEN_ENV_VARS: &[&str] = &["GH_TOKEN", "GITHUB_TOKEN"];
fn auth_token_in_env() -> bool {
AUTH_TOKEN_ENV_VARS
.iter()
.any(|var| std::env::var(var).is_ok_and(|v| !v.trim().is_empty()))
}
fn token_hint(rate_limited: bool) -> &'static str {
match (auth_token_in_env(), rate_limited) {
(false, _) => {
"hint: the request went out unauthenticated, and GitHub's anonymous budget of 60 \
requests/hour is counted per source IP, so a NAT'd or proxied network shares one \
budget. Set GH_TOKEN or GITHUB_TOKEN for the authenticated 5000/hour budget, e.g. \
GH_TOKEN=$(gh auth token) mind evolve"
}
(true, true) => {
"hint: GH_TOKEN/GITHUB_TOKEN is set, so this is that token's own budget; retry once \
it resets"
}
(true, false) => {
"hint: GH_TOKEN/GITHUB_TOKEN is set and was rejected; check that it is valid and not \
expired (gh auth status)"
}
}
}
fn humanize_secs(secs: u64) -> String {
let (hours, minutes, seconds) = (secs / 3600, (secs % 3600) / 60, secs % 60);
match (hours, minutes, seconds) {
(0, 0, s) => format!("{s}s"),
(0, m, 0) => format!("{m}m"),
(0, m, s) => format!("{m}m{s}s"),
(h, 0, _) => format!("{h}h"),
(h, m, _) => format!("{h}h{m}m"),
}
}
fn auth_refusal(err: &self_update::Error, rate_limited: bool) -> MindError {
let what = if rate_limited {
"rate limited"
} else {
"rejected"
};
let status = err
.http_status()
.map(|s| format!("HTTP {s}"))
.unwrap_or_else(|| "a refusal".to_string());
let url = crate::sanitize::strip_ansi(err.url().unwrap_or("the GitHub API"));
let wait = err
.rate_limit_delay()
.map(|d| format!(" (retry in {})", humanize_secs(d.as_secs())))
.unwrap_or_default();
MindError::SelfUpdateRefused {
reason: format!(
"GitHub {what} this request: {status} from {url}{wait}\n{}",
token_hint(rate_limited)
),
}
}
static MKTEMP_SEQ: AtomicU64 = AtomicU64::new(0);
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()),
)
}
const DOWNLOAD_TIMEOUT_CEILING_SECS: u64 = 600;
fn lookup_timeout() -> std::time::Duration {
std::time::Duration::from_secs(http_timeout_secs())
}
fn download_timeout() -> std::time::Duration {
std::time::Duration::from_secs(http_timeout_secs().max(DOWNLOAD_TIMEOUT_CEILING_SECS))
}
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::*;
use crate::paths::ENV_LOCK;
#[test]
fn run_refuses_an_explicit_to_target_with_path_segments_with_no_network_call() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig_policy_file = std::env::var("MIND_POLICY_FILE").ok();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
}
let result = run(
true,
false,
Some("1/../../../../attacker/mind/releases/download/v1".to_string()),
);
unsafe {
match orig_policy_file {
Some(v) => std::env::set_var("MIND_POLICY_FILE", v),
None => std::env::remove_var("MIND_POLICY_FILE"),
}
}
drop(guard);
match result {
Err(MindError::SelfUpdateInvalidTarget { value }) => {
assert!(
value.contains("attacker"),
"must name the rejected --to value: {value}"
);
}
other => panic!("expected a SelfUpdateInvalidTarget refusal, got {other:?}"),
}
}
#[test]
fn run_accepts_an_explicit_prerelease_to_target_and_reaches_decision_end_to_end() {
let current = env!("CARGO_PKG_VERSION");
let major: u64 = current
.split(['.', '-', '+'])
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let target = format!("{}.0.0-rc1", major + 1);
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig_policy_file = std::env::var("MIND_POLICY_FILE").ok();
let orig_tty = std::env::var("MIND_TTY").ok();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
std::env::set_var("MIND_TTY", "0");
}
let result = run(false, false, Some(target.clone()));
unsafe {
match orig_policy_file {
Some(v) => std::env::set_var("MIND_POLICY_FILE", v),
None => std::env::remove_var("MIND_POLICY_FILE"),
}
match orig_tty {
Some(v) => std::env::set_var("MIND_TTY", v),
None => std::env::remove_var("MIND_TTY"),
}
}
drop(guard);
match result {
Err(MindError::ConfirmationRequired { action }) => {
assert!(
action.contains(&target),
"must be prompting to update to the resolved prerelease target: {action}"
);
}
other => panic!(
"expected ConfirmationRequired (proving the prerelease target passed \
validation and reached an Update decision), got {other:?}"
),
}
}
#[test]
fn run_refuses_traversal_smuggled_inside_an_explicit_prerelease_suffix() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig_policy_file = std::env::var("MIND_POLICY_FILE").ok();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
}
let result = run(true, false, Some("1.0.0-../..".to_string()));
unsafe {
match orig_policy_file {
Some(v) => std::env::set_var("MIND_POLICY_FILE", v),
None => std::env::remove_var("MIND_POLICY_FILE"),
}
}
drop(guard);
match result {
Err(MindError::SelfUpdateInvalidTarget { value }) => {
assert!(
value.contains("1.0.0-../.."),
"must name the rejected --to value: {value}"
);
}
other => panic!("expected a SelfUpdateInvalidTarget refusal, got {other:?}"),
}
}
#[test]
fn run_refuses_a_pure_dot_run_smuggled_inside_an_explicit_prerelease_suffix() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig_policy_file = std::env::var("MIND_POLICY_FILE").ok();
unsafe {
std::env::remove_var("MIND_POLICY_FILE");
}
let result = run(true, false, Some("1.0.0-..".to_string()));
unsafe {
match orig_policy_file {
Some(v) => std::env::set_var("MIND_POLICY_FILE", v),
None => std::env::remove_var("MIND_POLICY_FILE"),
}
}
drop(guard);
match result {
Err(MindError::SelfUpdateInvalidTarget { value }) => {
assert!(
value.contains("1.0.0-.."),
"must name the rejected --to value: {value}"
);
}
other => panic!("expected a SelfUpdateInvalidTarget refusal, got {other:?}"),
}
}
#[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 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_names_only_settings_that_apply() {
let hint = maybe_proxy_hint("Received HTTP code 407 from proxy");
assert!(
!hint.contains("http.proxy"),
"hint must not mention git's http.proxy: {hint:?}"
);
assert!(
!hint.contains("curlrc"),
"hint must not mention ~/.curlrc, which the updater does not read: {hint:?}"
);
assert!(
hint.contains("HTTPS_PROXY") && hint.contains("HTTP_PROXY"),
"hint must name HTTPS_PROXY and 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 with_token_env<T>(token: Option<&str>, body: impl FnOnce() -> T) -> T {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let saved: Vec<(&str, Option<String>)> = AUTH_TOKEN_ENV_VARS
.iter()
.map(|var| (*var, std::env::var(var).ok()))
.collect();
unsafe {
for (var, _) in &saved {
std::env::remove_var(var);
}
if let Some(value) = token {
std::env::set_var("GH_TOKEN", value);
}
}
let result = body();
unsafe {
for (var, value) in &saved {
match value {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
}
drop(guard);
result
}
const API_URL: &str = "https://api.github.com/repos/jaemk/mind/releases/latest";
fn spent_quota_403(reset_in: u64) -> self_update::Error {
let reset = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after the epoch")
.as_secs()
+ reset_in;
let mut headers = self_update::http_client::HeaderMap::new();
headers.insert("x-ratelimit-remaining", "0".parse().unwrap());
headers.insert("x-ratelimit-reset", reset.to_string().parse().unwrap());
self_update::Error::http_status_error_with_headers(403, API_URL, &headers)
}
fn retry_after_403(secs: u64) -> self_update::Error {
let mut headers = self_update::http_client::HeaderMap::new();
headers.insert("retry-after", secs.to_string().parse().unwrap());
self_update::Error::http_status_error_with_headers(403, API_URL, &headers)
}
#[test]
fn a_refused_request_names_the_token_when_the_environment_supplies_none() {
let msg = with_token_env(None, || map_update_error(spent_quota_403(2400))).to_string();
assert!(
msg.contains("GH_TOKEN") && msg.contains("GITHUB_TOKEN"),
"the hint must name both variables the lookup reads: {msg:?}"
);
assert!(
msg.contains("gh auth token"),
"the hint must give a runnable way to supply a token: {msg:?}"
);
assert!(
msg.contains("per source IP"),
"the hint must explain why a shared egress exhausts the budget: {msg:?}"
);
assert!(
msg.contains("HTTP 403") && msg.contains(API_URL),
"the message must name the status and the endpoint that refused: {msg:?}"
);
assert!(
msg.contains("retry in 39m") || msg.contains("retry in 40m"),
"a known quota reset must be reported as a wait: {msg:?}"
);
}
#[test]
fn a_reported_wait_is_humanized_not_a_raw_second_count() {
let msg = with_token_env(None, || map_update_error(retry_after_403(2411))).to_string();
assert!(
msg.contains("(retry in 40m11s)"),
"the wait must be rendered in minutes and seconds: {msg:?}"
);
assert!(
!msg.contains("2411"),
"the raw second count must not survive: {msg:?}"
);
}
#[test]
fn humanize_secs_shows_at_most_two_units_largest_first() {
for (secs, expected) in [
(0, "0s"),
(1, "1s"),
(59, "59s"),
(60, "1m"),
(61, "1m1s"),
(2411, "40m11s"),
(3599, "59m59s"),
(3600, "1h"),
(3601, "1h"),
(3660, "1h1m"),
(3661, "1h1m"),
(7322, "2h2m"),
] {
assert_eq!(
humanize_secs(secs),
expected,
"humanize_secs({secs}) must render as {expected}"
);
}
}
#[test]
fn a_refused_request_does_not_tell_you_to_set_a_token_you_already_set() {
let limited = with_token_env(Some("t"), || map_update_error(spent_quota_403(60)));
let limited = limited.to_string();
assert!(
limited.contains("retry once it resets"),
"an authenticated rate limit must advise waiting: {limited:?}"
);
assert!(
!limited.contains("gh auth token"),
"an authenticated failure must NOT advise setting a token: {limited:?}"
);
let rejected = with_token_env(Some("t"), || {
map_update_error(self_update::Error::http_status_error(401, API_URL))
});
let rejected = rejected.to_string();
assert!(
rejected.contains("was rejected") && rejected.contains("gh auth status"),
"a rejected credential must advise checking the token: {rejected:?}"
);
assert!(
!rejected.contains("retry once it resets"),
"a rejected credential is not a wait: {rejected:?}"
);
}
#[test]
fn a_refused_request_treats_a_blank_token_as_no_token() {
let msg = with_token_env(Some(" "), || map_update_error(spent_quota_403(60))).to_string();
assert!(
msg.contains("unauthenticated") && msg.contains("gh auth token"),
"a whitespace-only token must count as no token: {msg:?}"
);
}
#[test]
fn a_refused_request_does_not_repeat_the_crates_generic_token_advice() {
let msg = with_token_env(Some("t"), || map_update_error(spent_quota_403(60))).to_string();
assert!(
!msg.contains("set an auth token"),
"the crate's generic advice must not survive into the message: {msg:?}"
);
assert_eq!(
msg.matches("GH_TOKEN").count(),
1,
"the token must be named exactly once: {msg:?}"
);
}
#[test]
fn a_bare_rate_limit_reports_no_wait_it_does_not_know() {
let msg = with_token_env(None, || {
map_update_error(self_update::Error::http_status_error(429, API_URL))
})
.to_string();
assert!(
!msg.contains("retry in"),
"an unknown wait must be omitted, not guessed: {msg:?}"
);
assert!(
msg.contains("HTTP 429"),
"the status must still be reported: {msg:?}"
);
}
#[test]
fn a_refused_request_sanitizes_the_endpoint_controlled_url() {
let hostile = "https://api.github.com/\x1b[31mx\x1b[0m";
let msg = with_token_env(None, || {
map_update_error(self_update::Error::http_status_error(403, hostile))
})
.to_string();
assert!(
!msg.contains('\x1b'),
"ANSI must be stripped from the endpoint-supplied URL: {msg:?}"
);
}
#[test]
fn map_update_error_routes_refusals_away_from_the_transport_hints() {
let rate_limited = self_update::Error::http_status_error(429, API_URL);
let unauthorized = self_update::Error::http_status_error(403, API_URL);
assert!(
matches!(rate_limited, self_update::Error::RateLimited { .. })
&& matches!(unauthorized, self_update::Error::Unauthorized { .. }),
"the fixtures must be the two variants under test"
);
for err in [rate_limited, unauthorized] {
let label = err.to_string();
let mapped = with_token_env(None, || map_update_error(err));
assert_eq!(
mapped.kind(),
"self-update-refused",
"{label} must carry its own error kind"
);
let msg = mapped.to_string();
assert!(
msg.contains("GH_TOKEN"),
"{label} must carry the token hint: {msg:?}"
);
assert!(
!msg.contains("HTTPS_PROXY") && !msg.contains("SSL_CERT_FILE"),
"{label} must not carry transport advice: {msg:?}"
);
assert!(
!msg.contains("failed to download"),
"{label} must not be reported as a download failure: {msg:?}"
);
}
}
#[test]
fn the_self_update_dependency_trusts_the_machines_certificate_store() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let manifest = std::fs::read_to_string(&path).expect("Cargo.toml must be readable");
let dep = manifest
.split_once("self_update = {")
.expect("Cargo.toml must declare the self_update dependency")
.1
.split_once('}')
.expect("the self_update dependency table must be closed")
.0;
assert!(
dep.contains("\"native-certs\""),
"self_update must enable `native-certs` so evolve verifies against the \
machine's certificate store (STO-78); got: {dep}"
);
}
#[test]
fn maybe_proxy_hint_certificate_failure_points_at_the_trust_store() {
let reason = "io: invalid peer certificate: UnknownIssuer";
let result = maybe_proxy_hint(reason);
assert!(
result.starts_with(reason),
"the original reason must be preserved ahead of the hint: {result:?}"
);
assert!(
result.contains("SSL_CERT_FILE"),
"a certificate failure must name the bundle override: {result:?}"
);
assert!(
result.contains("certificate store"),
"a certificate failure must name the machine's trust store: {result:?}"
);
assert!(
!result.contains("HTTPS_PROXY"),
"a certificate failure must NOT get the proxy hint: {result:?}"
);
}
#[test]
fn maybe_proxy_hint_certificate_markers_are_matched_case_insensitively() {
for reason in [
"Invalid Peer Certificate: UnknownIssuer",
"SSL certificate problem: unable to get local issuer certificate verify failed",
"tls handshake failed: UNKNOWNISSUER",
"Self-Signed Certificate in certificate chain",
] {
let result = maybe_proxy_hint(reason);
assert!(
result.contains("SSL_CERT_FILE"),
"{reason:?} must be recognized as a certificate failure: {result:?}"
);
}
}
#[test]
fn maybe_proxy_hint_prefers_the_proxy_hint_when_both_could_match() {
let reason = "407 Proxy Authentication Required (invalid peer certificate: UnknownIssuer)";
let result = maybe_proxy_hint(reason);
assert!(
result.contains("HTTPS_PROXY"),
"a 407 must take the proxy hint even alongside certificate wording: {result:?}"
);
assert!(
!result.contains("SSL_CERT_FILE"),
"the two hints must be mutually exclusive: {result:?}"
);
}
#[test]
fn maybe_proxy_hint_strips_ansi_and_bidi_from_a_certificate_reason() {
let hostile = "\x1b[1minvalid peer certificate\x1b[0m: \u{202E}UnknownIssuer";
let result = maybe_proxy_hint(hostile);
assert!(
!result.contains('\x1b'),
"ANSI must be stripped in the certificate branch: {result:?}"
);
assert!(
!result.contains('\u{202E}'),
"bidi overrides must be stripped in the certificate branch: {result:?}"
);
assert!(
result.contains("SSL_CERT_FILE"),
"sanitization must not suppress the certificate 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}"
);
assert!(
detail.contains("--to 0.15.0"),
"must name the canonical --to flag, not the hidden --version alias: {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 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 decision_prerelease_current_updates_onto_its_base_release() {
assert_eq!(decision("0.23.1-dev", "0.23.1", false), Decision::Update);
assert_eq!(decision("0.23.1-dev", "0.23.1", true), Decision::Update);
assert_eq!(decision("0.24.0-dev", "0.23.1", false), Decision::UpToDate);
assert_eq!(
decision("0.24.0-dev", "0.23.1", true),
Decision::PinnedBelowCurrent
);
assert_eq!(
decision("0.23.1-dev", "0.23.1-dev", false),
Decision::UpToDate
);
assert_eq!(decision("0.23.1", "0.23.1", false), Decision::UpToDate);
}
#[test]
fn decision_dotted_prerelease_current_updates_onto_its_base_release() {
assert_eq!(decision("1.0.0-rc.2", "1.0.0", false), Decision::Update);
assert_eq!(decision("1.0.0-rc.2", "1.0.0", true), Decision::Update);
assert_eq!(decision("1.1.0-rc.2", "1.0.0", false), Decision::UpToDate);
assert_eq!(
decision("1.1.0-rc.2", "1.0.0", true),
Decision::PinnedBelowCurrent
);
assert_eq!(
decision("1.0.0-rc.2", "1.0.0-rc.2", false),
Decision::UpToDate
);
}
#[test]
fn decision_explicit_prerelease_to_prerelease_tie_break_orders_by_precedence() {
assert_eq!(
decision("0.24.0-rc1", "0.24.0-rc2", true),
Decision::Update,
"pinning a HIGHER rc of the same base must offer the update"
);
assert_eq!(
decision("0.24.0-rc2", "0.24.0-rc1", true),
Decision::PinnedBelowCurrent,
"pinning a LOWER rc of the same base must be a refused downgrade, not UpToDate"
);
assert_eq!(
decision("0.24.0-rc2", "0.24.0-rc1", false),
Decision::UpToDate
);
assert_eq!(
decision("0.24.0-rc1", "0.24.0-rc1", true),
Decision::UpToDate
);
assert_eq!(decision("0.24.0-9", "0.24.0-10", true), Decision::Update);
assert_eq!(
decision("0.24.0-10", "0.24.0-9", true),
Decision::PinnedBelowCurrent
);
assert_eq!(
decision("0.24.0-rc.1", "0.24.0-rc.1.2", true),
Decision::Update
);
assert_eq!(
decision("0.24.0-rc.1.2", "0.24.0-rc.1", true),
Decision::PinnedBelowCurrent
);
}
#[test]
fn decision_explicit_release_to_same_base_prerelease_is_pinned_below_current() {
assert_eq!(
decision("0.24.0", "0.24.0-rc1", true),
Decision::PinnedBelowCurrent
);
assert_eq!(decision("0.24.0", "0.24.0-rc1", false), Decision::UpToDate);
}
#[test]
fn is_prerelease_detects_only_a_dash_suffix() {
assert!(is_prerelease("0.23.1-dev"));
assert!(is_prerelease("1.0.0-rc.2"));
assert!(!is_prerelease("0.23.1"));
assert!(!is_prerelease("0.23.1+build"));
}
#[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}"
);
}
#[test]
fn the_download_budget_is_never_the_metadata_budget() {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let orig = std::env::var("MIND_HTTP_TIMEOUT_SECS").ok();
unsafe { std::env::remove_var("MIND_HTTP_TIMEOUT_SECS") };
let (default_lookup, default_download) = (lookup_timeout(), download_timeout());
unsafe { std::env::set_var("MIND_HTTP_TIMEOUT_SECS", "30") };
let (raised_lookup, raised_download) = (lookup_timeout(), download_timeout());
unsafe { std::env::set_var("MIND_HTTP_TIMEOUT_SECS", "900") };
let above_ceiling = download_timeout();
unsafe {
match orig {
Some(v) => std::env::set_var("MIND_HTTP_TIMEOUT_SECS", v),
None => std::env::remove_var("MIND_HTTP_TIMEOUT_SECS"),
}
}
drop(guard);
assert_eq!(default_lookup.as_secs(), 15, "the default metadata budget");
assert_eq!(
default_download.as_secs(),
600,
"the download must get the 600s ceiling, not the 15s metadata budget"
);
assert_eq!(
raised_lookup.as_secs(),
30,
"MIND_HTTP_TIMEOUT_SECS sets the metadata budget"
);
assert_eq!(
raised_download.as_secs(),
600,
"a knob below the ceiling must not shrink the download budget"
);
assert_eq!(
above_ceiling.as_secs(),
900,
"a knob above the ceiling wins: raising it asks for more room, not less"
);
}
#[test]
fn validated_target_strips_exactly_one_leading_v() {
assert_eq!(validated_target("1.2.3").unwrap(), "1.2.3");
assert_eq!(
validated_target("v1.2.3").unwrap(),
"1.2.3",
"`--to v1.2.3` must behave identically to `--to 1.2.3`"
);
assert_eq!(validated_target("v1.2.3-rc1").unwrap(), "1.2.3-rc1");
assert!(
validated_target("vv1.2.3").is_err(),
"only one leading `v` is stripped, so the second one must fail validation"
);
}
#[test]
fn an_explicit_target_pins_a_single_v_prefixed_release_tag() {
use self_update::UpdateConfig as _;
let version = validated_target("v1.2.3").expect("a plain version validates");
let upd = updater(
"x86_64-unknown-linux-musl",
Some(&version),
download_timeout(),
)
.expect("building the updater must not require the network");
assert_eq!(upd.release_tag(), Some("v1.2.3"));
}
#[test]
fn no_explicit_target_leaves_the_updater_unpinned() {
use self_update::UpdateConfig as _;
let upd = updater("x86_64-unknown-linux-musl", None, lookup_timeout())
.expect("building the updater must not require the network");
assert_eq!(upd.release_tag(), None);
assert_eq!(
upd.target(),
"x86_64-unknown-linux-musl",
"the resolved triple selects the release asset (STO-65)"
);
}
#[test]
fn download_banner_is_suppressed_under_json() {
assert_eq!(
download_banner(true, "1.2.3", "x86_64-unknown-linux-musl"),
None,
"--json must emit no progress line"
);
let line = download_banner(false, "1.2.3", "x86_64-unknown-linux-musl")
.expect("text mode must still announce the download");
assert!(
line.contains("downloading mind 1.2.3"),
"the banner must name the version being installed: {line}"
);
assert!(
line.contains("x86_64-unknown-linux-musl"),
"the banner must name the artifact's target triple: {line}"
);
}
#[test]
fn mktemp_dir_prefixed_creates_a_fresh_directory() {
let dir = mktemp_dir_prefixed("mind-test").expect("mktemp_dir_prefixed");
let exists = dir.is_dir();
let named = dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("mind-test-"));
let _ = std::fs::remove_dir_all(&dir);
assert!(exists, "the directory must be created: {dir:?}");
assert!(
named,
"the caller's prefix must name the directory: {dir:?}"
);
}
#[test]
fn mktemp_dir_prefixed_yields_distinct_paths() {
let a = mktemp_dir_prefixed("mind-test").expect("first mktemp_dir_prefixed");
let b = mktemp_dir_prefixed("mind-test").expect("second mktemp_dir_prefixed");
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
assert_ne!(a, b, "successive calls must yield distinct paths");
}
#[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);
}
#[test]
fn a_refused_request_renders_as_one_status_line_and_one_hint_line() {
let msg = with_token_env(Some("t"), || {
map_update_error(self_update::Error::http_status_error(401, API_URL))
})
.to_string();
let lines: Vec<&str> = msg.lines().collect();
assert_eq!(
lines,
vec![
"GitHub rejected this request: HTTP 401 from \
https://api.github.com/repos/jaemk/mind/releases/latest",
"hint: GH_TOKEN/GITHUB_TOKEN is set and was rejected; check that it is valid and \
not expired (gh auth status)",
],
"the rendered shape changed: {msg:?}"
);
}
}