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/// Download `url`, verify its SHA-256, harden permissions and atomically
90/// move it to `dest`. `context` prefixes every error/log line (e.g.
91/// ``grammar `lua` `` or ``addon `lean-md` ``) so callers keep their
92/// established message shapes.
93pub(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    // Hardening: a dylib is dlopen'd into our own process → read-only on
142    // disk (a deliberate swap still fails the per-load hash pin). An addon
143    // binary is spawned → read + execute, still not writable (a swap fails
144    // the binhash spawn pin). macOS ad-hoc signing keeps a copy that picked
145    // up a quarantine xattr along the way loadable/runnable.
146    #[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
164/// Ensure the prebuilt addon binary for `asset` is installed in the managed
165/// bin dir for `name`/`version`, fetching + verifying it if absent. Returns
166/// the absolute binary path the gateway must spawn. No-op (pure hash check)
167/// when already installed and valid; `addons.policy = locked` blocks any
168/// fetch before network I/O.
169pub 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    // Egress transparency: the one network round-trip must be visible.
197    tracing::info!(
198        "addon `{name}` binary {version} installed from {} (sha256 verified)",
199        asset.url
200    );
201    Ok(dest)
202}
203
204/// Remove every managed binary version dir for `name` (best-effort cleanup
205/// on `addon remove`). Returns whether anything was deleted.
206pub fn remove_managed_binaries(name: &str) -> bool {
207    let Ok(dir) = managed_bin_dir(name, "unversioned") else {
208        return false;
209    };
210    // Pop the version leaf to get the addon's root: `…/bin/<name>/`.
211    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
218/// Prune every managed version dir of `name` except `keep_version`
219/// (post-update cleanup; best-effort).
220pub 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    /// Already-valid install short-circuits into a pure hash check — no
267    /// policy read, no network (example.invalid would fail loudly).
268    #[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    /// `addons.policy = locked` blocks the fetch before any network I/O —
282    /// the acceptance criterion of GH #725.
283    #[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        // Hash no longer matches the pin → not a short-circuit; the refetch
305        // hits example.invalid and fails, never silently accepting the
306        // tampered file.
307        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}