oxicode/extensions/
loading.rs1use 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
37const ENTRY_SYMBOL: &[u8] = b"oxicode_extension_create\0";
39
40type CreateFn = unsafe fn() -> *mut dyn Extension;
42
43pub 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
52fn 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
60pub fn discover_extensions(cwd: &Path, extra_paths: &[PathBuf]) -> Vec<PathBuf> {
63 let mut paths = Vec::new();
64
65 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 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 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
93pub 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
112pub 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 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 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 let library = unsafe { Library::new(path) }
207 .map_err(|e| anyhow::anyhow!("Failed to load library '{}': {}", path_display, e))?;
208
209 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 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 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 std::mem::forget(library);
250
251 Ok(extension)
252}
253
254pub 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#[derive(Debug)]
291pub struct ValidatedExtension {
292 pub path: PathBuf,
294 pub checksum: String,
296}
297
298pub 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 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 #[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 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 #[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 #[test]
403 #[cfg(target_os = "macos")]
404 fn validate_extension_rejects_wrong_platform_ext_on_macos() {
405 let tmp = tempfile::tempdir().unwrap();
406 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 #[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}