use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::Parser;
use mkit_core::hash;
use sha2::Digest as _;
use crate::clap_shim;
use crate::cli::CLI_VERSION;
use crate::exit;
use crate::format::json_escape;
const TARGET_TRIPLE: &str = env!("MKIT_TARGET_TRIPLE");
const DEFAULT_API_BASE: &str = "https://api.github.com/repos/officialunofficial/mkit";
const MAX_JSON_BYTES: u64 = 4 * 1024 * 1024;
const MAX_SHA256_BYTES: u64 = 4 * 1024;
const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024;
const MAX_REDIRECTS: usize = 5;
const REQUEST_TIMEOUT: Duration = Duration::from_mins(2);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Parser)]
#[command(
name = "mkit self update",
about = "Update the mkit binary in place from a signed release."
)]
pub struct Opts {
#[arg(long, value_name = "TAG")]
pub version: Option<String>,
#[arg(long)]
pub check: bool,
#[arg(long = "allow-downgrade")]
pub allow_downgrade: bool,
#[arg(long, value_name = "FMT", default_value = "human")]
pub format: String,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
match args.first().map(String::as_str) {
Some("update") => run_update_cli(&args[1..]),
Some("-h" | "--help") | None => {
let mut stdout = std::io::stdout().lock();
let _ = writeln!(
stdout,
"usage: mkit self update [--version <tag>] [--check] [--allow-downgrade] [--format human|json]"
);
exit::OK
}
Some(other) => super::error(
&format!("unknown self subcommand '{other}' (expected: update)"),
exit::USAGE,
),
}
}
fn run_update_cli(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<Opts>("mkit self update", args) {
Ok(o) => o,
Err(code) => return code,
};
if !matches!(opts.format.as_str(), "human" | "json") {
return super::error(
&format!("unknown --format '{}' (expected: human, json)", opts.format),
exit::USAGE,
);
}
if opts.allow_downgrade && opts.version.is_none() {
return super::error(
"--allow-downgrade requires an explicit --version pin",
exit::USAGE,
);
}
if cfg!(windows) {
return super::error(
"self update is not yet supported on Windows (there are no Windows \
release binaries yet); reinstall manually when a new release ships",
exit::UNAVAILABLE,
);
}
let env = match UpdateEnv::production() {
Ok(e) => e,
Err((msg, code)) => return super::error(&msg, code),
};
match run_update(&opts, &env) {
Ok(outcome) => {
emit_outcome(&outcome, &opts.format);
exit::OK
}
Err((msg, code)) => super::error(&msg, code),
}
}
#[derive(Debug)]
pub struct UpdateEnv {
pub api_base: String,
pub token: Option<String>,
pub exe_path: PathBuf,
pub state_dir: PathBuf,
pub current_version: String,
pub target: String,
}
impl UpdateEnv {
fn production() -> Result<Self, (String, u8)> {
let exe_path = std::env::current_exe()
.and_then(|p| p.canonicalize())
.map_err(|e| (format!("resolve current executable: {e}"), exit::NOINPUT))?;
let state_dir =
match std::env::var_os("MKIT_STATE_DIR") {
Some(d) => PathBuf::from(d),
None => match std::env::var_os("HOME") {
Some(h) => Path::new(&h).join(".local/state/mkit"),
None => return Err((
"HOME is not set; cannot locate the receipt state dir (set MKIT_STATE_DIR)"
.to_owned(),
exit::CONFIG_ERROR,
)),
},
};
let api_base = std::env::var("MKIT_SELF_UPDATE_API_BASE")
.unwrap_or_else(|_| DEFAULT_API_BASE.to_owned());
let token = std::env::var("GH_TOKEN")
.or_else(|_| std::env::var("GITHUB_TOKEN"))
.ok()
.filter(|t| !t.is_empty());
Ok(Self {
api_base: api_base.trim_end_matches('/').to_owned(),
token,
exe_path,
state_dir,
current_version: CLI_VERSION.to_owned(),
target: TARGET_TRIPLE.to_owned(),
})
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
UpToDate {
current: String,
},
UpdateAvailable {
current: String,
latest: String,
},
Updated {
from: String,
to: String,
exe: PathBuf,
},
}
fn emit_outcome(outcome: &Outcome, format: &str) {
let mut stdout = std::io::stdout().lock();
match (outcome, format) {
(Outcome::UpToDate { current }, "json") => {
let _ = writeln!(
stdout,
"{{\"status\":\"up-to-date\",\"current\":\"{}\"}}",
json_escape(current)
);
}
(Outcome::UpToDate { current }, _) => {
let _ = writeln!(stdout, "mkit {current} is up to date");
}
(Outcome::UpdateAvailable { current, latest }, "json") => {
let _ = writeln!(
stdout,
"{{\"status\":\"update-available\",\"current\":\"{}\",\"latest\":\"{}\"}}",
json_escape(current),
json_escape(latest)
);
}
(Outcome::UpdateAvailable { current, latest }, _) => {
let _ = writeln!(
stdout,
"update available: mkit {current} → {latest} (run `mkit self update`)"
);
}
(Outcome::Updated { from, to, exe }, "json") => {
let _ = writeln!(
stdout,
"{{\"status\":\"updated\",\"from\":\"{}\",\"to\":\"{}\",\"exe\":\"{}\"}}",
json_escape(from),
json_escape(to),
json_escape(&exe.display().to_string())
);
}
(Outcome::Updated { from, to, .. }, _) => {
let _ = writeln!(stdout, "updated mkit {from} → {to}");
}
}
}
#[allow(clippy::too_many_lines)] pub fn run_update(opts: &Opts, env: &UpdateEnv) -> Result<Outcome, (String, u8)> {
if let Some(tag) = opts.version.as_deref() {
validate_tag(tag).map_err(|e| (e, exit::USAGE))?;
}
let client = http_client(env)?;
let resolved_from_latest = opts.version.is_none();
let target_tag = match opts.version.clone() {
Some(t) => t,
None => resolve_latest_tag(&client, env)?,
};
let target_bare = target_tag.trim_start_matches('v').to_owned();
if opts.check {
return Ok(
match cmp_versions(&env.current_version, &target_bare)
.map_err(|e| (e, exit::DATAERR))?
{
std::cmp::Ordering::Less => Outcome::UpdateAvailable {
current: format!("v{}", env.current_version),
latest: target_tag,
},
_ => Outcome::UpToDate {
current: format!("v{}", env.current_version),
},
},
);
}
let bin_dir = env
.exe_path
.parent()
.ok_or_else(|| {
(
"executable has no parent directory".to_owned(),
exit::NOINPUT,
)
})?
.to_path_buf();
let local_receipt = bin_dir.join(".mkit-installed-tag");
let global_receipt = env.state_dir.join("installed-tag");
let local_tag = read_receipt(&local_receipt);
let Some(local_tag) = local_tag else {
return Err((unmanaged_guidance(&env.exe_path), exit::UNAVAILABLE));
};
let global_tag = read_receipt(&global_receipt);
if let Some(g) = &global_tag
&& g != &local_tag
{
return Err((
format!(
"installed-tag mismatch: {} says '{g}' but {} says '{local_tag}'. \
Refusing to update. Resolve manually.",
global_receipt.display(),
local_receipt.display()
),
exit::DATAERR,
));
}
let installed_tag = local_tag;
let installed_bare = installed_tag.trim_start_matches('v').to_owned();
if installed_bare != env.current_version {
eprintln!(
"warning: receipt says {installed_tag} but this binary reports v{} — \
receipts may have been edited; using the receipt for downgrade checks",
env.current_version
);
}
match cmp_versions(&target_bare, &installed_bare).map_err(|e| (e, exit::DATAERR))? {
std::cmp::Ordering::Equal => {
return Ok(Outcome::UpToDate {
current: installed_tag,
});
}
std::cmp::Ordering::Less if resolved_from_latest => {
return Err((
format!(
"refusing to silently downgrade from {installed_tag} to {target_tag} via \
'latest'. Pin --version {installed_tag} or newer, or delete {} and {}.",
global_receipt.display(),
local_receipt.display()
),
exit::DATAERR,
));
}
std::cmp::Ordering::Less if !opts.allow_downgrade => {
return Err((
format!(
"{target_tag} is a DOWNGRADE from {installed_tag}; pass --allow-downgrade \
to proceed anyway"
),
exit::USAGE,
));
}
std::cmp::Ordering::Less => {
eprintln!(
"warning: downgrading from {installed_tag} to {target_tag} (--allow-downgrade)"
);
}
std::cmp::Ordering::Greater => {}
}
refuse_lax_dir_perms(&bin_dir)?;
let release = fetch_release_by_tag(&client, env, &target_tag)?;
let archive_name = format!("mkit-{target_bare}-{}.tar.gz", env.target);
let archive_url = asset_url(&release, &archive_name).ok_or_else(|| {
(
format!("release {target_tag} has no prebuilt binary for {} ({archive_name} not among its assets)", env.target),
exit::UNAVAILABLE,
)
})?;
eprintln!("downloading mkit {target_tag} ({})...", env.target);
let archive_bytes = download(&client, env, &archive_url, MAX_ARCHIVE_BYTES)?;
if let Some(sha_url) = asset_url(&release, &format!("{archive_name}.sha256")) {
let sha_body = download(&client, env, &sha_url, MAX_SHA256_BYTES)?;
verify_sha256_sidecar(&archive_bytes, &sha_body, &archive_name)
.map_err(|e| (e, exit::DATAERR))?;
}
let binary = extract_binary(
&archive_bytes,
&format!("mkit-{target_bare}-{}", env.target),
)
.map_err(|e| (e, exit::DATAERR))?;
let staged = stage_binary(&bin_dir, &binary)?;
if let Err(e) = check_staged_version(&staged, &target_bare) {
let _ = std::fs::remove_file(&staged);
return Err((e, exit::DATAERR));
}
std::fs::rename(&staged, &env.exe_path).map_err(|e| {
let _ = std::fs::remove_file(&staged);
(
format!("replace {}: {e}", env.exe_path.display()),
exit::CANTCREAT,
)
})?;
for receipt in [&local_receipt, &global_receipt] {
if let Err(e) = write_receipt(receipt, &target_tag) {
eprintln!(
"warning: binary updated, but writing receipt {} failed: {e} — \
the silent-downgrade guard is weakened until it is restored",
receipt.display()
);
}
}
Ok(Outcome::Updated {
from: installed_tag,
to: target_tag,
exe: env.exe_path.clone(),
})
}
fn read_receipt(path: &Path) -> Option<String> {
let s = std::fs::read_to_string(path).ok()?;
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_owned())
}
}
fn write_receipt(path: &Path, tag: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("new");
std::fs::write(&tmp, format!("{tag}\n"))?;
std::fs::rename(&tmp, path)
}
fn unmanaged_guidance(exe: &Path) -> String {
let p = exe.to_string_lossy();
let hint = if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
"this looks like a Homebrew install — run `brew upgrade mkit` instead"
} else if p.contains("/.cargo/bin/") {
"this looks like a cargo install — run `cargo install --locked mkit-cli` \
(or `cargo binstall mkit-cli`) instead"
} else {
"reinstall via `curl mkit.sh | sh` to adopt it (the installer writes the receipt)"
};
format!(
"this mkit binary ({p}) is not installer-managed (no .mkit-installed-tag receipt \
next to it); {hint}"
)
}
fn validate_tag(tag: &str) -> Result<(), String> {
let err = || format!("tag '{tag}' is not strict semver (vMAJOR.MINOR.PATCH[-suffix])");
let rest = tag.strip_prefix('v').ok_or_else(err)?;
parse_version(rest).map(|_| ()).map_err(|_| err())
}
type Parsed = (u64, u64, u64, Option<Vec<PreSeg>>);
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
enum PreSeg {
Num(u64),
Alpha(String),
}
fn parse_version(bare: &str) -> Result<Parsed, String> {
let (core, pre) = match bare.split_once('-') {
Some((c, p)) => (c, Some(p)),
None => (bare, None),
};
let mut nums = core.split('.');
let mut next_num = |what: &str| -> Result<u64, String> {
nums.next()
.filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
.and_then(|p| p.parse().ok())
.ok_or_else(|| format!("bad {what} in version '{bare}'"))
};
let (major, minor, patch) = (next_num("major")?, next_num("minor")?, next_num("patch")?);
if nums.next().is_some() {
return Err(format!("version '{bare}' has more than three components"));
}
let pre = match pre {
None => None,
Some(p) => {
if p.is_empty() {
return Err(format!("version '{bare}' has an empty prerelease"));
}
let mut segs = Vec::new();
for s in p.split('.') {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(format!("bad prerelease segment '{s}' in '{bare}'"));
}
segs.push(if s.bytes().all(|b| b.is_ascii_digit()) {
PreSeg::Num(
s.parse()
.map_err(|_| format!("prerelease number overflow in '{bare}'"))?,
)
} else {
PreSeg::Alpha(s.to_owned())
});
}
Some(segs)
}
};
Ok((major, minor, patch, pre))
}
fn cmp_versions(a: &str, b: &str) -> Result<std::cmp::Ordering, String> {
let (amaj, amin, apat, apre) = parse_version(a)?;
let (bmaj, bmin, bpat, bpre) = parse_version(b)?;
Ok((amaj, amin, apat)
.cmp(&(bmaj, bmin, bpat))
.then_with(|| match (apre, bpre) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(x), Some(y)) => x.cmp(&y),
}))
}
fn http_client(env: &UpdateEnv) -> Result<reqwest::blocking::Client, (String, u8)> {
let policy = reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("too many redirects");
}
if let Some(prev) = attempt.previous().last()
&& prev.scheme() == "https"
&& attempt.url().scheme() != "https"
{
return attempt.error("refusing redirect that downgrades https to a weaker scheme");
}
attempt.follow()
});
reqwest::blocking::Client::builder()
.user_agent(format!("mkit/{} (self-update)", env.current_version))
.redirect(policy)
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.map_err(|e| (format!("build http client: {e}"), exit::GENERAL_ERROR))
}
fn get(
client: &reqwest::blocking::Client,
env: &UpdateEnv,
url: &str,
accept: &str,
cap: u64,
) -> Result<Vec<u8>, (String, u8)> {
let mut req = client.get(url).header("Accept", accept);
req = req.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(t) = &env.token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let resp = req
.send()
.map_err(|e| (format!("GET {url}: {}", error_chain(&e)), exit::TEMPFAIL))?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err((
format!(
"GET {url}: 404 — release or asset not found (for a private repo, set \
GH_TOKEN)"
),
exit::UNAVAILABLE,
));
}
if !status.is_success() {
return Err((format!("GET {url}: HTTP {status}"), exit::TEMPFAIL));
}
let mut body = Vec::new();
resp.take(cap + 1)
.read_to_end(&mut body)
.map_err(|e| (format!("read {url}: {e}"), exit::TEMPFAIL))?;
if body.len() as u64 > cap {
return Err((
format!("response from {url} exceeds the {cap}-byte cap"),
exit::DATAERR,
));
}
Ok(body)
}
fn error_chain(e: &dyn std::error::Error) -> String {
let mut out = e.to_string();
let mut cur = e.source();
while let Some(src) = cur {
out.push_str(": ");
out.push_str(&src.to_string());
cur = src.source();
}
out
}
fn get_json(
client: &reqwest::blocking::Client,
env: &UpdateEnv,
url: &str,
) -> Result<serde_json::Value, (String, u8)> {
let body = get(
client,
env,
url,
"application/vnd.github+json",
MAX_JSON_BYTES,
)?;
serde_json::from_slice(&body).map_err(|e| (format!("parse {url}: {e}"), exit::PROTOCOL_ERROR))
}
fn resolve_latest_tag(
client: &reqwest::blocking::Client,
env: &UpdateEnv,
) -> Result<String, (String, u8)> {
let v = get_json(client, env, &format!("{}/releases/latest", env.api_base))?;
let tag = v["tag_name"]
.as_str()
.ok_or_else(|| {
(
"releases/latest has no tag_name".to_owned(),
exit::PROTOCOL_ERROR,
)
})?
.to_owned();
validate_tag(&tag).map_err(|e| (format!("latest release: {e}"), exit::PROTOCOL_ERROR))?;
Ok(tag)
}
fn fetch_release_by_tag(
client: &reqwest::blocking::Client,
env: &UpdateEnv,
tag: &str,
) -> Result<serde_json::Value, (String, u8)> {
get_json(
client,
env,
&format!("{}/releases/tags/{tag}", env.api_base),
)
}
fn asset_url(release: &serde_json::Value, name: &str) -> Option<String> {
release["assets"].as_array()?.iter().find_map(|a| {
(a["name"].as_str() == Some(name)).then(|| a["url"].as_str().map(str::to_owned))?
})
}
fn download(
client: &reqwest::blocking::Client,
env: &UpdateEnv,
url: &str,
cap: u64,
) -> Result<Vec<u8>, (String, u8)> {
get(client, env, url, "application/octet-stream", cap)
}
fn verify_sha256_sidecar(archive: &[u8], sidecar: &[u8], archive_name: &str) -> Result<(), String> {
let text =
core::str::from_utf8(sidecar).map_err(|_| format!("{archive_name}.sha256 is not UTF-8"))?;
let expected = text
.split_whitespace()
.next()
.ok_or_else(|| format!("{archive_name}.sha256 is empty"))?
.to_ascii_lowercase();
let actual = hash::to_hex_bytes(&sha2::Sha256::digest(archive));
if actual == expected {
Ok(())
} else {
Err(format!(
"sha256 mismatch for {archive_name}: sidecar says {expected}, archive is {actual}"
))
}
}
fn extract_binary(archive: &[u8], stage_dir: &str) -> Result<Vec<u8>, String> {
let want = format!("{stage_dir}/mkit");
let gz = flate2::read::GzDecoder::new(archive);
let mut tar = tar::Archive::new(gz);
let entries = tar.entries().map_err(|e| format!("read archive: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("read archive entry: {e}"))?;
let path = entry
.path()
.map_err(|e| format!("archive entry path: {e}"))?;
if path.as_os_str() != want.as_str() {
continue;
}
if !entry.header().entry_type().is_file() {
return Err(format!("archive member {want} is not a regular file"));
}
let mut buf = Vec::new();
entry
.take(MAX_BINARY_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("extract {want}: {e}"))?;
if buf.len() as u64 > MAX_BINARY_BYTES {
return Err(format!("{want} exceeds the {MAX_BINARY_BYTES}-byte cap"));
}
return Ok(buf);
}
Err(format!("archive has no {want} member"))
}
#[cfg(unix)]
fn refuse_lax_dir_perms(dir: &Path) -> Result<(), (String, u8)> {
use std::os::unix::fs::MetadataExt as _;
let meta = std::fs::metadata(dir)
.map_err(|e| (format!("stat {}: {e}", dir.display()), exit::NOINPUT))?;
let mode = meta.mode() & 0o777;
if mode & 0o020 != 0 {
return Err((
format!(
"install dir {} is group-writable (mode {mode:o}); refusing to update — \
tighten permissions: chmod g-w {}",
dir.display(),
dir.display()
),
exit::NOPERM,
));
}
if mode & 0o002 != 0 {
return Err((
format!(
"install dir {} is world-writable (mode {mode:o}); refusing to update — \
tighten permissions: chmod o-w {}",
dir.display(),
dir.display()
),
exit::NOPERM,
));
}
Ok(())
}
#[cfg(not(unix))]
fn refuse_lax_dir_perms(_dir: &Path) -> Result<(), (String, u8)> {
Ok(())
}
fn stage_binary(bin_dir: &Path, binary: &[u8]) -> Result<PathBuf, (String, u8)> {
let staged = bin_dir.join(format!(".mkit-self-update.{}", std::process::id()));
std::fs::write(&staged, binary)
.map_err(|e| (format!("stage {}: {e}", staged.display()), exit::CANTCREAT))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
let _ = std::fs::remove_file(&staged);
(format!("chmod {}: {e}", staged.display()), exit::CANTCREAT)
})?;
}
Ok(staged)
}
fn check_staged_version(staged: &Path, target_bare: &str) -> Result<(), String> {
let out = std::process::Command::new(staged)
.arg("version")
.output()
.map_err(|e| format!("run staged binary {}: {e}", staged.display()))?;
let expected = format!("mkit {target_bare}\n");
let got = String::from_utf8_lossy(&out.stdout);
if !out.status.success() || got != expected {
return Err(format!(
"staged binary self-check failed: `version` printed {:?} (exit {:?}), expected {:?}",
got,
out.status.code(),
expected
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cmp_versions_basic() {
use std::cmp::Ordering::{Equal, Greater, Less};
assert_eq!(cmp_versions("0.3.0", "0.4.0").unwrap(), Less);
assert_eq!(cmp_versions("0.4.0", "0.4.0").unwrap(), Equal);
assert_eq!(cmp_versions("0.10.0", "0.9.9").unwrap(), Greater);
assert_eq!(cmp_versions("1.0.0-rc.1", "1.0.0").unwrap(), Less);
assert_eq!(cmp_versions("1.0.0-rc.2", "1.0.0-rc.10").unwrap(), Less);
assert_eq!(cmp_versions("1.0.0-alpha", "1.0.0-beta").unwrap(), Less);
assert_eq!(cmp_versions("1.0.0-1", "1.0.0-alpha").unwrap(), Less);
}
#[test]
fn parse_version_rejects_garbage() {
for bad in [
"1.2",
"1.2.3.4",
"1.2.x",
"01a.2.3",
"1.2.3-",
"1.2.3-a..b",
"",
] {
assert!(parse_version(bad).is_err(), "{bad} should be rejected");
}
}
#[test]
fn validate_tag_matrix() {
assert!(validate_tag("v0.4.0").is_ok());
assert!(validate_tag("v1.2.3-rc.1").is_ok());
assert!(validate_tag("0.4.0").is_err());
assert!(validate_tag("v1.2").is_err());
}
fn tmp_dir(name: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("mkit-self-update-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn receipt_roundtrip() {
let d = tmp_dir("receipt");
let p = d.join("installed-tag");
write_receipt(&p, "v0.4.0").unwrap();
assert_eq!(read_receipt(&p).as_deref(), Some("v0.4.0"));
assert_eq!(std::fs::read_to_string(&p).unwrap(), "v0.4.0\n");
}
#[test]
fn read_receipt_missing_or_empty_is_none() {
let d = tmp_dir("receipt-empty");
assert_eq!(read_receipt(&d.join("nope")), None);
std::fs::write(d.join("empty"), "\n").unwrap();
assert_eq!(read_receipt(&d.join("empty")), None);
}
#[test]
fn unmanaged_guidance_recognizes_channels() {
let brew = unmanaged_guidance(Path::new("/opt/homebrew/Cellar/mkit/0.3.0/bin/mkit"));
assert!(brew.contains("brew upgrade"), "{brew}");
let cargo = unmanaged_guidance(Path::new("/home/u/.cargo/bin/mkit"));
assert!(cargo.contains("cargo install --locked mkit-cli"), "{cargo}");
let other = unmanaged_guidance(Path::new("/usr/local/bin/mkit"));
assert!(other.contains("curl mkit.sh"), "{other}");
}
#[test]
fn sha256_sidecar_matches() {
let body = b"archive bytes";
let hex = hash::to_hex_bytes(&sha2::Sha256::digest(body));
let sidecar = format!("{hex} mkit-0.4.0-x.tar.gz\n");
verify_sha256_sidecar(body, sidecar.as_bytes(), "mkit-0.4.0-x.tar.gz").unwrap();
let e = verify_sha256_sidecar(b"tampered", sidecar.as_bytes(), "mkit-0.4.0-x.tar.gz")
.unwrap_err();
assert!(e.contains("sha256 mismatch"), "{e}");
}
fn tgz_with(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
Vec::new(),
flate2::Compression::fast(),
));
for (path, body) in entries {
let mut h = tar::Header::new_gnu();
h.set_size(body.len() as u64);
h.set_mode(0o755);
h.set_cksum();
builder.append_data(&mut h, path, *body).unwrap();
}
builder.into_inner().unwrap().finish().unwrap()
}
#[test]
fn extract_binary_finds_only_the_binary() {
let tgz = tgz_with(&[
("mkit-0.4.0-x/README.md", b"readme"),
("mkit-0.4.0-x/mkit", b"#!/bin/sh\necho hi\n"),
]);
let bin = extract_binary(&tgz, "mkit-0.4.0-x").unwrap();
assert_eq!(bin, b"#!/bin/sh\necho hi\n");
}
#[test]
fn extract_binary_missing_member_errors() {
let tgz = tgz_with(&[("mkit-0.4.0-x/README.md", b"readme")]);
let e = extract_binary(&tgz, "mkit-0.4.0-x").unwrap_err();
assert!(e.contains("no mkit-0.4.0-x/mkit member"), "{e}");
}
#[cfg(unix)]
#[test]
fn staged_version_check_enforces_contract() {
let d = tmp_dir("staged");
let ok = stage_binary(&d, b"#!/bin/sh\nprintf 'mkit 9.9.9\\n'\n").unwrap();
check_staged_version(&ok, "9.9.9").unwrap();
let e = check_staged_version(&ok, "9.9.8").unwrap_err();
assert!(e.contains("self-check failed"), "{e}");
}
#[cfg(unix)]
#[test]
fn lax_dir_perms_refused() {
use std::os::unix::fs::PermissionsExt as _;
let d = tmp_dir("perms");
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o777)).unwrap();
let (msg, code) = refuse_lax_dir_perms(&d).unwrap_err();
assert_eq!(code, exit::NOPERM);
assert!(msg.contains("writable"), "{msg}");
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap();
refuse_lax_dir_perms(&d).unwrap();
}
}