1use std::io::Read;
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23
24use super::binhash::sha256_file;
25use super::policy::AddonPolicy;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct ArtifactAsset {
33 pub filename: String,
36 pub url: String,
38 pub sha256: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ArtifactUse {
46 InProcessDylib,
48 Executable,
50}
51
52pub fn current_target_triple() -> &'static str {
56 if cfg!(all(target_arch = "x86_64", target_os = "windows")) {
57 "x86_64-pc-windows-msvc"
58 } else if cfg!(all(target_arch = "aarch64", target_os = "windows")) {
59 "aarch64-pc-windows-msvc"
60 } else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
61 "x86_64-apple-darwin"
62 } else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
63 "aarch64-apple-darwin"
64 } else if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
65 "x86_64-unknown-linux-gnu"
66 } else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
67 "aarch64-unknown-linux-gnu"
68 } else {
69 "unknown"
70 }
71}
72
73pub fn managed_bin_dir(name: &str, version: &str) -> Result<PathBuf, String> {
77 let version = version.trim();
78 Ok(crate::core::data_dir::lean_ctx_data_dir()?
79 .join("addons")
80 .join("bin")
81 .join(name)
82 .join(if version.is_empty() {
83 "unversioned"
84 } else {
85 version
86 }))
87}
88
89pub(crate) fn fetch_verified(
94 context: &str,
95 url: &str,
96 expected_sha256: &str,
97 dest: &Path,
98 usage: ArtifactUse,
99) -> Result<(), String> {
100 if expected_sha256.trim().is_empty() {
101 return Err(format!(
102 "{context} asset has no sha256 pin — refusing to fetch"
103 ));
104 }
105
106 let agent = crate::core::http_client::ureq_agent_with_timeouts(
107 Some(std::time::Duration::from_secs(10)),
108 Some(std::time::Duration::from_secs(15)),
109 Some(std::time::Duration::from_secs(20)),
110 );
111 let response = agent
112 .get(url)
113 .header(
114 "User-Agent",
115 &format!("lean-ctx/{}", env!("CARGO_PKG_VERSION")),
116 )
117 .call()
118 .map_err(|e| format!("{context} fetch failed: {e}"))?;
119 let mut bytes = Vec::new();
120 response
121 .into_body()
122 .into_reader()
123 .read_to_end(&mut bytes)
124 .map_err(|e| format!("{context} download read failed: {e}"))?;
125
126 if let Some(parent) = dest.parent() {
127 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
128 }
129 let tmp = dest.with_extension("tmp");
130 std::fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
131
132 let actual = sha256_file(&tmp)?;
133 if !actual.eq_ignore_ascii_case(expected_sha256) {
134 let _ = std::fs::remove_file(&tmp);
135 return Err(format!(
136 "{context} download hash mismatch: expected {expected_sha256}, got {actual} — \
137 refusing to install"
138 ));
139 }
140
141 #[cfg(unix)]
147 {
148 use std::os::unix::fs::PermissionsExt;
149 let mode = match usage {
150 ArtifactUse::InProcessDylib => 0o444,
151 ArtifactUse::Executable => 0o555,
152 };
153 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
154 }
155 #[cfg(not(unix))]
156 let _ = &usage;
157 #[cfg(target_os = "macos")]
158 crate::core::codesign::adhoc_sign(&tmp);
159
160 std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
161 Ok(())
162}
163
164pub fn ensure_addon_binary(
170 name: &str,
171 version: &str,
172 asset: &ArtifactAsset,
173) -> Result<PathBuf, String> {
174 if asset.filename.trim().is_empty() {
175 return Err(format!("addon `{name}` artifact has no filename"));
176 }
177 let dest = managed_bin_dir(name, version)?.join(asset.filename.trim());
178
179 if dest.is_file() && sha256_file(&dest).is_ok_and(|h| h.eq_ignore_ascii_case(&asset.sha256)) {
180 return Ok(dest);
181 }
182
183 let addons = crate::core::config::Config::load().addons;
184 if addons.policy() == AddonPolicy::Locked {
185 return Err("addons.policy = locked: managed artifact fetch disabled".into());
186 }
187
188 let context = format!("addon `{name}`");
189 fetch_verified(
190 &context,
191 &asset.url,
192 &asset.sha256,
193 &dest,
194 ArtifactUse::Executable,
195 )?;
196 tracing::info!(
198 "addon `{name}` binary {version} installed from {} (sha256 verified)",
199 asset.url
200 );
201 Ok(dest)
202}
203
204pub fn remove_managed_binaries(name: &str) -> bool {
207 let Ok(dir) = managed_bin_dir(name, "unversioned") else {
208 return false;
209 };
210 let root = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
212 if !root.is_dir() {
213 return false;
214 }
215 std::fs::remove_dir_all(&root).is_ok()
216}
217
218pub fn prune_other_versions(name: &str, keep_version: &str) {
221 let Ok(keep) = managed_bin_dir(name, keep_version) else {
222 return;
223 };
224 let Some(root) = keep.parent() else {
225 return;
226 };
227 let Ok(entries) = std::fs::read_dir(root) else {
228 return;
229 };
230 for entry in entries.flatten() {
231 let p = entry.path();
232 if p.is_dir() && p != keep {
233 let _ = std::fs::remove_dir_all(&p);
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::core::data_dir::isolated_data_dir;
242
243 fn asset(filename: &str, sha256: &str) -> ArtifactAsset {
244 ArtifactAsset {
245 filename: filename.into(),
246 url: "https://example.invalid/bin".into(),
247 sha256: sha256.into(),
248 }
249 }
250
251 #[test]
252 fn missing_pin_is_refused_without_network() {
253 let dest = std::env::temp_dir().join(format!("lc-artifact-test-{}-a", std::process::id()));
254 let err = fetch_verified(
255 "addon `x`",
256 "https://example.invalid/bin",
257 "",
258 &dest,
259 ArtifactUse::Executable,
260 )
261 .unwrap_err();
262 assert!(err.contains("sha256 pin"), "got: {err}");
263 assert!(!dest.exists());
264 }
265
266 #[test]
269 fn valid_managed_binary_short_circuits() {
270 let _iso = isolated_data_dir();
271 let dir = managed_bin_dir("demo", "1.0.0").unwrap();
272 std::fs::create_dir_all(&dir).unwrap();
273 let bin = dir.join("demo-bin");
274 std::fs::write(&bin, b"binary bytes").unwrap();
275 let hash = sha256_file(&bin).unwrap();
276
277 let got = ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &hash)).unwrap();
278 assert_eq!(got, bin);
279 }
280
281 #[test]
284 fn locked_policy_blocks_fetch() {
285 let _iso = isolated_data_dir();
286 crate::core::config::Config::update_global(|cfg| {
287 cfg.addons.policy = "locked".into();
288 })
289 .unwrap();
290
291 let err =
292 ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &"a".repeat(64))).unwrap_err();
293 assert!(err.contains("locked"), "got: {err}");
294 }
295
296 #[test]
297 fn tampered_managed_binary_refetches_and_fails_offline() {
298 let _iso = isolated_data_dir();
299 let dir = managed_bin_dir("demo", "1.0.0").unwrap();
300 std::fs::create_dir_all(&dir).unwrap();
301 let bin = dir.join("demo-bin");
302 std::fs::write(&bin, b"tampered").unwrap();
303
304 let err =
308 ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &"a".repeat(64))).unwrap_err();
309 assert!(err.contains("fetch failed"), "got: {err}");
310 }
311
312 #[test]
313 fn current_triple_is_known_on_ci_platforms() {
314 if cfg!(any(
315 target_os = "linux",
316 target_os = "macos",
317 target_os = "windows"
318 )) && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
319 {
320 assert_ne!(current_target_triple(), "unknown");
321 }
322 }
323
324 #[test]
325 fn remove_and_prune_managed_binaries() {
326 let _iso = isolated_data_dir();
327 for v in ["1.0.0", "1.1.0"] {
328 let dir = managed_bin_dir("demo", v).unwrap();
329 std::fs::create_dir_all(&dir).unwrap();
330 std::fs::write(dir.join("demo-bin"), v).unwrap();
331 }
332
333 prune_other_versions("demo", "1.1.0");
334 assert!(!managed_bin_dir("demo", "1.0.0").unwrap().exists());
335 assert!(managed_bin_dir("demo", "1.1.0").unwrap().exists());
336
337 assert!(remove_managed_binaries("demo"));
338 assert!(!managed_bin_dir("demo", "1.1.0").unwrap().exists());
339 }
340}