use std::io::Read;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::binhash::sha256_file;
use super::policy::AddonPolicy;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtifactAsset {
pub filename: String,
pub url: String,
pub sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactUse {
InProcessDylib,
Executable,
}
pub fn current_target_triple() -> &'static str {
if cfg!(all(target_arch = "x86_64", target_os = "windows")) {
"x86_64-pc-windows-msvc"
} else if cfg!(all(target_arch = "aarch64", target_os = "windows")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
"aarch64-unknown-linux-gnu"
} else {
"unknown"
}
}
pub fn managed_bin_dir(name: &str, version: &str) -> Result<PathBuf, String> {
let version = version.trim();
Ok(crate::core::data_dir::lean_ctx_data_dir()?
.join("addons")
.join("bin")
.join(name)
.join(if version.is_empty() {
"unversioned"
} else {
version
}))
}
pub(crate) fn fetch_verified(
context: &str,
url: &str,
expected_sha256: &str,
dest: &Path,
usage: ArtifactUse,
) -> Result<(), String> {
if expected_sha256.trim().is_empty() {
return Err(format!(
"{context} asset has no sha256 pin — refusing to fetch"
));
}
let agent = crate::core::http_client::ureq_agent_with_timeouts(
Some(std::time::Duration::from_secs(10)),
Some(std::time::Duration::from_secs(15)),
Some(std::time::Duration::from_secs(20)),
);
let response = agent
.get(url)
.header(
"User-Agent",
&format!("lean-ctx/{}", env!("CARGO_PKG_VERSION")),
)
.call()
.map_err(|e| format!("{context} fetch failed: {e}"))?;
let mut bytes = Vec::new();
response
.into_body()
.into_reader()
.read_to_end(&mut bytes)
.map_err(|e| format!("{context} download read failed: {e}"))?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let tmp = dest.with_extension("tmp");
std::fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
let actual = sha256_file(&tmp)?;
if !actual.eq_ignore_ascii_case(expected_sha256) {
let _ = std::fs::remove_file(&tmp);
return Err(format!(
"{context} download hash mismatch: expected {expected_sha256}, got {actual} — \
refusing to install"
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = match usage {
ArtifactUse::InProcessDylib => 0o444,
ArtifactUse::Executable => 0o555,
};
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
let _ = &usage;
#[cfg(target_os = "macos")]
crate::core::codesign::adhoc_sign(&tmp);
std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
Ok(())
}
pub fn ensure_addon_binary(
name: &str,
version: &str,
asset: &ArtifactAsset,
) -> Result<PathBuf, String> {
if asset.filename.trim().is_empty() {
return Err(format!("addon `{name}` artifact has no filename"));
}
let dest = managed_bin_dir(name, version)?.join(asset.filename.trim());
if dest.is_file() && sha256_file(&dest).is_ok_and(|h| h.eq_ignore_ascii_case(&asset.sha256)) {
return Ok(dest);
}
let addons = crate::core::config::Config::load().addons;
if addons.policy() == AddonPolicy::Locked {
return Err("addons.policy = locked: managed artifact fetch disabled".into());
}
let context = format!("addon `{name}`");
fetch_verified(
&context,
&asset.url,
&asset.sha256,
&dest,
ArtifactUse::Executable,
)?;
tracing::info!(
"addon `{name}` binary {version} installed from {} (sha256 verified)",
asset.url
);
Ok(dest)
}
pub fn remove_managed_binaries(name: &str) -> bool {
let Ok(dir) = managed_bin_dir(name, "unversioned") else {
return false;
};
let root = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
if !root.is_dir() {
return false;
}
std::fs::remove_dir_all(&root).is_ok()
}
pub fn prune_other_versions(name: &str, keep_version: &str) {
let Ok(keep) = managed_bin_dir(name, keep_version) else {
return;
};
let Some(root) = keep.parent() else {
return;
};
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() && p != keep {
let _ = std::fs::remove_dir_all(&p);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::data_dir::isolated_data_dir;
fn asset(filename: &str, sha256: &str) -> ArtifactAsset {
ArtifactAsset {
filename: filename.into(),
url: "https://example.invalid/bin".into(),
sha256: sha256.into(),
}
}
#[test]
fn missing_pin_is_refused_without_network() {
let dest = std::env::temp_dir().join(format!("lc-artifact-test-{}-a", std::process::id()));
let err = fetch_verified(
"addon `x`",
"https://example.invalid/bin",
"",
&dest,
ArtifactUse::Executable,
)
.unwrap_err();
assert!(err.contains("sha256 pin"), "got: {err}");
assert!(!dest.exists());
}
#[test]
fn valid_managed_binary_short_circuits() {
let _iso = isolated_data_dir();
let dir = managed_bin_dir("demo", "1.0.0").unwrap();
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("demo-bin");
std::fs::write(&bin, b"binary bytes").unwrap();
let hash = sha256_file(&bin).unwrap();
let got = ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &hash)).unwrap();
assert_eq!(got, bin);
}
#[test]
fn locked_policy_blocks_fetch() {
let _iso = isolated_data_dir();
crate::core::config::Config::update_global(|cfg| {
cfg.addons.policy = "locked".into();
})
.unwrap();
let err =
ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &"a".repeat(64))).unwrap_err();
assert!(err.contains("locked"), "got: {err}");
}
#[test]
fn tampered_managed_binary_refetches_and_fails_offline() {
let _iso = isolated_data_dir();
let dir = managed_bin_dir("demo", "1.0.0").unwrap();
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("demo-bin");
std::fs::write(&bin, b"tampered").unwrap();
let err =
ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &"a".repeat(64))).unwrap_err();
assert!(err.contains("fetch failed"), "got: {err}");
}
#[test]
fn current_triple_is_known_on_ci_platforms() {
if cfg!(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
)) && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
{
assert_ne!(current_target_triple(), "unknown");
}
}
#[test]
fn remove_and_prune_managed_binaries() {
let _iso = isolated_data_dir();
for v in ["1.0.0", "1.1.0"] {
let dir = managed_bin_dir("demo", v).unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("demo-bin"), v).unwrap();
}
prune_other_versions("demo", "1.1.0");
assert!(!managed_bin_dir("demo", "1.0.0").unwrap().exists());
assert!(managed_bin_dir("demo", "1.1.0").unwrap().exists());
assert!(remove_managed_binaries("demo"));
assert!(!managed_bin_dir("demo", "1.1.0").unwrap().exists());
}
}