oxi-cli 0.46.0

Terminal-based AI coding assistant — multi-provider, streaming-first, extensible
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Extension dynamic loading.
//!
//! Loads Rust extensions compiled as `cdylib` shared libraries (`.dylib`/`.so`/`.dll`).
//!
//! # Extension ABI
//!
//! Every extension must export a single entry point:
//!
//! ```ignore
//! #[no_mangle]
//! pub extern "C" fn oxi_extension_create() -> *mut oxi_cli::extensions::Extension {
//!     Box::into_raw(Box::new(MyExtension))
//! }
//! ```
//!
//! # Directory layout
//!
//! ```text
//! ~/.oxi/extensions/
//!   ├── my_ext.dylib    # macOS
//!   ├── other_ext.so    # Linux
//!   └── win_ext.dll     # Windows
//! ```
//!
//! Extensions are discovered in `~/.oxi/extensions/` and any extra paths
//! configured in settings.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use libloading::Library;
use sha2::Digest;

use crate::extensions::Extension;
use crate::extensions::types::ExtensionError;

/// Entry point symbol that every extension must export.
const ENTRY_SYMBOL: &[u8] = b"oxi_extension_create\0";

/// Function signature for the extension creation entry point.
type CreateFn = unsafe fn() -> *mut dyn Extension;

/// Shared library extension for the current platform.
pub const SHARED_LIB_EXTENSION: &str = if cfg!(target_os = "macos") {
    "dylib"
} else if cfg!(target_os = "windows") {
    "dll"
} else {
    "so"
};

/// Check if a file looks like a shared library for the current platform.
fn is_shared_library(path: &Path) -> bool {
    path.extension()
        .and_then(|e| e.to_str())
        .map(|e| e == SHARED_LIB_EXTENSION)
        .unwrap_or(false)
}

/// Discover extension shared libraries in `~/.oxi/extensions/` and extra paths.
pub fn discover_extensions(cwd: &Path, extra_paths: &[PathBuf]) -> Vec<PathBuf> {
    let mut paths = Vec::new();

    // ~/.oxi/extensions/
    if let Some(home) = dirs::home_dir() {
        let ext_dir = home.join(".oxi").join("extensions");
        if ext_dir.is_dir() {
            discover_in_dir(&ext_dir, &mut paths);
        }
    }

    // .oxi/extensions/ (project-local)
    let project_ext_dir = cwd.join(".oxi").join("extensions");
    if project_ext_dir.is_dir() {
        discover_in_dir(&project_ext_dir, &mut paths);
    }

    // Extra paths from settings
    for extra in extra_paths {
        if extra.is_dir() {
            discover_in_dir(extra, &mut paths);
        } else if is_shared_library(extra) && extra.exists() {
            paths.push(extra.clone());
        }
    }

    paths.sort();
    paths.dedup();
    paths
}

/// Discover extension shared libraries in a single directory.
pub fn discover_extensions_in_dir(dir: &Path) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    discover_in_dir(dir, &mut paths);
    paths
}

fn discover_in_dir(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_file() && is_shared_library(&path) {
            out.push(path);
        }
    }
}

