Skip to main content

oxicode/extensions/
loading.rs

1//! Extension dynamic loading.
2//!
3//! Loads Rust extensions compiled as `cdylib` shared libraries (`.dylib`/`.so`/`.dll`).
4//!
5//! # Extension ABI
6//!
7//! Every extension must export a single entry point:
8//!
9//! ```ignore
10//! #[no_mangle]
11//! pub extern "C" fn oxicode_extension_create() -> *mut oxicode_cli::extensions::Extension {
12//!     Box::into_raw(Box::new(MyExtension))
13//! }
14//! ```
15//!
16//! # Directory layout
17//!
18//! ```text
19//! ~/.oxicode/extensions/
20//!   ├── my_ext.dylib    # macOS
21//!   ├── other_ext.so    # Linux
22//!   └── win_ext.dll     # Windows
23//! ```
24//!
25//! Extensions are discovered in `~/.oxicode/extensions/` and any extra paths
26//! configured in settings.
27
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30
31use libloading::Library;
32use sha2::Digest;
33
34use crate::extensions::Extension;
35use crate::extensions::types::ExtensionError;
36
37/// Entry point symbol that every extension must export.
38const ENTRY_SYMBOL: &[u8] = b"oxicode_extension_create\0";
39
40/// Function signature for the extension creation entry point.
41type CreateFn = unsafe fn() -> *mut dyn Extension;
42
43/// Shared library extension for the current platform.
44pub const SHARED_LIB_EXTENSION: &str = if cfg!(target_os = "macos") {
45    "dylib"
46} else if cfg!(target_os = "windows") {
47    "dll"
48} else {
49    "so"
50};
51
52/// Check if a file looks like a shared library for the current platform.
53fn is_shared_library(path: &Path) -> bool {
54    path.extension()
55        .and_then(|e| e.to_str())
56        .map(|e| e == SHARED_LIB_EXTENSION)
57        .unwrap_or(false)
58}
59
60/// Discover extension shared libraries in the canonical extensions directory
61/// (legacy `~/.oxicode/extensions/` read-only fallback) and extra paths.
62pub fn discover_extensions(cwd: &Path, extra_paths: &[PathBuf]) -> Vec<PathBuf> {
63    let mut paths = Vec::new();
64
65    // Canonical extensions dir, else legacy (pre-unified-layout installs).
66    let user_ext_dir = oxicode_catalog::oxi_home::read_path(Path::new("extensions"));
67    if let Some(ext_dir) = user_ext_dir
68        && ext_dir.is_dir()
69    {
70        discover_in_dir(&ext_dir, &mut paths);
71    }
72
73    // .oxicode/extensions/ (project-local)
74    let project_ext_dir = cwd.join(".oxicode").join("extensions");
75    if project_ext_dir.is_dir() {
76        discover_in_dir(&project_ext_dir, &mut paths);
77    }
78
79    // Extra paths from settings
80    for extra in extra_paths {
81        if extra.is_dir() {
82            discover_in_dir(extra, &mut paths);
83        } else if is_shared_library(extra) && extra.exists() {
84            paths.push(extra.clone());
85        }
86    }
87
88    paths.sort();
89    paths.dedup();
90    paths
91}
92
93/// Discover extension shared libraries in a single directory.
94pub fn discover_extensions_in_dir(dir: &Path) -> Vec<PathBuf> {
95    let mut paths = Vec::new();
96    discover_in_dir(dir, &mut paths);
97    paths
98}
99
100fn discover_in_dir(dir: &Path, out: &mut Vec<PathBuf>) {
101    let Ok(entries) = std::fs::read_dir(dir) else {
102        return;
103    };
104    for entry in entries.flatten() {
105        let path = entry.path();
106        if path.is_file() && is_shared_library(&path) {
107            out.push(path);
108        }
109    }
110}
111
112/// Load a single extension from a shared library.
113///
114/// # Integrity (audit F-2)
115///
116/// `expected_checksum` is the SHA-256 hex digest that the caller (e.g. the
117/// package manager's lockfile reader) has on record for this binary. When
118/// `Some`, the binary is hashed before loading and rejected on mismatch —
119/// this is the supply-chain integrity gate for native extensions, which
120/// otherwise run arbitrary in-process code with no sandbox (libloading +
121/// `unsafe extern "C"` entry). When `None`, the caller is opting out of
122/// verification explicitly; this is reserved for locally-built extensions
123/// the user just compiled and trusts by construction.
124///
125/// The hash comparison is constant-time on the hex string length via
126/// `subtle::ConstantTimeEq` if the `subtle` dep is added; until then
127/// `eq_ignore_ascii_case` is used (timing leak is negligible here since
128/// the hash is not a secret and an attacker who can swap the binary
129/// already controls the comparison outcome).
130///
131/// # Safety
132///
133/// The loaded library must export `oxicode_extension_create` returning a valid
134/// pointer to a `dyn Extension`. The library must have been compiled with
135/// a compatible Rust toolchain version.
136pub fn load_extension(
137    path: &Path,
138    expected_checksum: Option<&str>,
139) -> anyhow::Result<Arc<dyn Extension>> {
140    let path_display = path.display().to_string();
141    // Security: native extensions are unsandboxed arbitrary in-process code
142    // (loaded via libloading with no sandbox). Require explicit opt-in so
143    // they cannot execute by default — mirrors the `OXICODE_EXTENSION_EXEC`
144    // opt-in for WASM extensions.
145    if std::env::var("OXICODE_NATIVE_EXTENSIONS").ok().as_deref() != Some("1") {
146        tracing::warn!(
147            path = %path_display,
148            "native extension skipped — set OXICODE_NATIVE_EXTENSIONS=1 to load unsandboxed extensions"
149        );
150        anyhow::bail!(
151            "Native extensions are disabled; set OXICODE_NATIVE_EXTENSIONS=1 to load '{}'",
152            path_display
153        );
154    }
155
156    if !path.exists() {
157        anyhow::bail!("Extension file not found: {}", path_display);
158    }
159
160    if !is_shared_library(path) {
161        anyhow::bail!(
162            "Not a shared library (expected .{}): {}",
163            SHARED_LIB_EXTENSION,
164            path_display
165        );
166    }
167
168    // F-2 (audit 2026-06-21): integrity check before mmap.
169    //
170    // `validate_extension` performs pre-load validation (file exists, size
171    // bounds, platform extension, SHA-256). It returns `ValidatedExtension`
172    // with the actual checksum; we compare it to the caller-supplied
173    // expected checksum and bail on mismatch — refusing to load a binary
174    // that has been swapped since the lockfile was written.
175    let validated = validate_extension(path).map_err(|e| {
176        anyhow::anyhow!(
177            "native extension pre-load validation failed for '{}': {}",
178            path_display,
179            e
180        )
181    })?;
182    if let Some(expected) = expected_checksum {
183        if !validated.checksum.eq_ignore_ascii_case(expected) {
184            anyhow::bail!(
185                "native extension checksum mismatch for '{}': expected sha256-{expected}, got sha256-{}",
186                path_display,
187                validated.checksum
188            );
189        }
190        tracing::debug!(
191            path = %path_display,
192            checksum = %validated.checksum,
193            "native extension integrity verified"
194        );
195    } else {
196        tracing::warn!(
197            path = %path_display,
198            "loading native extension WITHOUT integrity verification — caller passed None"
199        );
200    }
201
202    // SAFETY: Library::new loads a shared library from the given path.
203    // This is unsafe because the loaded code can perform arbitrary operations.
204    // We trust the user-installed extension at the given path, AND its
205    // integrity has been verified above when `expected_checksum` is Some.
206    let library = unsafe { Library::new(path) }
207        .map_err(|e| anyhow::anyhow!("Failed to load library '{}': {}", path_display, e))?;
208
209    // SAFETY: library.get looks up a symbol by name in the loaded shared library.
210    // The symbol name is a static constant, not user-controlled.
211    let create: libloading::Symbol<CreateFn> =
212        unsafe { library.get(ENTRY_SYMBOL) }.map_err(|e| {
213            anyhow::anyhow!(
214                "Symbol 'oxicode_extension_create' not found in '{}': {}",
215                path_display,
216                e
217            )
218        })?;
219
220    // SAFETY: Calling the extension's oxicode_extension_create entry point.
221    // The function signature is `unsafe fn() -> *mut dyn Extension`.
222    // We check the returned pointer for null below.
223    let raw_ptr = unsafe { create() };
224    if raw_ptr.is_null() {
225        anyhow::bail!(
226            "oxicode_extension_create returned null in '{}'",
227            path_display
228        );
229    }
230
231    // SAFETY: Box::from_raw takes ownership of the pointer returned by
232    // oxicode_extension_create. The extension must have allocated this with
233    // Box::new (documented contract). Null was checked above.
234    let extension: Arc<dyn Extension> = unsafe {
235        let boxed: Box<dyn Extension> = Box::from_raw(raw_ptr);
236        Arc::from(boxed)
237    };
238
239    tracing::info!(
240        name = %extension.name(),
241        path = %path_display,
242        "Extension loaded"
243    );
244
245    // IMPORTANT: We must keep the Library alive for the entire lifetime
246    // of the extension. Leak it intentionally — the extension's code lives
247    // in this library. Unloading it while extension objects exist would
248    // cause undefined behavior.
249    std::mem::forget(library);
250
251    Ok(extension)
252}
253
254/// Load multiple extensions from the given paths.
255///
256/// Returns successfully loaded extensions and any errors encountered.
257/// Does not abort on individual failures — loads as many as possible.
258///
259/// `checksums` is parallel to `paths`: `checksums[i]` is the expected
260/// SHA-256 of `paths[i]`. Pass `None` to opt out of integrity verification
261/// for a particular extension (the same semantics as `load_extension`).
262/// A `Some(_)` mismatch is reported as an error but does not stop the
263/// other extensions from loading.
264pub fn load_extensions(
265    paths: &[&Path],
266    checksums: &[Option<&str>],
267) -> (Vec<Arc<dyn Extension>>, Vec<anyhow::Error>) {
268    assert_eq!(
269        paths.len(),
270        checksums.len(),
271        "load_extensions: paths and checksums must be parallel slices"
272    );
273    let mut loaded = Vec::new();
274    let mut errors = Vec::new();
275
276    for (path, expected) in paths.iter().zip(checksums.iter()) {
277        match load_extension(path, *expected) {
278            Ok(ext) => loaded.push(ext),
279            Err(e) => {
280                tracing::warn!("Failed to load extension '{}': {}", path.display(), e);
281                errors.push(e);
282            }
283        }
284    }
285
286    (loaded, errors)
287}
288
289/// Extension binary validation result.
290#[derive(Debug)]
291pub struct ValidatedExtension {
292    /// Path to the validated extension binary.
293    pub path: PathBuf,
294    /// SHA-256 hex digest of the file contents.
295    pub checksum: String,
296}
297
298/// Perform pre-load validation on an extension binary.
299///
300/// Checks file existence, size bounds, and platform-appropriate extension.
301pub fn validate_extension(path: &Path) -> Result<ValidatedExtension, ExtensionError> {
302    if !path.exists() {
303        return Err(ExtensionError::LoadFailed {
304            name: path.display().to_string(),
305            reason: "File not found".into(),
306        });
307    }
308
309    let metadata = std::fs::metadata(path).map_err(|e| ExtensionError::LoadFailed {
310        name: path.display().to_string(),
311        reason: format!("Cannot read file metadata: {e}"),
312    })?;
313
314    if metadata.len() == 0 {
315        return Err(ExtensionError::LoadFailed {
316            name: path.display().to_string(),
317            reason: "Empty file".into(),
318        });
319    }
320    if metadata.len() > 100 * 1024 * 1024 {
321        return Err(ExtensionError::LoadFailed {
322            name: path.display().to_string(),
323            reason: "File too large (>100MB)".into(),
324        });
325    }
326
327    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
328    let valid_ext = match std::env::consts::OS {
329        "linux" => ext == "so",
330        "macos" => ext == "dylib",
331        "windows" => ext == "dll",
332        _ => true,
333    };
334    if !valid_ext {
335        return Err(ExtensionError::LoadFailed {
336            name: path.display().to_string(),
337            reason: format!("Invalid extension: .{ext}"),
338        });
339    }
340
341    let data = std::fs::read(path).map_err(|e| ExtensionError::LoadFailed {
342        name: path.display().to_string(),
343        reason: format!("Cannot read file: {e}"),
344    })?;
345    let checksum = format!("{:x}", sha2::Sha256::digest(&data));
346
347    Ok(ValidatedExtension {
348        path: path.to_path_buf(),
349        checksum,
350    })
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use std::io::Write;
357
358    // ── F-2 regression: validate_extension computes deterministic SHA-256 ──
359
360    fn write_fake_ext(path: &Path, payload: &[u8]) {
361        let mut f = std::fs::File::create(path).unwrap();
362        f.write_all(payload).unwrap();
363    }
364
365    /// Two calls to `validate_extension` on the same file yield the same
366    /// SHA-256 hex digest — the function is pure and stable.
367    #[test]
368    fn validate_extension_is_deterministic() {
369        let tmp = tempfile::tempdir().unwrap();
370        let ext_path = tmp.path().join(format!("lib.{}", SHARED_LIB_EXTENSION));
371        write_fake_ext(&ext_path, b"deterministic test payload");
372
373        let v1 = validate_extension(&ext_path).expect("validate should succeed");
374        let v2 = validate_extension(&ext_path).expect("validate should succeed");
375        assert_eq!(v1.checksum, v2.checksum);
376        // SHA-256 hex is 64 chars, lowercase.
377        assert_eq!(v1.checksum.len(), 64);
378        assert!(
379            v1.checksum
380                .chars()
381                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
382        );
383    }
384
385    /// Distinct file contents produce distinct checksums.
386    #[test]
387    fn validate_extension_distinguishes_content() {
388        let tmp = tempfile::tempdir().unwrap();
389        let ext_a = tmp.path().join(format!("a.{}", SHARED_LIB_EXTENSION));
390        let ext_b = tmp.path().join(format!("b.{}", SHARED_LIB_EXTENSION));
391        write_fake_ext(&ext_a, b"alpha");
392        write_fake_ext(&ext_b, b"beta");
393
394        let v_a = validate_extension(&ext_a).unwrap();
395        let v_b = validate_extension(&ext_b).unwrap();
396        assert_ne!(v_a.checksum, v_b.checksum);
397    }
398
399    /// `validate_extension` rejects a file with the wrong platform extension
400    /// (e.g. `.so` on macOS). The pre-load gate must catch this before any
401    /// `libloading::Library::new` call.
402    #[test]
403    #[cfg(target_os = "macos")]
404    fn validate_extension_rejects_wrong_platform_ext_on_macos() {
405        let tmp = tempfile::tempdir().unwrap();
406        // `.so` is the Linux extension; on macOS a `.dylib` is required.
407        let wrong = tmp.path().join("lib.so");
408        write_fake_ext(&wrong, b"x");
409        let err = validate_extension(&wrong).expect_err("wrong platform ext must fail");
410        let msg = format!("{err}");
411        assert!(msg.contains("Invalid extension"), "unexpected err: {msg}");
412    }
413
414    /// A non-existent path returns `File not found`, not a panic.
415    #[test]
416    fn validate_extension_handles_missing_path() {
417        let tmp = tempfile::tempdir().unwrap();
418        let missing = tmp.path().join("does-not-exist.dylib");
419        let err = validate_extension(&missing).expect_err("missing path must fail");
420        assert!(format!("{err}").contains("File not found"));
421    }
422}