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> {
62 let mut paths = Vec::new();
63
64 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 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 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
92pub 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
111pub 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 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 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 let library = unsafe { Library::new(path) }
206 .map_err(|e| anyhow::anyhow!("Failed to load library '{}': {}", path_display, e))?;
207
208 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 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 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 std::mem::forget(library);
249
250 Ok(extension)
251}
252
253pub 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#[derive(Debug)]
290pub struct ValidatedExtension {
291 pub path: PathBuf,
293 pub checksum: String,
295}
296
297pub 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 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 #[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 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 #[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 #[test]
402 #[cfg(target_os = "macos")]
403 fn validate_extension_rejects_wrong_platform_ext_on_macos() {
404 let tmp = tempfile::tempdir().unwrap();
405 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 #[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}