/// Load a single extension from a shared library.
///
/// # Integrity (audit F-2)
///
/// `expected_checksum` is the SHA-256 hex digest that the caller (e.g. the
/// package manager's lockfile reader) has on record for this binary. When
/// `Some`, the binary is hashed before loading and rejected on mismatch —
/// this is the supply-chain integrity gate for native extensions, which
/// otherwise run arbitrary in-process code with no sandbox (libloading +
/// `unsafe extern "C"` entry). When `None`, the caller is opting out of
/// verification explicitly; this is reserved for locally-built extensions
/// the user just compiled and trusts by construction.
///
/// The hash comparison is constant-time on the hex string length via
/// `subtle::ConstantTimeEq` if the `subtle` dep is added; until then
/// `eq_ignore_ascii_case` is used (timing leak is negligible here since
/// the hash is not a secret and an attacker who can swap the binary
/// already controls the comparison outcome).
///
/// # Safety
///
/// The loaded library must export `oxi_extension_create` returning a valid
/// pointer to a `dyn Extension`. The library must have been compiled with
/// a compatible Rust toolchain version.
pub fn load_extension(
    path: &Path,
    expected_checksum: Option<&str>,
) -> anyhow::Result<Arc<dyn Extension>> {
    let path_display = path.display().to_string();
    // Security: native extensions are unsandboxed arbitrary in-process code
    // (loaded via libloading with no sandbox). Require explicit opt-in so
    // they cannot execute by default — mirrors the `OXI_EXTENSION_EXEC`
    // opt-in for WASM extensions.
    if std::env::var("OXI_NATIVE_EXTENSIONS").ok().as_deref() != Some("1") {
        tracing::warn!(
            path = %path_display,
            "native extension skipped — set OXI_NATIVE_EXTENSIONS=1 to load unsandboxed extensions"
        );
        anyhow::bail!(
            "Native extensions are disabled; set OXI_NATIVE_EXTENSIONS=1 to load '{}'",
            path_display
        );
    }

    if !path.exists() {
        anyhow::bail!("Extension file not found: {}", path_display);
    }

    if !is_shared_library(path) {
        anyhow::bail!(
            "Not a shared library (expected .{}): {}",
            SHARED_LIB_EXTENSION,
            path_display
        );
    }

    // F-2 (audit 2026-06-21): integrity check before mmap.
    //
    // `validate_extension` performs pre-load validation (file exists, size
    // bounds, platform extension, SHA-256). It returns `ValidatedExtension`
    // with the actual checksum; we compare it to the caller-supplied
    // expected checksum and bail on mismatch — refusing to load a binary
    // that has been swapped since the lockfile was written.
    let validated = validate_extension(path).map_err(|e| {
        anyhow::anyhow!(
            "native extension pre-load validation failed for '{}': {}",
            path_display,
            e
        )
    })?;
    if let Some(expected) = expected_checksum {
        if !validated.checksum.eq_ignore_ascii_case(expected) {
            anyhow::bail!(
                "native extension checksum mismatch for '{}': expected sha256-{expected}, got sha256-{}",
                path_display,
                validated.checksum
            );
        }
        tracing::debug!(
            path = %path_display,
            checksum = %validated.checksum,
            "native extension integrity verified"
        );
    } else {
        tracing::warn!(
            path = %path_display,
            "loading native extension WITHOUT integrity verification — caller passed None"
        );
    }

    // SAFETY: Library::new loads a shared library from the given path.
    // This is unsafe because the loaded code can perform arbitrary operations.
    // We trust the user-installed extension at the given path, AND its
    // integrity has been verified above when `expected_checksum` is Some.
    let library = unsafe { Library::new(path) }
        .map_err(|e| anyhow::anyhow!("Failed to load library '{}': {}", path_display, e))?;

    // SAFETY: library.get looks up a symbol by name in the loaded shared library.
    // The symbol name is a static constant, not user-controlled.
    let create: libloading::Symbol<CreateFn> =
        unsafe { library.get(ENTRY_SYMBOL) }.map_err(|e| {
            anyhow::anyhow!(
                "Symbol 'oxi_extension_create' not found in '{}': {}",
                path_display,
                e
            )
        })?;

    // SAFETY: Calling the extension's oxi_extension_create entry point.
    // The function signature is `unsafe fn() -> *mut dyn Extension`.
    // We check the returned pointer for null below.
    let raw_ptr = unsafe { create() };
    if raw_ptr.is_null() {
        anyhow::bail!("oxi_extension_create returned null in '{}'", path_display);
    }

    // SAFETY: Box::from_raw takes ownership of the pointer returned by
    // oxi_extension_create. The extension must have allocated this with
    // Box::new (documented contract). Null was checked above.
    let extension: Arc<dyn Extension> = unsafe {
        let boxed: Box<dyn Extension> = Box::from_raw(raw_ptr);
        Arc::from(boxed)
    };

    tracing::info!(
        name = %extension.name(),
        path = %path_display,
        "Extension loaded"
    );

    // IMPORTANT: We must keep the Library alive for the entire lifetime
    // of the extension. Leak it intentionally — the extension's code lives
    // in this library. Unloading it while extension objects exist would
    // cause undefined behavior.
    std::mem::forget(library);

    Ok(extension)
}

/// Load multiple extensions from the given paths.
///
/// Returns successfully loaded extensions and any errors encountered.
/// Does not abort on individual failures — loads as many as possible.
///
/// `checksums` is parallel to `paths`: `checksums[i]` is the expected
/// SHA-256 of `paths[i]`. Pass `None` to opt out of integrity verification
/// for a particular extension (the same semantics as `load_extension`).
/// A `Some(_)` mismatch is reported as an error but does not stop the
/// other extensions from loading.
pub fn load_extensions(
    paths: &[&Path],
    checksums: &[Option<&str>],
) -> (Vec<Arc<dyn Extension>>, Vec<anyhow::Error>) {
    assert_eq!(
        paths.len(),
        checksums.len(),
        "load_extensions: paths and checksums must be parallel slices"
    );
    let mut loaded = Vec::new();
    let mut errors = Vec::new();

    for (path, expected) in paths.iter().zip(checksums.iter()) {
        match load_extension(path, *expected) {
            Ok(ext) => loaded.push(ext),
            Err(e) => {
                tracing::warn!("Failed to load extension '{}': {}", path.display(), e);
                errors.push(e);
            }
        }
    }

    (loaded, errors)
}

