use std::io::Read;
use std::path::Path;
use super::binhash::sha256_file;
use super::grammar_manifest::{GrammarAsset, GrammarManifest};
use super::policy::AddonPolicy;
pub(crate) fn ensure_installed(
manifest: &GrammarManifest,
asset: &GrammarAsset,
dest: &Path,
) -> Result<(), String> {
if dest.is_file() && sha256_file(dest).is_ok_and(|h| h.eq_ignore_ascii_case(&asset.sha256)) {
return Ok(());
}
let addons = crate::core::config::Config::load().addons;
if addons.policy() == AddonPolicy::Locked {
return Err("addons.policy = locked: grammar-addon fetch disabled".into());
}
if !addons.grammar_auto_fetch {
return Err("addons.grammar_auto_fetch = false: fetch disabled".into());
}
if asset.sha256.trim().is_empty() {
return Err(format!(
"grammar `{}` asset has no sha256 pin — refusing to fetch",
manifest.name
));
}
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(&asset.url)
.header(
"User-Agent",
&format!("lean-ctx/{}", env!("CARGO_PKG_VERSION")),
)
.call()
.map_err(|e| format!("grammar `{}` fetch failed: {e}", manifest.name))?;
let mut bytes = Vec::new();
response
.into_body()
.into_reader()
.read_to_end(&mut bytes)
.map_err(|e| format!("grammar `{}` download read failed: {e}", manifest.name))?;
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(&asset.sha256) {
let _ = std::fs::remove_file(&tmp);
return Err(format!(
"grammar `{}` download hash mismatch: expected {}, got {actual} — refusing to install",
manifest.name, asset.sha256
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o444));
}
#[cfg(target_os = "macos")]
crate::core::codesign::adhoc_sign(&tmp);
std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
tracing::info!(
"grammar addon `{}` installed from {} (sha256 verified)",
manifest.name,
asset.url
);
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
fn manifest_and_asset(sha256: &str) -> (GrammarManifest, GrammarAsset) {
let asset = GrammarAsset {
filename: "lua-x86_64-pc-windows-msvc.dll".into(),
url: "https://example.invalid/lua.dll".into(),
sha256: sha256.into(),
};
let manifest = GrammarManifest {
name: "lua".into(),
extensions: vec!["lua".into()],
abi_version: 15,
assets: BTreeMap::from([("x86_64-pc-windows-msvc".into(), asset.clone())]),
..Default::default()
};
(manifest, asset)
}
#[test]
fn already_installed_with_matching_hash_is_a_no_op() {
let tmp = std::env::temp_dir().join(format!(
"lc-grammar-install-test-{}-a.dll",
std::process::id()
));
std::fs::write(&tmp, b"fake dylib bytes").unwrap();
let hash = sha256_file(&tmp).unwrap();
let (manifest, asset) = manifest_and_asset(&hash);
assert!(ensure_installed(&manifest, &asset, &tmp).is_ok());
std::fs::remove_file(&tmp).ok();
}
#[test]
fn missing_sha256_pin_is_refused_without_network() {
let dest = std::env::temp_dir().join(format!(
"lc-grammar-install-test-{}-b.dll",
std::process::id()
));
let (manifest, asset) = manifest_and_asset("");
let err = ensure_installed(&manifest, &asset, &dest).unwrap_err();
assert!(err.contains("sha256 pin"), "got: {err}");
assert!(!dest.exists());
}
#[test]
fn existing_valid_install_short_circuits_before_any_policy_gate() {
let tmp = std::env::temp_dir().join(format!(
"lc-grammar-install-test-{}-c.dll",
std::process::id()
));
std::fs::write(&tmp, b"already installed").unwrap();
let hash = sha256_file(&tmp).unwrap();
let (manifest, asset) = manifest_and_asset(&hash);
assert!(ensure_installed(&manifest, &asset, &tmp).is_ok());
std::fs::remove_file(&tmp).ok();
}
}