Skip to main content

lean_ctx/core/addons/
artifact_install.rs

1//! Unified artifact installer (GH #724/#725, Phase 1) — the one download →
2//! verify → atomic-install path for every managed binary artifact lean-ctx
3//! fetches: grammar dylibs (#690) and prebuilt addon binaries.
4//!
5//! Extracted from `grammar_install` (which keeps its zero-config policy
6//! gates and now delegates the mechanics here) so the flow exists exactly
7//! once: bounded-timeout fetch via
8//! `crate::core::http_client::ureq_agent_with_timeouts`, SHA-256 verify of
9//! a sibling `.tmp` file via [`super::binhash::sha256_file`], hardened file
10//! permissions + macOS ad-hoc signing, then an atomic rename — a bad
11//! download never lands at the destination.
12//!
13//! Addon binaries install into the **managed bin dir**
14//! `<data_dir>/addons/bin/<name>/<version>/` — deliberately never on `PATH`
15//! and never a shared user-writable location. The gateway spawns them by
16//! absolute path recorded in the install receipt, closing both PATH
17//! hijacking and the "download a binary and move it around manually" UX.
18
19use 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/// One platform's downloadable artifact: a grammar dylib or an addon binary.
28/// The shape every registry surface shares (GH #724 — `GrammarAsset` is an
29/// alias of this since Phase 1).
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct ArtifactAsset {
33    /// Release asset filename, e.g. `lean-md-aarch64-apple-darwin` or
34    /// `lua-x86_64-pc-windows-msvc.dll`.
35    pub filename: String,
36    /// Download URL for this asset.
37    pub url: String,
38    /// SHA-256 of the artifact bytes (hex). Mandatory: unpinned artifacts
39    /// are refused before any network I/O.
40    pub sha256: String,
41}
42
43/// How the installed artifact will be used — decides on-disk hardening.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ArtifactUse {
46    /// dlopen'd into our own process (grammar dylib): read-only (0o444).
47    InProcessDylib,
48    /// Spawned as a subprocess (addon binary): read + execute (0o555).
49    Executable,
50}
51
52/// Rust target-triple key this build was compiled for — matches the asset
53/// keys release CI matrices publish under (grammar dylibs and addon
54/// binaries use the same convention).
55pub 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
73/// The managed install dir for one addon version:
74/// `<data_dir>/addons/bin/<name>/<version>/`. A blank version (local
75/// manifests without one) maps to `unversioned` so the layout stays uniform.
76pub 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
89/// Reject an artifact missing its mandatory SHA-256 pin before any policy or network work.
90pub(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
99/// Download `url`, verify its SHA-256, harden permissions and atomically
100/// move it to `dest`. `context` prefixes every error/log line (e.g.
101/// ``grammar `lua` `` or ``addon `lean-md` ``) so callers keep their
102/// established message shapes.
103pub(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    // Hardening: a dylib is dlopen'd into our own process → read-only on
148    // disk (a deliberate swap still fails the per-load hash pin). An addon
149    // binary is spawned → read + execute, still not writable (a swap fails
150    // the binhash spawn pin). macOS ad-hoc signing keeps a copy that picked
151    // up a quarantine xattr along the way loadable/runnable.
152    #[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
170/// Ensure the prebuilt addon binary for `asset` is installed in the managed
171/// bin dir for `name`/`version`, fetching + verifying it if absent. Returns
172/// the absolute binary path the gateway must spawn. No-op (pure hash check)
173/// when already installed and valid; `addons.policy = locked` blocks any
174/// fetch before network I/O.
175pub 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    // Egress transparency: the one network round-trip must be visible.
203    tracing::info!(
204        "addon `{name}` binary {version} installed from {} (sha256 verified)",
205        asset.url
206    );
207    Ok(dest)
208}
209
210/// Remove every managed binary version dir for `name` (best-effort cleanup
211/// on `addon remove`). Returns whether anything was deleted.
212pub fn remove_managed_binaries(name: &str) -> bool {
213    let Ok(dir) = managed_bin_dir(name, "unversioned") else {
214        return false;
215    };
216    // Pop the version leaf to get the addon's root: `…/bin/<name>/`.
217    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
224/// Prune every managed version dir of `name` except `keep_version`
225/// (post-update cleanup; best-effort).
226pub 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    /// Already-valid install short-circuits into a pure hash check — no
273    /// policy read, no network (example.invalid would fail loudly).
274    #[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    /// `addons.policy = locked` blocks the fetch before any network I/O —
288    /// the acceptance criterion of GH #725.
289    #[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        // Hash no longer matches the pin → not a short-circuit; the refetch
311        // hits example.invalid and fails, never silently accepting the
312        // tampered file.
313        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}