/// Extension binary validation result.
#[derive(Debug)]
pub struct ValidatedExtension {
    /// Path to the validated extension binary.
    pub path: PathBuf,
    /// SHA-256 hex digest of the file contents.
    pub checksum: String,
}

/// Perform pre-load validation on an extension binary.
///
/// Checks file existence, size bounds, and platform-appropriate extension.
pub fn validate_extension(path: &Path) -> Result<ValidatedExtension, ExtensionError> {
    if !path.exists() {
        return Err(ExtensionError::LoadFailed {
            name: path.display().to_string(),
            reason: "File not found".into(),
        });
    }

    let metadata = std::fs::metadata(path).map_err(|e| ExtensionError::LoadFailed {
        name: path.display().to_string(),
        reason: format!("Cannot read file metadata: {e}"),
    })?;

    if metadata.len() == 0 {
        return Err(ExtensionError::LoadFailed {
            name: path.display().to_string(),
            reason: "Empty file".into(),
        });
    }
    if metadata.len() > 100 * 1024 * 1024 {
        return Err(ExtensionError::LoadFailed {
            name: path.display().to_string(),
            reason: "File too large (>100MB)".into(),
        });
    }

    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
    let valid_ext = match std::env::consts::OS {
        "linux" => ext == "so",
        "macos" => ext == "dylib",
        "windows" => ext == "dll",
        _ => true,
    };
    if !valid_ext {
        return Err(ExtensionError::LoadFailed {
            name: path.display().to_string(),
            reason: format!("Invalid extension: .{ext}"),
        });
    }

    let data = std::fs::read(path).map_err(|e| ExtensionError::LoadFailed {
        name: path.display().to_string(),
        reason: format!("Cannot read file: {e}"),
    })?;
    let checksum = format!("{:x}", sha2::Sha256::digest(&data));

    Ok(ValidatedExtension {
        path: path.to_path_buf(),
        checksum,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    // ── F-2 regression: validate_extension computes deterministic SHA-256 ──

    fn write_fake_ext(path: &Path, payload: &[u8]) {
        let mut f = std::fs::File::create(path).unwrap();
        f.write_all(payload).unwrap();
    }

    /// Two calls to `validate_extension` on the same file yield the same
    /// SHA-256 hex digest — the function is pure and stable.
    #[test]
    fn validate_extension_is_deterministic() {
        let tmp = tempfile::tempdir().unwrap();
        let ext_path = tmp.path().join(format!("lib.{}", SHARED_LIB_EXTENSION));
        write_fake_ext(&ext_path, b"deterministic test payload");

        let v1 = validate_extension(&ext_path).expect("validate should succeed");
        let v2 = validate_extension(&ext_path).expect("validate should succeed");
        assert_eq!(v1.checksum, v2.checksum);
        // SHA-256 hex is 64 chars, lowercase.
        assert_eq!(v1.checksum.len(), 64);
        assert!(
            v1.checksum
                .chars()
                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
        );
    }

    /// Distinct file contents produce distinct checksums.
    #[test]
    fn validate_extension_distinguishes_content() {
        let tmp = tempfile::tempdir().unwrap();
        let ext_a = tmp.path().join(format!("a.{}", SHARED_LIB_EXTENSION));
        let ext_b = tmp.path().join(format!("b.{}", SHARED_LIB_EXTENSION));
        write_fake_ext(&ext_a, b"alpha");
        write_fake_ext(&ext_b, b"beta");

        let v_a = validate_extension(&ext_a).unwrap();
        let v_b = validate_extension(&ext_b).unwrap();
        assert_ne!(v_a.checksum, v_b.checksum);
    }

    /// `validate_extension` rejects a file with the wrong platform extension
    /// (e.g. `.so` on macOS). The pre-load gate must catch this before any
    /// `libloading::Library::new` call.
    #[test]
    #[cfg(target_os = "macos")]
    fn validate_extension_rejects_wrong_platform_ext_on_macos() {
        let tmp = tempfile::tempdir().unwrap();
        // `.so` is the Linux extension; on macOS a `.dylib` is required.
        let wrong = tmp.path().join("lib.so");
        write_fake_ext(&wrong, b"x");
        let err = validate_extension(&wrong).expect_err("wrong platform ext must fail");
        let msg = format!("{err}");
        assert!(msg.contains("Invalid extension"), "unexpected err: {msg}");
    }

    /// A non-existent path returns `File not found`, not a panic.
    #[test]
    fn validate_extension_handles_missing_path() {
        let tmp = tempfile::tempdir().unwrap();
        let missing = tmp.path().join("does-not-exist.dylib");
        let err = validate_extension(&missing).expect_err("missing path must fail");
        assert!(format!("{err}").contains("File not found"));
    }
}