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 require_sha256_pin(context: &str, expected_sha256: &str) -> Result<(), String> {
91 if expected_sha256.trim().is_empty() {
92 return Err(format!(
93 "{context} asset has no sha256 pin — refusing to fetch"
94 ));
95 }
96 Ok(())
97}
98
99pub(crate) fn fetch_verified(
104 context: &str,
105 url: &str,
106 expected_sha256: &str,
107 dest: &Path,
108 usage: ArtifactUse,
109) -> Result<(), String> {
110 require_sha256_pin(context, expected_sha256)?;
111
112 let agent = crate::core::http_client::ureq_agent_with_timeouts(
113 Some(std::time::Duration::from_secs(10)),
114 Some(std::time::Duration::from_secs(15)),
115 Some(std::time::Duration::from_secs(20)),
116 );
117 let response = agent
118 .get(url)
119 .header(
120 "User-Agent",
121 &format!("lean-ctx/{}", env!("CARGO_PKG_VERSION")),
122 )
123 .call()
124 .map_err(|e| format!("{context} fetch failed: {e}"))?;
125 let mut bytes = Vec::new();
126 response
127 .into_body()
128 .into_reader()
129 .read_to_end(&mut bytes)
130 .map_err(|e| format!("{context} download read failed: {e}"))?;
131
132 if let Some(parent) = dest.parent() {
133 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
134 }
135 let tmp = dest.with_extension("tmp");
136 std::fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
137
138 let actual = sha256_file(&tmp)?;
139 if !actual.eq_ignore_ascii_case(expected_sha256) {
140 let _ = std::fs::remove_file(&tmp);
141 return Err(format!(
142 "{context} download hash mismatch: expected {expected_sha256}, got {actual} — \
143 refusing to install"
144 ));
145 }
146
147 #[cfg(unix)]
153 {
154 use std::os::unix::fs::PermissionsExt;
155 let mode = match usage {
156 ArtifactUse::InProcessDylib => 0o444,
157 ArtifactUse::Executable => 0o555,
158 };
159 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
160 }
161 #[cfg(not(unix))]
162 let _ = &usage;
163 #[cfg(target_os = "macos")]
164 crate::core::codesign::adhoc_sign(&tmp);
165
166 std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
167 Ok(())
168}
169
170pub fn ensure_addon_binary(
176 name: &str,
177 version: &str,
178 asset: &ArtifactAsset,
179) -> Result<PathBuf, String> {
180 if asset.filename.trim().is_empty() {
181 return Err(format!("addon `{name}` artifact has no filename"));
182 }
183 let dest = managed_bin_dir(name, version)?.join(asset.filename.trim());
184
185 if dest.is_file() && sha256_file(&dest).is_ok_and(|h| h.eq_ignore_ascii_case(&asset.sha256)) {
186 return Ok(dest);
187 }
188
189 let addons = crate::core::config::Config::load().addons;
190 if addons.policy() == AddonPolicy::Locked {
191 return Err("addons.policy = locked: managed artifact fetch disabled".into());
192 }
193
194 let context = format!("addon `{name}`");
195 fetch_verified(
196 &context,
197 &asset.url,
198 &asset.sha256,
199 &dest,
200 ArtifactUse::Executable,
201 )?;
202 tracing::info!(
204 "addon `{name}` binary {version} installed from {} (sha256 verified)",
205 asset.url
206 );
207 Ok(dest)
208}
209
210pub fn remove_managed_binaries(name: &str) -> bool {
213 let Ok(dir) = managed_bin_dir(name, "unversioned") else {
214 return false;
215 };
216 let root = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
218 if !root.is_dir() {
219 return false;
220 }
221 std::fs::remove_dir_all(&root).is_ok()
222}
223
224pub fn prune_other_versions(name: &str, keep_version: &str) {
227 let Ok(keep) = managed_bin_dir(name, keep_version) else {
228 return;
229 };
230 let Some(root) = keep.parent() else {
231 return;
232 };
233 let Ok(entries) = std::fs::read_dir(root) else {
234 return;
235 };
236 for entry in entries.flatten() {
237 let p = entry.path();
238 if p.is_dir() && p != keep {
239 let _ = std::fs::remove_dir_all(&p);
240 }
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::core::data_dir::isolated_data_dir;
248
249 fn asset(filename: &str, sha256: &str) -> ArtifactAsset {
250 ArtifactAsset {
251 filename: filename.into(),
252 url: "https://example.invalid/bin".into(),
253 sha256: sha256.into(),
254 }
255 }
256
257 #[test]
258 fn missing_pin_is_refused_without_network() {
259 let dest = std::env::temp_dir().join(format!("lc-artifact-test-{}-a", std::process::id()));
260 let err = fetch_verified(
261 "addon `x`",
262 "https://example.invalid/bin",
263 "",
264 &dest,
265 ArtifactUse::Executable,
266 )
267 .unwrap_err();
268 assert!(err.contains("sha256 pin"), "got: {err}");
269 assert!(!dest.exists());
270 }
271
272 #[test]
275 fn valid_managed_binary_short_circuits() {
276 let _iso = isolated_data_dir();
277 let dir = managed_bin_dir("demo", "1.0.0").unwrap();
278 std::fs::create_dir_all(&dir).unwrap();
279 let bin = dir.join("demo-bin");
280 std::fs::write(&bin, b"binary bytes").unwrap();
281 let hash = sha256_file(&bin).unwrap();
282
283 let got = ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &hash)).unwrap();
284 assert_eq!(got, bin);
285 }
286
287 #[test]
290 fn locked_policy_blocks_fetch() {
291 let _iso = isolated_data_dir();
292 crate::core::config::Config::update_global(|cfg| {
293 cfg.addons.policy = "locked".into();
294 })
295 .unwrap();
296
297 let err =
298 ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &"a".repeat(64))).unwrap_err();
299 assert!(err.contains("locked"), "got: {err}");
300 }
301
302 #[test]
303 fn tampered_managed_binary_refetches_and_fails_offline() {
304 let _iso = isolated_data_dir();
305 let dir = managed_bin_dir("demo", "1.0.0").unwrap();
306 std::fs::create_dir_all(&dir).unwrap();
307 let bin = dir.join("demo-bin");
308 std::fs::write(&bin, b"tampered").unwrap();
309
310 let err =
314 ensure_addon_binary("demo", "1.0.0", &asset("demo-bin", &"a".repeat(64))).unwrap_err();
315 assert!(err.contains("fetch failed"), "got: {err}");
316 }
317
318 #[test]
319 fn current_triple_is_known_on_ci_platforms() {
320 if cfg!(any(
321 target_os = "linux",
322 target_os = "macos",
323 target_os = "windows"
324 )) && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
325 {
326 assert_ne!(current_target_triple(), "unknown");
327 }
328 }
329
330 #[test]
331 fn remove_and_prune_managed_binaries() {
332 let _iso = isolated_data_dir();
333 for v in ["1.0.0", "1.1.0"] {
334 let dir = managed_bin_dir("demo", v).unwrap();
335 std::fs::create_dir_all(&dir).unwrap();
336 std::fs::write(dir.join("demo-bin"), v).unwrap();
337 }
338
339 prune_other_versions("demo", "1.1.0");
340 assert!(!managed_bin_dir("demo", "1.0.0").unwrap().exists());
341 assert!(managed_bin_dir("demo", "1.1.0").unwrap().exists());
342
343 assert!(remove_managed_binaries("demo"));
344 assert!(!managed_bin_dir("demo", "1.1.0").unwrap().exists());
345 }
346}