diff --git a/make/help.mk b/make/help.mk index 6a16a9a1d..40d7ba249 100644 --- a/make/help.mk +++ b/make/help.mk @@ -51,6 +51,7 @@ help: @echo " make test:integration:node - Run Node.js SDK integration tests (requires VM)" @echo " make test:all:node - Run all Node.js SDK tests" @echo " make test:unit:go - Run Go SDK unit tests" + @echo " make test:integration:go - Run Go SDK archive round-trip test (requires VM, FILTER=)" @echo " make test:all:go - Run all Go SDK tests" @echo " make test:unit:c - Run C SDK unit tests (no VM required)" @echo " make test:integration:c - Run C SDK integration tests (requires VM)" diff --git a/make/test.mk b/make/test.mk index 493102662..b63ef55be 100644 --- a/make/test.mk +++ b/make/test.mk @@ -348,6 +348,12 @@ test\:unit\:go: @$(MAKE) dev:go @cd sdks/go && go test -tags boxlite_dev -v $(GOTEST_FILTER) ./... +# Go SDK archive round-trip integration test. Intentionally excluded from +# default test matrices and CI. +test\:integration\:go: dev\:go + @echo "๐Ÿงช Running Go SDK archive integration test (requires VM)..." + @cd sdks/go && go test -count=1 -tags=boxlite_dev,boxlite_integration -v $(GOTEST_FILTER) ./integration/archive + # Go SDK full suite. test\:all\:go: @$(MAKE) test:unit:go diff --git a/sdks/c/include/boxlite.h b/sdks/c/include/boxlite.h index 606d45d9f..8d476e296 100644 --- a/sdks/c/include/boxlite.h +++ b/sdks/c/include/boxlite.h @@ -143,14 +143,20 @@ typedef struct FFIError { char *message; } FFIError; -typedef struct RuntimeHandle CBoxliteRuntime; - -typedef struct OptionsHandle CBoxliteOptions; - typedef struct BoxHandle CBoxHandle; typedef struct FFIError CBoxliteError; +// Box export completion. +typedef void (*CBoxExportCb)(char*, CBoxliteError*, void*); + +typedef struct RuntimeHandle CBoxliteRuntime; + +// Runtime import completion. +typedef void (*CRuntimeImportCb)(CBoxHandle*, CBoxliteError*, void*); + +typedef struct OptionsHandle CBoxliteOptions; + // Box creation completion. typedef void (*CBoxCreateBoxCb)(CBoxHandle*, CBoxliteError*, void*); @@ -463,6 +469,28 @@ enum BoxliteErrorCode boxlite_advanced_options_set_capabilities_drop(CAdvancedBo const char *const *capabilities, int count); +// Submit a box export. +// +// On success, the callback owns the returned path and must release it with +// `boxlite_free_string`. The archive itself is never deleted by this bridge. +enum BoxliteErrorCode boxlite_box_export(CBoxHandle *handle, + const char *dest, + CBoxExportCb cb, + void *user_data, + CBoxliteError *out_error); + +// Submit a trusted archive import. +// +// A null or empty `name_or_null` leaves the new box unnamed. The callback +// owns the returned stopped box handle. The caller retains ownership of the +// archive file; this bridge never removes it. +enum BoxliteErrorCode boxlite_runtime_import(CBoxliteRuntime *runtime, + const char *archive_path, + const char *name_or_null, + CRuntimeImportCb cb, + void *user_data, + CBoxliteError *out_error); + enum BoxliteErrorCode boxlite_create_box(CBoxliteRuntime *runtime, CBoxliteOptions *opts, CBoxCreateBoxCb cb, diff --git a/sdks/c/src/archive.rs b/sdks/c/src/archive.rs new file mode 100644 index 000000000..027030ff2 --- /dev/null +++ b/sdks/c/src/archive.rs @@ -0,0 +1,412 @@ +//! Path-based box archive import and export for the C SDK. +//! +//! Inputs are copied before the entrypoint returns, and completions are +//! delivered by the runtime's existing post-and-drain event queue. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_void}; +use std::path::PathBuf; +use std::sync::Arc; + +use boxlite::{BoxArchive, BoxliteError, ExportOptions}; +use tokio::runtime::Runtime as TokioRuntime; + +use crate::box_handle::BoxHandle; +use crate::error::{BoxliteErrorCode, FFIError, error_to_code, null_pointer_error, write_error}; +use crate::event_queue::{CBoxExportCb, CRuntimeImportCb, OwnedFfiPtr, RuntimeEvent, push_event}; +use crate::runtime::RuntimeHandle; +use crate::{CBoxHandle, CBoxliteError, CBoxliteRuntime}; + +// use it to prevent the runtime from being dropped while the import task is running. +// The runtime is cloned into the task, +// and this guard will drop that clone when the task completes. +// "an Arc will prevent the runtime from shutting down." according to: https://docs.rs/tokio/latest/tokio/runtime/struct.Runtime.html#sharing +struct TokioRuntimeDropGuard(Option>); + +impl TokioRuntimeDropGuard { + fn new(runtime: Arc) -> Self { + Self(Some(runtime)) + } +} + +impl Drop for TokioRuntimeDropGuard { + fn drop(&mut self) { + let Some(runtime) = self.0.take() else { + return; + }; + match Arc::try_unwrap(runtime) { + Ok(runtime) => runtime.shutdown_background(), + Err(runtime) => { + drop(std::thread::spawn(move || drop(runtime))); + } + } + } +} + +/// Submit a box export. +/// +/// On success, the callback owns the returned path and must release it with +/// `boxlite_free_string`. The archive itself is never deleted by this bridge. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_box_export( + handle: *mut CBoxHandle, + dest: *const c_char, + cb: CBoxExportCb, + user_data: *mut c_void, + out_error: *mut CBoxliteError, +) -> BoxliteErrorCode { + unsafe { export_box(handle, dest, cb, user_data, out_error) } +} + +/// Submit a trusted archive import. +/// +/// A null or empty `name_or_null` leaves the new box unnamed. The callback +/// owns the returned stopped box handle. The caller retains ownership of the +/// archive file; this bridge never removes it. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_runtime_import( + runtime: *mut CBoxliteRuntime, + archive_path: *const c_char, + name_or_null: *const c_char, + cb: CRuntimeImportCb, + user_data: *mut c_void, + out_error: *mut CBoxliteError, +) -> BoxliteErrorCode { + unsafe { + import_box( + runtime, + archive_path, + name_or_null, + cb, + user_data, + out_error, + ) + } +} + +unsafe fn export_box( + handle: *mut BoxHandle, + dest: *const c_char, + cb: CBoxExportCb, + user_data: *mut c_void, + out_error: *mut FFIError, +) -> BoxliteErrorCode { + unsafe { + if handle.is_null() { + write_error(out_error, null_pointer_error("handle")); + return BoxliteErrorCode::InvalidArgument; + } + let dest = match parse_required_path(dest, "dest") { + Ok(dest) => dest, + Err(error) => { + let code = error_to_code(&error); + write_error(out_error, error); + return code; + } + }; + let cb = crate::unwrap_cb_or_return!(cb, out_error); + + let handle_ref = &*handle; + let lite = handle_ref.handle.clone(); + let queue = handle_ref.queue.clone(); + let user_data = user_data as usize; + + handle_ref.tokio_rt.spawn(async move { + let result = lite + .export(ExportOptions::default(), &dest) + .await + .and_then(|archive| archive_path_to_c_string(archive.path())); + push_event( + &queue, + RuntimeEvent::BoxExport { + cb, + user_data, + result, + }, + ) + .await; + }); + + BoxliteErrorCode::Ok + } +} + +unsafe fn import_box( + runtime: *mut RuntimeHandle, + archive_path: *const c_char, + name_or_null: *const c_char, + cb: CRuntimeImportCb, + user_data: *mut c_void, + out_error: *mut FFIError, +) -> BoxliteErrorCode { + unsafe { + if runtime.is_null() { + write_error(out_error, null_pointer_error("runtime")); + return BoxliteErrorCode::InvalidArgument; + } + let archive_path = match parse_required_path(archive_path, "archive_path") { + Ok(archive_path) => archive_path, + Err(error) => { + let code = error_to_code(&error); + write_error(out_error, error); + return code; + } + }; + let name = match parse_optional_name(name_or_null) { + Ok(name) => name, + Err(error) => { + let code = error_to_code(&error); + write_error(out_error, error); + return code; + } + }; + let cb = crate::unwrap_cb_or_return!(cb, out_error); + + let runtime_ref = &*runtime; + let runtime = runtime_ref.runtime.clone(); + let tokio_rt = runtime_ref.tokio_rt.clone(); + let queue = runtime_ref.queue.clone(); + let box_tokio_rt = tokio_rt.clone(); + let task_tokio_rt = tokio_rt.clone(); + let box_queue = queue.clone(); + let user_data = user_data as usize; + + tokio_rt.spawn(async move { + // Prevent the runtime from being dropped while the import task is running. + let _runtime_drop_guard = TokioRuntimeDropGuard::new(task_tokio_rt); + // Local runtimes intentionally trust caller-managed archives. A + // REST server applies its own untrusted-upload policy server-side. + let archive = BoxArchive::new(archive_path); + let result = runtime.import_box(archive, name).await.map(|handle| { + let box_id = handle.id().clone(); + OwnedFfiPtr::new(Box::new(BoxHandle { + handle: Arc::new(handle), + box_id, + tokio_rt: box_tokio_rt, + queue: box_queue, + })) + }); + push_event( + &queue, + RuntimeEvent::RuntimeImport { + cb, + user_data, + result, + }, + ) + .await; + }); + + BoxliteErrorCode::Ok + } +} + +unsafe fn parse_required_path( + value: *const c_char, + parameter: &str, +) -> Result { + let value = unsafe { parse_required_string(value, parameter)? }; + if value.is_empty() { + return Err(BoxliteError::InvalidArgument(format!( + "{parameter} must not be empty" + ))); + } + Ok(PathBuf::from(value)) +} + +unsafe fn parse_optional_name(value: *const c_char) -> Result, BoxliteError> { + if value.is_null() { + return Ok(None); + } + let value = unsafe { parse_required_string(value, "name_or_null")? }; + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +unsafe fn parse_required_string( + value: *const c_char, + parameter: &str, +) -> Result { + if value.is_null() { + return Err(null_pointer_error(parameter)); + } + unsafe { CStr::from_ptr(value) } + .to_str() + .map(str::to_owned) + .map_err(|error| { + BoxliteError::InvalidArgument(format!("{parameter} is not valid UTF-8: {error}")) + }) +} + +fn archive_path_to_c_string(path: &std::path::Path) -> Result { + CString::new(path.to_string_lossy().as_bytes()).map_err(|_| { + BoxliteError::Internal("exported archive path contains an interior NUL byte".to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::ptr; + + extern "C" fn noop_export_cb( + _archive_path: *mut c_char, + _error: *mut CBoxliteError, + _user_data: *mut c_void, + ) { + } + + extern "C" fn noop_import_cb( + _handle: *mut CBoxHandle, + _error: *mut CBoxliteError, + _user_data: *mut c_void, + ) { + } + + fn assert_invalid_argument(code: BoxliteErrorCode, error: &mut FFIError, parameter: &str) { + assert_eq!(code, BoxliteErrorCode::InvalidArgument); + assert_eq!(error.code, BoxliteErrorCode::InvalidArgument); + assert!(!error.message.is_null()); + let message = unsafe { CStr::from_ptr(error.message) }.to_string_lossy(); + assert!( + message.contains(parameter), + "error should mention {parameter}: {message}" + ); + unsafe { crate::boxlite_error_free(error) }; + } + + #[test] + fn export_rejects_invalid_arguments_synchronously() { + let dest = CString::new("/tmp/export.boxlite").unwrap(); + let dangling_handle = ptr::NonNull::::dangling().as_ptr(); + let mut error = FFIError::default(); + + let code = unsafe { + boxlite_box_export( + ptr::null_mut(), + dest.as_ptr(), + Some(noop_export_cb), + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "handle"); + + let code = unsafe { + boxlite_box_export( + dangling_handle, + ptr::null(), + Some(noop_export_cb), + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "dest"); + + let code = unsafe { + boxlite_box_export( + dangling_handle, + dest.as_ptr(), + None, + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "cb"); + } + + #[test] + fn import_rejects_invalid_arguments_synchronously() { + let archive_path = CString::new("/tmp/import.boxlite").unwrap(); + let dangling_runtime = ptr::NonNull::::dangling().as_ptr(); + let mut error = FFIError::default(); + + let code = unsafe { + boxlite_runtime_import( + ptr::null_mut(), + archive_path.as_ptr(), + ptr::null(), + Some(noop_import_cb), + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "runtime"); + + let code = unsafe { + boxlite_runtime_import( + dangling_runtime, + ptr::null(), + ptr::null(), + Some(noop_import_cb), + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "archive_path"); + + let code = unsafe { + boxlite_runtime_import( + dangling_runtime, + archive_path.as_ptr(), + ptr::null(), + None, + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "cb"); + } + + #[test] + fn archive_paths_must_not_be_empty() { + let empty = CString::new("").unwrap(); + let dangling_handle = ptr::NonNull::::dangling().as_ptr(); + let dangling_runtime = ptr::NonNull::::dangling().as_ptr(); + let mut error = FFIError::default(); + + let code = unsafe { + boxlite_box_export( + dangling_handle, + empty.as_ptr(), + Some(noop_export_cb), + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "dest"); + + let code = unsafe { + boxlite_runtime_import( + dangling_runtime, + empty.as_ptr(), + ptr::null(), + Some(noop_import_cb), + ptr::null_mut(), + &mut error, + ) + }; + assert_invalid_argument(code, &mut error, "archive_path"); + } + + #[test] + fn null_and_empty_import_names_are_unnamed() { + assert_eq!(unsafe { parse_optional_name(ptr::null()) }.unwrap(), None); + + let empty = CString::new("").unwrap(); + assert_eq!( + unsafe { parse_optional_name(empty.as_ptr()) }.unwrap(), + None + ); + + let named = CString::new("restored-box").unwrap(); + assert_eq!( + unsafe { parse_optional_name(named.as_ptr()) }.unwrap(), + Some("restored-box".to_string()) + ); + } +} diff --git a/sdks/c/src/event_queue.rs b/sdks/c/src/event_queue.rs index abc0cd89c..ad0c43c72 100644 --- a/sdks/c/src/event_queue.rs +++ b/sdks/c/src/event_queue.rs @@ -5,7 +5,8 @@ //! thread. Callbacks therefore NEVER fire on Tokio worker threads. use std::collections::VecDeque; -use std::os::raw::{c_int, c_void}; +use std::ffi::CString; +use std::os::raw::{c_char, c_int, c_void}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Condvar, Mutex}; @@ -89,6 +90,16 @@ pub type CBoxGetOrCreateBoxCb = pub(crate) type CBoxGetOrCreateBoxFn = extern "C" fn(*mut crate::CBoxHandle, bool, *mut crate::CBoxliteError, *mut c_void); +/// Box export completion. +pub type CBoxExportCb = Option; +pub(crate) type CBoxExportFn = extern "C" fn(*mut c_char, *mut crate::CBoxliteError, *mut c_void); + +/// Runtime import completion. +pub type CRuntimeImportCb = + Option; +pub(crate) type CRuntimeImportFn = + extern "C" fn(*mut crate::CBoxHandle, *mut crate::CBoxliteError, *mut c_void); + /// Box start completion. pub type CBoxStartBoxCb = Option; pub(crate) type CBoxStartBoxFn = extern "C" fn(*mut crate::CBoxliteError, *mut c_void); @@ -320,6 +331,16 @@ pub enum RuntimeEvent { user_data: usize, result: Result<(OwnedFfiPtr, bool), BoxliteError>, }, + BoxExport { + cb: CBoxExportFn, + user_data: usize, + result: Result, + }, + RuntimeImport { + cb: CRuntimeImportFn, + user_data: usize, + result: Result, BoxliteError>, + }, StartBox { cb: CBoxStartBoxFn, user_data: usize, @@ -448,10 +469,15 @@ impl EventQueue { } } - /// Mark the queue closed and wake every parked drainer so they observe it. + /// Close the queue, discard pending events, and wake every parked drainer. pub fn mark_closed(&self) { - self.closed.store(true, Ordering::Release); + let pending = { + let mut events = self.inner.lock().unwrap(); + self.closed.store(true, Ordering::Release); + std::mem::take(&mut *events) + }; self.cv.notify_all(); + drop(pending); } pub fn is_closed(&self) -> bool { @@ -489,6 +515,10 @@ pub(crate) async fn push_event_with_capacity( } { let mut g = queue.inner.lock().unwrap(); + if queue.is_closed() { + drop(g); + return; + } if g.len() < capacity { g.push_back(ev.take().expect("event consumed exactly once")); drop(g); @@ -839,7 +869,9 @@ mod phase2_regression_tests { mod close_and_free_tests { use super::*; + use std::ffi::CString; use std::sync::Arc; + use std::sync::atomic::Ordering as AtomicOrdering; use std::sync::mpsc::{TryRecvError, channel}; use std::thread; use std::time::{Duration, Instant}; @@ -847,8 +879,9 @@ mod close_and_free_tests { use tokio::runtime::Builder as TokioBuilder; use crate::error::FFIError; + use crate::images::{CImagePullResult, free_image_pull_result}; use crate::runtime::{RuntimeHandle, RuntimeLiveness}; - use crate::{boxlite_runtime_drain, boxlite_runtime_free}; + use crate::{FREE_STR_CALLS, FREE_STR_LOCK, boxlite_runtime_drain, boxlite_runtime_free}; fn new_stub_runtime_handle() -> *mut RuntimeHandle { let tokio_rt = Arc::new( @@ -891,6 +924,28 @@ mod close_and_free_tests { extern "C" fn dummy_stdout_cb(_data: *const u8, _len: usize, _ud: *mut c_void) {} + extern "C" fn noop_image_pull_cb( + _result: *mut CImagePullResult, + _error: *mut crate::CBoxliteError, + _user_data: *mut c_void, + ) { + } + + fn tracked_owned_event() -> RuntimeEvent { + RuntimeEvent::ImagePull { + cb: noop_image_pull_cb, + user_data: 0, + result: Ok(OwnedFfiPtr::new_with( + Box::new(CImagePullResult { + reference: CString::new("alpine:latest").unwrap().into_raw(), + config_digest: CString::new("sha256:queued").unwrap().into_raw(), + layer_count: 1, + }), + free_image_pull_result, + )), + } + } + /// Closes the queue while a drain is parked on `cv.wait(timeout=-1)`. /// Asserts drain returns within 100ms. /// @@ -949,6 +1004,66 @@ mod close_and_free_tests { assert_eq!(queue.inner.lock().unwrap().len(), 0); } + #[test] + fn runtime_free_reclaims_already_queued_owned_payload() { + let guard = FREE_STR_LOCK.lock().unwrap(); + let before = FREE_STR_CALLS.load(AtomicOrdering::SeqCst); + let rt_ptr = new_stub_runtime_handle(); + let queue = unsafe { (*rt_ptr).queue.clone() }; + let tokio_rt = unsafe { (*rt_ptr).tokio_rt.clone() }; + + tokio_rt.block_on(push_event_with_capacity(&queue, tracked_owned_event(), 4)); + assert_eq!(queue.inner.lock().unwrap().len(), 1); + + unsafe { boxlite_runtime_free(rt_ptr) }; + + let reclaimed = FREE_STR_CALLS.load(AtomicOrdering::SeqCst) - before; + let pending = queue.inner.lock().unwrap().len(); + drop(queue); + drop(guard); + + assert_eq!( + reclaimed, 2, + "runtime_free returned while the queued owned payload was still retained" + ); + assert_eq!( + pending, 0, + "runtime_free left a pending event in the closed queue" + ); + } + + #[test] + fn closed_queue_reclaims_late_owned_payload() { + let guard = FREE_STR_LOCK.lock().unwrap(); + let before = FREE_STR_CALLS.load(AtomicOrdering::SeqCst); + let queue = Arc::new(EventQueue::new()); + queue.mark_closed(); + let tokio_rt = TokioBuilder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + tokio_rt.block_on(push_event_with_capacity(&queue, tracked_owned_event(), 4)); + + let reclaimed = FREE_STR_CALLS.load(AtomicOrdering::SeqCst) - before; + let pending = queue.inner.lock().unwrap().len(); + drop(queue); + drop(guard); + + assert_eq!(reclaimed, 2, "late event payload was not reclaimed"); + assert_eq!(pending, 0, "late event was queued after close"); + } + + #[test] + fn closing_queue_twice_is_idempotent() { + let queue = EventQueue::new(); + queue.mark_closed(); + queue.mark_closed(); + + assert!(queue.is_closed()); + assert!(queue.inner.lock().unwrap().is_empty()); + } + /// The actual UAF reproducer: drain is parked when runtime_free runs. /// /// BEFORE FIX: drain holds `let rt = &*rt;` borrow; runtime_free does @@ -1073,50 +1188,6 @@ mod owned_ffi_ptr_tests { } assert_eq!(counter.load(AtomicOrdering::SeqCst), 1); } - - /// End-to-end guard: an event whose payload is an `OwnedFfiPtr`, - /// pushed into a closed queue, must reclaim the allocation. - /// - /// We use the real `RuntimeEvent::Info` variant because `CBoxInfo` is a - /// plain repr(C) struct that's trivial to construct in a test. - #[test] - fn closed_queue_drops_event_with_owned_payload_does_not_leak() { - // SAFETY: CBoxInfo is repr(C) with all-pointer/integer fields โ€” zero - // is a valid bit pattern for our construction-only test (the Drop - // counter we care about is on the wrapping Box's ownership chain, - // not on CBoxInfo's internals). - use std::sync::atomic::AtomicUsize; - let counter = Arc::new(AtomicUsize::new(0)); - - // Use OwnedFfiPtr directly, not via RuntimeEvent โ€” - // the variant is typed for FFI structs and TrackedResource isn't - // one. The point of the test is the close-path behaviour, which - // depends only on Drop running on the OwnedFfiPtr. - let queue = Arc::new(EventQueue::new()); - queue.mark_closed(); - - // Stand-in: simulate the producer side of CreateBox by allocating a - // tracked resource, immediately wrapping in OwnedFfiPtr, then - // explicitly dropping the wrapper to mirror what happens when - // push_event_with_capacity short-circuits on a closed queue. - let owned = OwnedFfiPtr::new(Box::new(TrackedResource { - counter: counter.clone(), - })); - drop(owned); - - // The wrapper's Drop must have reclaimed the underlying Box. - assert_eq!( - counter.load(AtomicOrdering::SeqCst), - 1, - "dropping the OwnedFfiPtr (i.e. closed-queue event drop path) \ - did NOT reclaim the underlying allocation" - ); - - // Sanity: marking the queue closed shouldn't change anything โ€” the - // test never actually pushes; it directly drops, which is the same - // outcome the close-path produces. - assert!(queue.is_closed()); - } } // โ”€โ”€โ”€ OwnedFfiPtr must reclaim nested CString allocations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/sdks/c/src/lib.rs b/sdks/c/src/lib.rs index f6efa5970..5fb71e3f8 100644 --- a/sdks/c/src/lib.rs +++ b/sdks/c/src/lib.rs @@ -8,6 +8,7 @@ #![allow(clippy::too_many_arguments)] mod advanced_options; +mod archive; mod box_handle; mod copy; mod error; diff --git a/sdks/c/src/runtime.rs b/sdks/c/src/runtime.rs index 6073b363e..499c4864e 100644 --- a/sdks/c/src/runtime.rs +++ b/sdks/c/src/runtime.rs @@ -3,6 +3,7 @@ //! Provides Tokio runtime, BoxliteRuntime handle management, and the //! per-runtime event queue + drain that drives the post-and-drain callback API. +use std::ffi::CString; use std::os::raw::{c_char, c_int, c_void}; use std::ptr; use std::sync::Arc; @@ -551,6 +552,16 @@ unsafe fn dispatch_event(event: RuntimeEvent) { user_data, result, } => dispatch_get_or_create_event(result, user_data, cb), + RuntimeEvent::BoxExport { + cb, + user_data, + result, + } => dispatch_string_event(result, user_data, cb), + RuntimeEvent::RuntimeImport { + cb, + user_data, + result, + } => dispatch_handle_event::(result, user_data, cb), RuntimeEvent::StartBox { cb, user_data, @@ -674,6 +685,9 @@ type UnitCb = extern "C" fn(*mut FFIError, *mut c_void); /// Callback shape for events carrying an owned out-pointer + possible error. type HandleCb = extern "C" fn(*mut T, *mut FFIError, *mut c_void); +/// Callback shape for events carrying an owned C string + possible error. +type StringCb = extern "C" fn(*mut c_char, *mut FFIError, *mut c_void); + unsafe fn dispatch_unit_event(result: Result<(), BoxliteError>, user_data: usize, cb: UnitCb) { unsafe { let mut err = FFIError::default(); @@ -714,6 +728,29 @@ unsafe fn dispatch_handle_event( } } +/// Dispatch an owned C string. The callback takes ownership of a successful +/// value and must release it with `boxlite_free_string`. +unsafe fn dispatch_string_event( + result: Result, + user_data: usize, + cb: StringCb, +) { + unsafe { + let mut err = FFIError::default(); + let value = match result { + Ok(value) => value.into_raw(), + Err(error) => { + err = crate::error::error_to_c_error(error); + ptr::null_mut() + } + }; + cb(value, &mut err as *mut _, user_data as *mut c_void); + if !err.message.is_null() { + crate::boxlite_error_free(&mut err); + } + } +} + /// Like [`dispatch_handle_event`] for the box handle, but also forwards the /// `created` flag (`true` = newly created, `false` = adopted existing box). /// On error the handle is null and `created` is reported as `false`. @@ -869,4 +906,67 @@ mod tests { assert!(result.is_err()); } } + + static BOX_EXPORT_CALLBACK_VALUE: std::sync::Mutex> = + std::sync::Mutex::new(None); + static RUNTIME_IMPORT_CALLBACK_DROPS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + + struct TrackedImportPayload; + + impl Drop for TrackedImportPayload { + fn drop(&mut self) { + RUNTIME_IMPORT_CALLBACK_DROPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + + extern "C" fn capture_box_export_path( + value: *mut c_char, + _error: *mut FFIError, + _user_data: *mut c_void, + ) { + let value = if value.is_null() { + None + } else { + let value = unsafe { CString::from_raw(value) }; + Some(value.into_string().expect("valid UTF-8 callback path")) + }; + *BOX_EXPORT_CALLBACK_VALUE.lock().unwrap() = value; + } + + extern "C" fn consume_runtime_import_handle( + value: *mut TrackedImportPayload, + _error: *mut FFIError, + _user_data: *mut c_void, + ) { + if !value.is_null() { + unsafe { drop(Box::from_raw(value)) }; + } + } + + #[test] + fn box_export_dispatch_transfers_string_ownership_to_callback() { + *BOX_EXPORT_CALLBACK_VALUE.lock().unwrap() = None; + let value = CString::new("/tmp/export.boxlite").unwrap(); + + unsafe { dispatch_string_event(Ok(value), 0, capture_box_export_path) }; + + assert_eq!( + BOX_EXPORT_CALLBACK_VALUE.lock().unwrap().take().as_deref(), + Some("/tmp/export.boxlite") + ); + } + + #[test] + fn runtime_import_dispatch_transfers_handle_ownership_to_callback() { + RUNTIME_IMPORT_CALLBACK_DROPS.store(0, std::sync::atomic::Ordering::SeqCst); + let owned = crate::event_queue::OwnedFfiPtr::new(Box::new(TrackedImportPayload)); + + unsafe { dispatch_handle_event(Ok(owned), 0, consume_runtime_import_handle) }; + + assert_eq!( + RUNTIME_IMPORT_CALLBACK_DROPS.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + } } diff --git a/sdks/go/README.md b/sdks/go/README.md index 1b0306888..15351d547 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -66,6 +66,30 @@ func main() { } ``` +### Archive Export and Import + +```go +archivePath, err := box.Export(ctx, "/var/lib/my-app/archives") +if err != nil { + log.Fatal(err) +} + +// An empty name uses the same unnamed-box behavior as Create without +// WithName. The imported box receives a new ID and starts stopped. +restored, err := rt.Import(ctx, archivePath, "") +if err != nil { + log.Fatal(err) +} +defer restored.Close() +``` + +A local runtime treats the archive as trusted because local applications own +both the runtime and archive. A REST runtime uploads the file and relies on the +server's untrusted-upload policy. + +Export and Import never delete the archive. The caller owns its retention and +must explicitly remove it when it is no longer needed. + ### Runtime Image Management ```go diff --git a/sdks/go/archive.go b/sdks/go/archive.go new file mode 100644 index 000000000..a4792ad97 --- /dev/null +++ b/sdks/go/archive.go @@ -0,0 +1,186 @@ +package boxlite + +/* +#include "bridge.h" +#include +*/ +import "C" + +import ( + "context" + "runtime/cgo" + "strings" + "unsafe" +) + +func validateArchiveArgument(value, parameter string, allowEmpty bool) error { + if value == "" && !allowEmpty { + return &Error{ + Code: ErrInvalidArgument, + Message: parameter + " must not be empty", + } + } + if strings.IndexByte(value, 0) >= 0 { + return &Error{ + Code: ErrInvalidArgument, + Message: parameter + " must not contain NUL bytes", + } + } + return nil +} + +func archiveNameCString(name string) (*C.char, func()) { + if name == "" { + return nil, func() {} + } + cName := toCString(name) + return cName, func() { + C.free(unsafe.Pointer(cName)) + } +} + +func abandonOwnedResult[T any](result <-chan handleResult[T], handle cgo.Handle, dispose func(T)) { + if claimHandleForDispatch(handle) { + handle.Delete() + return + } + + go func() { + completed := <-result + dispose(completed.value) + }() +} + +// Export exports the box to a portable .boxlite archive. +// +// If dest is a directory, Export creates the archive inside it and returns the +// actual archive file path selected by the backend. +// +// Export never deletes the archive. The caller owns the returned file and is +// responsible for retaining, moving, or deleting it. +func (b *Box) Export(ctx context.Context, dest string) (string, error) { + if err := validateArchiveArgument(dest, "export destination", false); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + + b.runtime.ensureDrainRunning() + + cDest := toCString(dest) + defer C.free(unsafe.Pointer(cDest)) + + result := make(chan handleResult[*C.char], 1) + handle := registerHandleForDispatch(cgo.NewHandle(result)) + + if err := ctx.Err(); err != nil { + deleteHandleForDispatch(handle) + return "", err + } + + var cError C.CBoxliteError + code := C.boxlite_box_export( + b.handle, + cDest, + C.cbBoxExport(), + handleToPtr(handle), + &cError, + ) + if code != C.Ok { + deleteHandleForDispatch(handle) + return "", freeError(&cError) + } + + dispose := func(path *C.char) { + if path != nil { + freeBoxliteString(path) + } + } + select { + case completed := <-result: + defer dispose(completed.value) + if completed.err != nil { + return "", completed.err + } + return cString(completed.value), nil + case <-ctx.Done(): + abandonOwnedResult(result, handle, dispose) + return "", ctx.Err() + case <-b.runtime.closing: + abandonOwnedResult(result, handle, dispose) + return "", ErrRuntimeClosed + } +} + +// Import imports a .boxlite archive and returns a new, stopped box. +// +// Local runtimes treat caller-provided archives as trusted and preserve their +// complete configuration. REST runtimes upload the archive, and the server +// applies its untrusted-upload policy. +// +// An empty name leaves the imported box unnamed, matching Create without +// WithName. Import assigns a new box ID. +// +// Import never consumes or deletes archivePath. The caller owns the archive +// and is responsible for retaining, moving, or deleting it. +func (r *Runtime) Import(ctx context.Context, archivePath, name string) (*Box, error) { + if err := validateArchiveArgument(archivePath, "archive path", false); err != nil { + return nil, err + } + if err := validateArchiveArgument(name, "import name", true); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + r.ensureDrainRunning() + + cArchivePath := toCString(archivePath) + defer C.free(unsafe.Pointer(cArchivePath)) + cName, freeName := archiveNameCString(name) + defer freeName() + + result := make(chan handleResult[*C.CBoxHandle], 1) + handle := registerHandleForDispatch(cgo.NewHandle(result)) + + if err := ctx.Err(); err != nil { + deleteHandleForDispatch(handle) + return nil, err + } + + var cError C.CBoxliteError + code := C.boxlite_runtime_import( + r.handle, + cArchivePath, + cName, + C.cbRuntimeImport(), + handleToPtr(handle), + &cError, + ) + if code != C.Ok { + deleteHandleForDispatch(handle) + return nil, freeError(&cError) + } + + dispose := func(handle *C.CBoxHandle) { + if handle != nil { + C.boxlite_box_free(handle) + } + } + select { + case completed := <-result: + if completed.err != nil { + dispose(completed.value) + return nil, completed.err + } + return newBoxFromHandle(r, completed.value, name), nil + case <-ctx.Done(): + abandonOwnedResult(result, handle, dispose) + return nil, ctx.Err() + case <-r.closing: + abandonOwnedResult(result, handle, dispose) + return nil, ErrRuntimeClosed + } +} diff --git a/sdks/go/archive_test.go b/sdks/go/archive_test.go new file mode 100644 index 000000000..bbdc5a007 --- /dev/null +++ b/sdks/go/archive_test.go @@ -0,0 +1,667 @@ +package boxlite + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime/cgo" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +var ( + _ interface { + Export(context.Context, string) (string, error) + } = (*Box)(nil) + + _ interface { + Import(context.Context, string, string) (*Box, error) + } = (*Runtime)(nil) +) + +type archiveImportResult struct { + box *Box + err error +} + +type archiveExportResult struct { + path string + err error +} + +// cancelBetweenErrChecksContext pauses after caching the first Err result. +// Canceling the wrapped context while paused makes the API's second Err check +// deterministically observe cancellation before native submission. +type cancelBetweenErrChecksContext struct { + context.Context + once sync.Once + sampled chan struct{} + resume <-chan struct{} +} + +func (c *cancelBetweenErrChecksContext) Err() error { + err := c.Context.Err() + c.once.Do(func() { + close(c.sampled) + <-c.resume + }) + return err +} + +func TestAbandonOwnedResultCancelClaimsBeforeCallback(t *testing.T) { + result := make(chan handleResult[*int], 1) + handle := registerHandleForDispatch(cgo.NewHandle(result)) + payload := new(int) + *payload = 42 + + var disposeCount atomic.Int32 + disposed := make(chan *int, 1) + dispose := func(value *int) { + disposeCount.Add(1) + disposed <- value + } + + abandonOwnedResult(result, handle, dispose) + if claimOrFreePayload(handle, &payload, func(value **int) { + dispose(*value) + }) { + t.Fatal("late callback claimed an abandoned handle") + } + + select { + case got := <-disposed: + if got != payload { + t.Fatal("disposed a different payload") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for late callback payload disposal") + } + if got := disposeCount.Load(); got != 1 { + t.Fatalf("dispose count = %d, want 1", got) + } +} + +func TestAbandonOwnedResultDrainsCallbackOwnedPayload(t *testing.T) { + result := make(chan handleResult[*int], 1) + handle := registerHandleForDispatch(cgo.NewHandle(result)) + payload := new(int) + *payload = 42 + + if !claimOrFreePayload(handle, &payload, func(_ **int) { + t.Fatal("callback unexpectedly lost handle ownership") + }) { + t.Fatal("callback did not claim handle ownership") + } + handleNeedsDelete := true + defer func() { + if handleNeedsDelete { + handle.Delete() + } + }() + + var disposeCount atomic.Int32 + disposed := make(chan *int, 1) + abandonOwnedResult(result, handle, func(value *int) { + disposeCount.Add(1) + disposed <- value + }) + result <- handleResult[*int]{value: payload, err: errors.New("native error")} + handle.Delete() + handleNeedsDelete = false + + select { + case got := <-disposed: + if got != payload { + t.Fatal("disposed a different payload") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for callback-owned payload disposal") + } + if got := disposeCount.Load(); got != 1 { + t.Fatalf("dispose count = %d, want 1", got) + } +} + +func TestArchiveArgumentValidation(t *testing.T) { + runtime := &Runtime{} + box := &Box{runtime: runtime} + tests := []struct { + name string + parameter string + call func() error + }{ + { + name: "empty export destination", + parameter: "export destination", + call: func() error { + _, err := box.Export(context.Background(), "") + return err + }, + }, + { + name: "NUL export destination", + parameter: "export destination", + call: func() error { + _, err := box.Export(context.Background(), "archive\x00ignored") + return err + }, + }, + { + name: "empty archive path", + parameter: "archive path", + call: func() error { + _, err := runtime.Import(context.Background(), "", "") + return err + }, + }, + { + name: "NUL archive path", + parameter: "archive path", + call: func() error { + _, err := runtime.Import(context.Background(), "archive.boxlite\x00ignored", "") + return err + }, + }, + { + name: "NUL import name", + parameter: "import name", + call: func() error { + _, err := runtime.Import(context.Background(), "archive.boxlite", "restored\x00ignored") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if err == nil { + t.Fatal("expected validation error") + } + var boxliteErr *Error + if !errors.As(err, &boxliteErr) { + t.Fatalf("expected *Error, got %T: %v", err, err) + } + if boxliteErr.Code != ErrInvalidArgument { + t.Fatalf("error code = %d, want ErrInvalidArgument", boxliteErr.Code) + } + if !strings.Contains(err.Error(), tt.parameter) { + t.Fatalf("error %q does not identify %q", err, tt.parameter) + } + }) + } +} + +func TestArchiveEmptyNameUsesNull(t *testing.T) { + cName, freeName := archiveNameCString("") + defer freeName() + if cName != nil { + t.Fatal("empty import name must cross the C boundary as NULL") + } + + cName, freeName = archiveNameCString("restored") + defer freeName() + if cName == nil { + t.Fatal("non-empty import name unexpectedly mapped to NULL") + } + if got := cString(cName); got != "restored" { + t.Fatalf("converted import name = %q, want %q", got, "restored") + } +} + +func TestArchiveCanceledBeforeSubmission(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + runtime := &Runtime{} + box := &Box{runtime: runtime} + + if _, err := box.Export(ctx, "archive.boxlite"); !errors.Is(err, context.Canceled) { + t.Fatalf("Export error = %v, want context.Canceled", err) + } + if _, err := runtime.Import(ctx, "archive.boxlite", ""); !errors.Is(err, context.Canceled) { + t.Fatalf("Import error = %v, want context.Canceled", err) + } +} + +func TestArchiveCancellationDuringPreparationPreventsNativeSubmission(t *testing.T) { + t.Run("Import", func(t *testing.T) { + started := make(chan struct{}) + releaseNative := make(chan struct{}) + close(releaseNative) + server := newArchiveImportServer(t, started, releaseNative) + defer server.Close() + + runtime, err := NewRest(BoxliteRestOptions{URL: server.URL}) + if err != nil { + t.Fatalf("NewRest: %v", err) + } + defer runtime.Close() + + archivePath := filepath.Join(t.TempDir(), "input.boxlite") + if err := os.WriteFile(archivePath, []byte("archive"), 0o600); err != nil { + t.Fatalf("write archive fixture: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + resume := make(chan struct{}) + var resumeOnce sync.Once + t.Cleanup(func() { resumeOnce.Do(func() { close(resume) }) }) + controlled := &cancelBetweenErrChecksContext{ + Context: ctx, + sampled: make(chan struct{}), + resume: resume, + } + result := make(chan archiveImportResult, 1) + go func() { + box, err := runtime.Import(controlled, archivePath, "") + result <- archiveImportResult{box: box, err: err} + }() + + waitForArchiveSignal(t, controlled.sampled, "initial Import context check") + cancel() + resumeOnce.Do(func() { close(resume) }) + + got := waitForArchiveImport(t, result) + if got.box != nil { + defer got.box.Close() + } + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("Import error = %v, want context.Canceled", got.err) + } + assertArchiveRequestNotSubmitted(t, started, "Import") + }) + + t.Run("Export", func(t *testing.T) { + started := make(chan struct{}) + releaseNative := make(chan struct{}) + close(releaseNative) + server := newArchiveExportServer(t, started, releaseNative) + defer server.Close() + + runtime, err := NewRest(BoxliteRestOptions{URL: server.URL}) + if err != nil { + t.Fatalf("NewRest: %v", err) + } + defer runtime.Close() + box, err := runtime.Get(context.Background(), "source-box") + if err != nil { + t.Fatalf("Get source box: %v", err) + } + defer box.Close() + ctx, cancel := context.WithCancel(context.Background()) + resume := make(chan struct{}) + var resumeOnce sync.Once + t.Cleanup(func() { resumeOnce.Do(func() { close(resume) }) }) + controlled := &cancelBetweenErrChecksContext{ + Context: ctx, + sampled: make(chan struct{}), + resume: resume, + } + result := make(chan archiveExportResult, 1) + archiveDir := t.TempDir() + go func() { + path, err := box.Export(controlled, archiveDir) + result <- archiveExportResult{path: path, err: err} + }() + + waitForArchiveSignal(t, controlled.sampled, "initial Export context check") + cancel() + resumeOnce.Do(func() { close(resume) }) + + got := waitForArchiveExport(t, result) + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("Export error = %v, want context.Canceled", got.err) + } + assertArchiveRequestNotSubmitted(t, started, "Export") + }) +} + +func TestArchiveImportReturnsCancellationAfterSubmission(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + + server := newArchiveImportServer(t, started, release) + t.Cleanup(server.Close) + + runtime, err := NewRest(BoxliteRestOptions{URL: server.URL}) + if err != nil { + t.Fatalf("NewRest: %v", err) + } + t.Cleanup(func() { + if err := runtime.Close(); err != nil { + t.Errorf("Close runtime: %v", err) + } + }) + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + }) + + archivePath := filepath.Join(t.TempDir(), "input.boxlite") + if err := os.WriteFile(archivePath, []byte("archive"), 0o600); err != nil { + t.Fatalf("write archive fixture: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan archiveImportResult, 1) + go func() { + box, err := runtime.Import(ctx, archivePath, "") + result <- archiveImportResult{box: box, err: err} + }() + + waitForArchiveSignal(t, started, "native import submission") + cancel() + + got := waitForArchiveImport(t, result) + if got.box != nil { + _ = got.box.Close() + t.Fatal("Import returned a box after cancellation") + } + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("Import error = %v, want context.Canceled", got.err) + } +} + +func TestArchiveExportReturnsCancellationAfterSubmission(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + + server := newArchiveExportServer(t, started, release) + t.Cleanup(server.Close) + + runtime, err := NewRest(BoxliteRestOptions{URL: server.URL}) + if err != nil { + t.Fatalf("NewRest: %v", err) + } + t.Cleanup(func() { + if err := runtime.Close(); err != nil { + t.Errorf("Close runtime: %v", err) + } + }) + + box, err := runtime.Get(context.Background(), "source-box") + if err != nil { + t.Fatalf("Get source box: %v", err) + } + t.Cleanup(func() { + if err := box.Close(); err != nil { + t.Errorf("Close box: %v", err) + } + }) + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + }) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan archiveExportResult, 1) + archiveDir := t.TempDir() + go func() { + path, err := box.Export(ctx, archiveDir) + result <- archiveExportResult{path: path, err: err} + }() + + waitForArchiveSignal(t, started, "native export submission") + cancel() + + got := waitForArchiveExport(t, result) + if got.path != "" { + t.Fatalf("Export path = %q after cancellation, want empty", got.path) + } + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("Export error = %v, want context.Canceled", got.err) + } +} + +func TestArchiveCloseDoesNotWaitForAcceptedImport(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + + server := newArchiveImportServer(t, started, release) + t.Cleanup(server.Close) + + runtime, err := NewRest(BoxliteRestOptions{URL: server.URL}) + if err != nil { + t.Fatalf("NewRest: %v", err) + } + runtime.ensureDrainRunning() + + closeDone := make(chan struct{}) + closeResult := make(chan error, 1) + closeStarted := false + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + if !closeStarted { + _ = runtime.Close() + return + } + select { + case <-closeDone: + case <-time.After(5 * time.Second): + t.Error("Runtime.Close did not finish during cleanup") + } + }) + + archivePath := filepath.Join(t.TempDir(), "input.boxlite") + if err := os.WriteFile(archivePath, []byte("archive"), 0o600); err != nil { + t.Fatalf("write archive fixture: %v", err) + } + + importResult := make(chan archiveImportResult, 1) + go func() { + box, err := runtime.Import(context.Background(), archivePath, "") + importResult <- archiveImportResult{box: box, err: err} + }() + waitForArchiveSignal(t, started, "native import submission") + + closeStarted = true + go func() { + closeResult <- runtime.Close() + close(closeDone) + }() + + select { + case err := <-closeResult: + if err != nil { + t.Fatalf("Runtime.Close: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Runtime.Close waited for accepted Import") + } + + imported := waitForArchiveImport(t, importResult) + if imported.box != nil { + _ = imported.box.Close() + t.Fatal("Import returned a box after Runtime.Close") + } + if !errors.Is(imported.err, ErrRuntimeClosed) { + t.Fatalf("Import error = %v, want ErrRuntimeClosed", imported.err) + } +} + +func TestArchiveShutdownDoesNotWaitForAcceptedImport(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + + server := newArchiveImportServer(t, started, release) + t.Cleanup(server.Close) + + runtime, err := NewRest(BoxliteRestOptions{URL: server.URL}) + if err != nil { + t.Fatalf("NewRest: %v", err) + } + t.Cleanup(func() { + if err := runtime.Close(); err != nil { + t.Errorf("Close runtime: %v", err) + } + }) + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + }) + + archivePath := filepath.Join(t.TempDir(), "input.boxlite") + if err := os.WriteFile(archivePath, []byte("archive"), 0o600); err != nil { + t.Fatalf("write archive fixture: %v", err) + } + + importResult := make(chan archiveImportResult, 1) + go func() { + box, err := runtime.Import(context.Background(), archivePath, "") + importResult <- archiveImportResult{box: box, err: err} + }() + waitForArchiveSignal(t, started, "native import submission") + + shutdownResult := make(chan error, 1) + go func() { + shutdownResult <- runtime.Shutdown(context.Background(), 0) + }() + + select { + case err := <-shutdownResult: + if err != nil { + t.Fatalf("Runtime.Shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Runtime.Shutdown waited for accepted Import") + } + + releaseOnce.Do(func() { close(release) }) + imported := waitForArchiveImport(t, importResult) + if imported.err != nil { + t.Fatalf("Import after REST Shutdown: %v", imported.err) + } + if imported.box == nil { + t.Fatal("Import after REST Shutdown returned nil box") + } + t.Cleanup(func() { + if err := imported.box.Close(); err != nil { + t.Errorf("Close imported box: %v", err) + } + }) + + source, err := runtime.Get(context.Background(), "source-box") + if err != nil { + t.Fatalf("Get source box after REST Shutdown: %v", err) + } + t.Cleanup(func() { + if err := source.Close(); err != nil { + t.Errorf("Close source box: %v", err) + } + }) + exportedPath, err := source.Export(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("Export after REST Shutdown: %v", err) + } + if _, err := os.Stat(exportedPath); err != nil { + t.Fatalf("stat archive exported after REST Shutdown: %v", err) + } +} + +func newArchiveImportServer(t *testing.T, started chan<- struct{}, release <-chan struct{}) *httptest.Server { + t.Helper() + var startOnce sync.Once + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/config": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"capabilities":{"import_enabled":true,"export_enabled":true}}`) + case r.Method == http.MethodGet && r.URL.Path == "/v1/boxes/source-box": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "box_id":"source-box","name":"source-name","status":"stopped", + "created_at":"2026-08-07T00:00:00Z","updated_at":"2026-08-07T00:00:00Z", + "pid":null,"image":"source-image","cpus":1,"memory_mib":256 + }`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/boxes/import": + startOnce.Do(func() { close(started) }) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "box_id":"imported-box-1","name":null,"status":"stopped", + "created_at":"2026-08-07T00:00:00Z","updated_at":"2026-08-07T00:00:00Z", + "pid":null,"image":"archive-image","cpus":1,"memory_mib":256 + }`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/boxes/source-box/export": + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.WriteString(w, "archive-bytes") + default: + http.NotFound(w, r) + } + })) +} + +func newArchiveExportServer(t *testing.T, started chan<- struct{}, release <-chan struct{}) *httptest.Server { + t.Helper() + var startOnce sync.Once + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/boxes/source-box": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "box_id":"source-box","name":"source-name","status":"stopped", + "created_at":"2026-08-07T00:00:00Z","updated_at":"2026-08-07T00:00:00Z", + "pid":null,"image":"source-image","cpus":1,"memory_mib":256 + }`) + case r.Method == http.MethodGet && r.URL.Path == "/v1/config": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"capabilities":{"export_enabled":true}}`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/boxes/source-box/export": + startOnce.Do(func() { close(started) }) + <-release + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.WriteString(w, "archive-bytes") + default: + http.NotFound(w, r) + } + })) +} + +func waitForArchiveSignal(t *testing.T, signal <-chan struct{}, operation string) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %s", operation) + } +} + +func waitForArchiveImport(t *testing.T, result <-chan archiveImportResult) archiveImportResult { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for Import result") + return archiveImportResult{} + } +} + +func waitForArchiveExport(t *testing.T, result <-chan archiveExportResult) archiveExportResult { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for Export result") + return archiveExportResult{} + } +} + +func assertArchiveRequestNotSubmitted(t *testing.T, started <-chan struct{}, operation string) { + t.Helper() + select { + case <-started: + t.Fatalf("%s submitted native work after its context was canceled", operation) + default: + } +} diff --git a/sdks/go/bridge.c b/sdks/go/bridge.c index a8b27ce8c..4cd6a4fc2 100644 --- a/sdks/go/bridge.c +++ b/sdks/go/bridge.c @@ -3,7 +3,7 @@ // strongly-typed function-pointer typedef so cgo wrappers in any Go file in // this package can pass `C.cbXxx()` directly into the SDK. -#include "boxlite.h" +#include "bridge.h" // Forward declarations for the //export'd Go callbacks. The signatures must // match the cbindgen-generated typedefs exactly; the casts in the accessor @@ -13,6 +13,8 @@ extern void goBoxliteOnStderr(uint8_t const *data, size_t len, void *ud); extern void goBoxliteOnExit(int exit_code, void *ud); extern void goBoxliteOnCreateBox(CBoxHandle *box, CBoxliteError *err, void *ud); +extern void goBoxliteOnBoxExport(char *archive_path, CBoxliteError *err, void *ud); +extern void goBoxliteOnRuntimeImport(CBoxHandle *box, CBoxliteError *err, void *ud); extern void goBoxliteOnGetOrCreateBox(CBoxHandle *box, bool created, CBoxliteError *err, void *ud); extern void goBoxliteOnGetBox(CBoxHandle *box, CBoxliteError *err, void *ud); extern void goBoxliteOnStartBox(CBoxliteError *err, void *ud); @@ -45,6 +47,12 @@ CBoxStderrCb cbStderr(void) { return (CBoxStderrCb)goBoxliteOnStderr; } CBoxExitCb cbExit(void) { return (CBoxExitCb)goBoxliteOnExit; } CBoxCreateBoxCb cbCreateBox(void) { return (CBoxCreateBoxCb)goBoxliteOnCreateBox; } +CBoxExportCb cbBoxExport(void) { + return (CBoxExportCb)goBoxliteOnBoxExport; +} +CRuntimeImportCb cbRuntimeImport(void) { + return (CRuntimeImportCb)goBoxliteOnRuntimeImport; +} CBoxGetOrCreateBoxCb cbGetOrCreateBox(void) { return (CBoxGetOrCreateBoxCb)goBoxliteOnGetOrCreateBox; } CBoxGetBoxCb cbGetBox(void) { return (CBoxGetBoxCb)goBoxliteOnGetBox; } CBoxStartBoxCb cbStartBox(void) { return (CBoxStartBoxCb)goBoxliteOnStartBox; } diff --git a/sdks/go/bridge.h b/sdks/go/bridge.h index 0cee21cc8..74fda331a 100644 --- a/sdks/go/bridge.h +++ b/sdks/go/bridge.h @@ -12,6 +12,8 @@ extern CBoxStderrCb cbStderr(void); extern CBoxExitCb cbExit(void); extern CBoxCreateBoxCb cbCreateBox(void); +extern CBoxExportCb cbBoxExport(void); +extern CRuntimeImportCb cbRuntimeImport(void); extern CBoxGetOrCreateBoxCb cbGetOrCreateBox(void); extern CBoxGetBoxCb cbGetBox(void); extern CBoxStartBoxCb cbStartBox(void); diff --git a/sdks/go/bridge_callback.go b/sdks/go/bridge_callback.go index 953adc2c6..b26d00ced 100644 --- a/sdks/go/bridge_callback.go +++ b/sdks/go/bridge_callback.go @@ -86,6 +86,13 @@ func freeBoxHandlePayload(b **C.CBoxHandle) { C.boxlite_box_free(*b) } +func freeBoxliteStringPayload(value **C.char) { + if value == nil || *value == nil { + return + } + freeBoxliteString(*value) +} + //export goBoxliteOnCreateBox func goBoxliteOnCreateBox(box *C.CBoxHandle, errPtr *C.CBoxliteError, userData unsafe.Pointer) { h := ptrToHandle(userData) @@ -103,6 +110,46 @@ func goBoxliteOnCreateBox(box *C.CBoxHandle, errPtr *C.CBoxliteError, userData u ch <- handleResult[*C.CBoxHandle]{value: box, err: errorFromCError(errPtr)} } +//export goBoxliteOnBoxExport +func goBoxliteOnBoxExport(archivePath *C.char, errPtr *C.CBoxliteError, userData unsafe.Pointer) { + h := ptrToHandle(userData) + if h == 0 { + freeBoxliteStringPayload(&archivePath) + return + } + if !claimOrFreePayload(h, &archivePath, freeBoxliteStringPayload) { + return + } + defer h.Delete() + + ch, ok := h.Value().(chan handleResult[*C.char]) + if !ok { + freeBoxliteStringPayload(&archivePath) + return + } + ch <- handleResult[*C.char]{value: archivePath, err: errorFromCError(errPtr)} +} + +//export goBoxliteOnRuntimeImport +func goBoxliteOnRuntimeImport(box *C.CBoxHandle, errPtr *C.CBoxliteError, userData unsafe.Pointer) { + h := ptrToHandle(userData) + if h == 0 { + freeBoxHandlePayload(&box) + return + } + if !claimOrFreePayload(h, &box, freeBoxHandlePayload) { + return + } + defer h.Delete() + + ch, ok := h.Value().(chan handleResult[*C.CBoxHandle]) + if !ok { + freeBoxHandlePayload(&box) + return + } + ch <- handleResult[*C.CBoxHandle]{value: box, err: errorFromCError(errPtr)} +} + // boxAndCreated carries a get-or-create result across the dispatch channel: the // box handle plus whether it was newly created (true) or an adopted existing // box (false). Wrapping both in one value lets GetOrCreate reuse the generic diff --git a/sdks/go/integration/archive/archive_test.go b/sdks/go/integration/archive/archive_test.go new file mode 100644 index 000000000..f467bcb42 --- /dev/null +++ b/sdks/go/integration/archive/archive_test.go @@ -0,0 +1,305 @@ +//go:build boxlite_dev && boxlite_integration + +package archive_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + boxlite "github.com/boxlite-ai/boxlite/sdks/go" +) + +const ( + archiveMarkerPath = "/root/boxlite-go-archive-marker" + archiveMarker = "boxlite-go-archive-marker" +) + +type archiveRoundTripFixture struct { + t *testing.T + ctx context.Context + runtime *boxlite.Runtime + source *boxlite.Box + restored *boxlite.Box + sourceID string + restoredID string + archivePath string + runtimeIsShutdown bool +} + +// TestIntegrationArchiveRoundTrip covers the public Go SDK path against a real +// VM. The test removes the caller-owned archive before starting the imported +// box, proving that the restored box is self-contained. +func TestIntegrationArchiveRoundTrip(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + defer cancel() + + fixture := newArchiveRoundTripFixture(t, ctx) + fixture.createAndSeedSource() + fixture.exportAndValidateArchive() + fixture.importAndValidateRestored() + fixture.removeArchiveAndValidateRestored() + fixture.validatePostShutdownBehavior() +} + +func newArchiveRoundTripFixture(t *testing.T, ctx context.Context) *archiveRoundTripFixture { + t.Helper() + + runtime, err := boxlite.NewRuntime(boxlite.WithHomeDir(t.TempDir())) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + fixture := &archiveRoundTripFixture{ + t: t, + ctx: ctx, + runtime: runtime, + } + t.Cleanup(fixture.cleanup) + return fixture +} + +func (f *archiveRoundTripFixture) cleanup() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + + if f.restoredID != "" && !f.runtimeIsShutdown { + if err := f.runtime.ForceRemove(cleanupCtx, f.restoredID); err != nil { + f.t.Errorf("ForceRemove restored box: %v", err) + } + } + if f.sourceID != "" && !f.runtimeIsShutdown { + if err := f.runtime.ForceRemove(cleanupCtx, f.sourceID); err != nil { + f.t.Errorf("ForceRemove source box: %v", err) + } + } + if f.restored != nil { + if err := f.restored.Close(); err != nil { + f.t.Errorf("Close restored box handle: %v", err) + } + } + if f.source != nil { + if err := f.source.Close(); err != nil { + f.t.Errorf("Close source box handle: %v", err) + } + } + if err := f.runtime.Close(); err != nil { + f.t.Errorf("Close runtime: %v", err) + } +} + +func (f *archiveRoundTripFixture) createAndSeedSource() { + f.t.Helper() + + source, err := f.runtime.Create( + f.ctx, + "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0", + boxlite.WithName("go-archive-source"), + boxlite.WithCPUs(1), + boxlite.WithMemory(512), + boxlite.WithDiskSize(2), + boxlite.WithAutoRemove(false), + ) + f.source = source + if err != nil { + f.t.Fatalf("Create source box: %v", err) + } + f.sourceID = f.source.ID() + if f.sourceID == "" { + f.t.Fatal("source box ID is empty") + } + if err := f.source.Start(f.ctx); err != nil { + f.t.Fatalf("Start source box: %v", err) + } + + assertExec( + f.t, + f.source, + f.ctx, + "write durable source marker", + archiveMarker, + "/bin/sh", + "-c", + "sudo /bin/sh -c \"printf '"+archiveMarker+"' > "+archiveMarkerPath+" && sync\" && sudo /bin/cat "+archiveMarkerPath, + ) +} + +func (f *archiveRoundTripFixture) exportAndValidateArchive() { + f.t.Helper() + + archiveDir := f.t.TempDir() + archivePath, err := f.source.Export(f.ctx, archiveDir) + if err != nil { + f.t.Fatalf("Export source box: %v", err) + } + f.archivePath = filepath.Clean(archivePath) + if filepath.Dir(f.archivePath) != filepath.Clean(archiveDir) { + f.t.Fatalf("Export path %q is outside destination %q", f.archivePath, archiveDir) + } + archiveInfo, err := os.Stat(f.archivePath) + if err != nil { + f.t.Fatalf("stat exported archive: %v", err) + } + if !archiveInfo.Mode().IsRegular() { + f.t.Fatalf("exported archive %q is not a regular file", f.archivePath) + } + if archiveInfo.Size() == 0 { + f.t.Fatalf("exported archive %q is empty", f.archivePath) + } + if filepath.Ext(f.archivePath) != ".boxlite" { + f.t.Fatalf("exported archive %q does not use .boxlite extension", f.archivePath) + } + + assertExec( + f.t, + f.source, + f.ctx, + "read source marker after Export", + archiveMarker, + "/bin/sh", + "-c", + "sudo /bin/cat "+archiveMarkerPath, + ) + if err := f.source.Stop(f.ctx); err != nil { + f.t.Fatalf("Stop source box: %v", err) + } +} + +func (f *archiveRoundTripFixture) importAndValidateRestored() { + f.t.Helper() + + restored, err := f.runtime.Import(f.ctx, f.archivePath, "") + f.restored = restored + if err != nil { + f.t.Fatalf("Import archive: %v", err) + } + f.restoredID = f.restored.ID() + if f.restoredID == "" { + f.t.Fatal("restored box ID is empty") + } + if f.restoredID == f.sourceID { + f.t.Fatalf("Import reused source box ID %q", f.sourceID) + } + if f.restored.Name() != "" { + f.t.Fatalf("restored handle name = %q, want unnamed", f.restored.Name()) + } + + restoredInfo, err := f.restored.Info(f.ctx) + if err != nil { + f.t.Fatalf("Info restored box: %v", err) + } + if restoredInfo.Name != "" { + f.t.Fatalf("restored persisted name = %q, want unnamed", restoredInfo.Name) + } + if restoredInfo.State != boxlite.StateStopped || restoredInfo.Running { + f.t.Fatalf( + "restored state = %q running=%v, want stopped", + restoredInfo.State, + restoredInfo.Running, + ) + } + + if _, err := os.Stat(f.archivePath); err != nil { + f.t.Fatalf("Import removed caller-owned archive: %v", err) + } +} + +func (f *archiveRoundTripFixture) removeArchiveAndValidateRestored() { + f.t.Helper() + + if err := os.Remove(f.archivePath); err != nil { + f.t.Fatalf("caller remove archive: %v", err) + } + if _, err := os.Stat(f.archivePath); !os.IsNotExist(err) { + f.t.Fatalf("archive still exists after caller removal: %v", err) + } + + if err := f.restored.Start(f.ctx); err != nil { + f.t.Fatalf("Start restored box after archive deletion: %v", err) + } + assertExec( + f.t, + f.restored, + f.ctx, + "read restored durable marker", + archiveMarker, + "/bin/sh", + "-c", + "sudo /bin/cat "+archiveMarkerPath, + ) + + const writableMarker = "restored-box-remains-writable" + assertExec( + f.t, + f.restored, + f.ctx, + "write and read restored box", + writableMarker, + "/bin/sh", + "-c", + "sudo /bin/sh -c \"printf '"+writableMarker+"' > /root/restored-writable\" && sudo /bin/cat /root/restored-writable", + ) +} + +func (f *archiveRoundTripFixture) validatePostShutdownBehavior() { + f.t.Helper() + + if err := f.runtime.Shutdown(f.ctx, 2*time.Minute); err != nil { + f.t.Fatalf("Shutdown runtime: %v", err) + } + f.runtimeIsShutdown = true + + postShutdownArchive, err := f.restored.Export(f.ctx, f.t.TempDir()) + if err != nil { + f.t.Fatalf("Export after Shutdown: %v", err) + } + postShutdownInfo, err := os.Stat(postShutdownArchive) + if err != nil { + f.t.Fatalf("stat post-Shutdown archive: %v", err) + } + if !postShutdownInfo.Mode().IsRegular() || postShutdownInfo.Size() == 0 { + f.t.Fatalf("post-Shutdown archive is not a non-empty regular file: %+v", postShutdownInfo) + } + + unexpected, err := f.runtime.Import(f.ctx, postShutdownArchive, "") + if unexpected != nil { + _ = unexpected.Close() + f.t.Fatal("Import after Shutdown returned a box") + } + if !boxlite.IsStopped(err) { + f.t.Fatalf("Import after Shutdown error = %v, want stopped", err) + } +} + +func assertExec( + t *testing.T, + box *boxlite.Box, + ctx context.Context, + operation string, + wantStdout string, + command string, + args ...string, +) { + t.Helper() + + result, err := box.Exec(ctx, command, args...) + if err != nil { + t.Fatalf("%s: %v", operation, err) + } + if result == nil { + t.Fatalf("%s returned nil result", operation) + } + if result.ExitCode != 0 { + t.Fatalf( + "%s exit code = %d, want 0; stderr=%q", + operation, + result.ExitCode, + result.Stderr, + ) + } + if result.Stdout != wantStdout { + t.Fatalf("%s stdout = %q, want %q", operation, result.Stdout, wantStdout) + } +} diff --git a/sdks/go/runtime.go b/sdks/go/runtime.go index fd7b04f2b..63cc74af8 100644 --- a/sdks/go/runtime.go +++ b/sdks/go/runtime.go @@ -82,15 +82,9 @@ func NewRuntime(opts ...RuntimeOption) (*Runtime, error) { // Close releases the runtime. Implements io.Closer. // // Order matters: closing the `r.closing` channel first wakes every in-flight -// async caller (Create, Pull, Shutdown, etc.) that's parked on its result -// channel. They observe ErrRuntimeClosed and return promptly, releasing -// their cgo.Handles via abandonAsync. Only then do we stop the drain -// goroutine and free the C runtime handle โ€” at that point no Go caller is -// still depending on the drain to deliver a result. -// -// Without this ordering, an in-flight caller with a non-cancellable ctx -// would block forever after stopDrain killed the only goroutine that -// pumps events from C to its result channel. +// async caller that's parked on its result channel. They return +// ErrRuntimeClosed and release their per-call resources before the drain +// goroutine and native runtime are stopped. func (r *Runtime) Close() error { if r.handle == nil { return nil diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index ac7b8bc50..7d17e1d2a 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -211,7 +211,13 @@ impl RuntimeImpl { experimental_features: ExperimentalFeatures, ) -> BoxliteResult { let _sys = crate::system_check::SystemCheck::run()?; + Self::initialize(options, experimental_features) + } + fn initialize( + options: BoxliteOptions, + experimental_features: ExperimentalFeatures, + ) -> BoxliteResult { // Validate Early: Check preconditions before expensive work if !options.home_dir.is_absolute() { return Err(BoxliteError::Internal(format!( @@ -378,6 +384,11 @@ impl RuntimeImpl { archive: BoxArchive, name: Option, ) -> BoxliteResult { + if self.shutdown_token.is_cancelled() { + return Err(BoxliteError::Stopped( + "Cannot import box: runtime has been shut down".into(), + )); + } super::import::import_box(self, archive, name).await } @@ -1953,6 +1964,17 @@ mod tests { (runtime, temp_dir) } + fn create_test_runtime_without_host_preflight() -> (SharedRuntimeImpl, TempDir) { + let temp_dir = TempDir::new_in("/tmp").expect("Failed to create temp dir"); + let options = BoxliteOptions { + home_dir: temp_dir.path().to_path_buf(), + image_registries: vec![], + }; + let runtime = RuntimeImpl::initialize(options, ExperimentalFeatures::default()) + .expect("Failed to create test runtime"); + (runtime, temp_dir) + } + /// Create a minimal BoxConfig for testing. fn test_box_config(detach: bool) -> BoxConfig { BoxConfig { @@ -3057,6 +3079,27 @@ mod tests { // Post-shutdown operation rejection // ==================================================================== + #[tokio::test] + async fn test_import_after_shutdown_returns_stopped_before_archive_validation() { + let (runtime, dir) = create_test_runtime_without_host_preflight(); + runtime.shutdown_token.cancel(); + + let result = runtime + .import_box( + BoxArchive::new(dir.path().join("missing.boxlite")), + Some("imported".to_string()), + ) + .await; + + match result { + Err(BoxliteError::Stopped(message)) => { + assert!(message.contains("shut down"), "{message}"); + } + Err(other) => panic!("expected Stopped before archive validation, got: {other}"), + Ok(_) => panic!("import should fail after shutdown"), + } + } + #[tokio::test] async fn test_create_after_shutdown_returns_stopped() { let (runtime, _dir) = create_test_runtime();