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