From 75ade51d5ee74df354d1b70d6cd84e2de180f116 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Mon, 6 Jul 2026 09:59:38 -0700 Subject: [PATCH] Expose restore_interactive through the C ABI and Python SDK Signed-off-by: Cong Wang --- crates/sandlock-core/src/checkpoint/mod.rs | 11 + crates/sandlock-core/src/checkpoint/resume.rs | 33 +-- crates/sandlock-core/src/lib.rs | 2 +- crates/sandlock-core/src/sandbox.rs | 28 ++- crates/sandlock-core/src/sandbox/builder.rs | 1 + .../tests/integration/test_restore.rs | 7 +- crates/sandlock-ffi/include/sandlock.h | 56 +++++ crates/sandlock-ffi/src/lib.rs | 121 ++++++++++ crates/sandlock-ffi/tests/restore.rs | 223 ++++++++++++++++++ crates/sandlock-oci/src/supervisor.rs | 15 +- python/README.md | 38 ++- python/src/sandlock/__init__.py | 3 +- python/src/sandlock/_sdk.py | 105 ++++----- python/src/sandlock/sandbox.py | 61 +++++ python/tests/test_checkpoint.py | 189 ++++++++++++--- 15 files changed, 762 insertions(+), 131 deletions(-) create mode 100644 crates/sandlock-ffi/tests/restore.rs diff --git a/crates/sandlock-core/src/checkpoint/mod.rs b/crates/sandlock-core/src/checkpoint/mod.rs index 7141abeb..ea6c6950 100644 --- a/crates/sandlock-core/src/checkpoint/mod.rs +++ b/crates/sandlock-core/src/checkpoint/mod.rs @@ -71,3 +71,14 @@ pub struct FdInfo { pub flags: i32, pub offset: u64, } + +/// An fd that a restore could not transparently recreate (socket, pipe, +/// memfd, deleted or pseudo-filesystem path). The restored process runs +/// without it; such resources fall to the `app_state` hatch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedFd { + /// The fd number in the checkpointed process. + pub fd: i32, + /// The resource the fd pointed at (e.g. `pipe:[12345]`, `/memfd:x`). + pub path: String, +} diff --git a/crates/sandlock-core/src/checkpoint/resume.rs b/crates/sandlock-core/src/checkpoint/resume.rs index 28dbb62e..bf01e3fb 100644 --- a/crates/sandlock-core/src/checkpoint/resume.rs +++ b/crates/sandlock-core/src/checkpoint/resume.rs @@ -1,4 +1,4 @@ -use crate::checkpoint::{Checkpoint, FdInfo, MemoryMap, MemorySegment}; +use crate::checkpoint::{Checkpoint, FdInfo, MemoryMap, MemorySegment, SkippedFd}; /// One planned memory-restore action for a saved region. #[derive(Debug)] @@ -53,17 +53,17 @@ fn is_restorable_file_path(path: &str) -> bool { /// Split the saved fd table into transparently restorable regular files and a /// list of skipped non-regular fds (sockets, pipes, eventfd, ...). The skipped -/// list is logged by the caller; those resources fall to the app_state hatch. +/// list is surfaced to the caller; those resources fall to the app_state hatch. /// memfd, "(deleted)", and pseudo-filesystem (/proc/, /sys/, /dev/) paths start /// with '/' but are not transparently reopenable, so they are skipped. -pub(crate) fn build_fd_plan(fds: &[FdInfo]) -> (Vec, Vec) { +pub(crate) fn build_fd_plan(fds: &[FdInfo]) -> (Vec, Vec) { let mut restorable = Vec::new(); let mut skipped = Vec::new(); for f in fds { if is_restorable_file_path(&f.path) { restorable.push(f.clone()); } else { - skipped.push(f.path.clone()); + skipped.push(SkippedFd { fd: f.fd, path: f.path.clone() }); } } (restorable, skipped) @@ -83,8 +83,8 @@ fn prot_from_perms(perms: &str) -> libc::c_int { /// a valid executable rip). Drives the rebuild entirely via ptrace syscall /// injection through a trampoline placed in a hole of the CHECKPOINT's layout. /// Leaves the child stopped with the saved registers loaded; the caller resumes -/// it (PTRACE_CONT / detach). Returns the list of non-transparently-restored -/// resource names (skipped fds) for the caller to log. +/// it (PTRACE_CONT / detach). Returns the non-transparently-restored fds +/// (as [`SkippedFd`] fd + path entries) for the caller to surface. /// On `Err`, the child is left half-built and still ptrace-stopped; the caller /// MUST kill and reap it. /// Limitation: file-backed regions are restored `MAP_PRIVATE` from the on-disk @@ -98,7 +98,7 @@ fn prot_from_perms(perms: &str) -> libc::c_int { pub(crate) fn restore_into( pid: i32, cp: &Checkpoint, -) -> Result, crate::error::SandlockError> { +) -> Result, crate::error::SandlockError> { use crate::checkpoint::inject; use crate::error::{SandboxRuntimeError, SandlockError}; @@ -316,7 +316,7 @@ pub(crate) fn restore_into( pub(crate) fn restore_into( _pid: i32, _cp: &Checkpoint, -) -> Result, crate::error::SandlockError> { +) -> Result, crate::error::SandlockError> { Err(crate::error::SandlockError::Runtime( crate::error::SandboxRuntimeError::Child( "injection-based restore is only implemented on x86_64".into(), @@ -327,7 +327,7 @@ pub(crate) fn restore_into( #[cfg(test)] mod tests { use super::*; - use crate::checkpoint::{FdInfo, MemoryMap, MemorySegment}; + use crate::checkpoint::{FdInfo, MemoryMap, MemorySegment, SkippedFd}; #[test] fn fd_plan_keeps_regular_files_only() { @@ -339,7 +339,10 @@ mod tests { let (restorable, skipped) = build_fd_plan(&fds); assert_eq!(restorable.len(), 1); assert_eq!(restorable[0].fd, 3); - assert_eq!(skipped, vec!["socket:[12345]".to_string(), "pipe:[6789]".to_string()]); + assert_eq!(skipped, vec![ + SkippedFd { fd: 4, path: "socket:[12345]".into() }, + SkippedFd { fd: 5, path: "pipe:[6789]".into() }, + ]); } #[test] @@ -357,13 +360,13 @@ mod tests { assert_eq!(restorable[0].fd, 3); assert!(restorable.iter().all(|f| f.fd != 6 && f.fd != 7 && f.fd != 8 && f.fd != 9 && f.fd != 10), "deleted, memfd, and pseudo-filesystem fds must not appear in restorable"); - assert!(skipped.contains(&"/tmp/gone (deleted)".to_string())); - assert!(skipped.contains(&"/memfd:scratch (deleted)".to_string())); - assert!(skipped.contains(&"/proc/1234/maps".to_string()), + assert!(skipped.contains(&SkippedFd { fd: 6, path: "/tmp/gone (deleted)".into() })); + assert!(skipped.contains(&SkippedFd { fd: 7, path: "/memfd:scratch (deleted)".into() })); + assert!(skipped.contains(&SkippedFd { fd: 8, path: "/proc/1234/maps".into() }), "/proc/ paths must be skipped"); - assert!(skipped.contains(&"/dev/pts/3".to_string()), + assert!(skipped.contains(&SkippedFd { fd: 9, path: "/dev/pts/3".into() }), "/dev/ paths must be skipped"); - assert!(skipped.contains(&"/sys/kernel/x".to_string()), + assert!(skipped.contains(&SkippedFd { fd: 10, path: "/sys/kernel/x".into() }), "/sys/ paths must be skipped"); } diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index b4330768..dbc3233a 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -33,7 +33,7 @@ mod transparent_proxy; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; -pub use checkpoint::Checkpoint; +pub use checkpoint::{Checkpoint, SkippedFd}; pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus}; pub use sandbox::{Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode}; pub use result::{RunResult, ExitStatus}; diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index ca99f9f5..220100a1 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -438,6 +438,11 @@ pub struct Sandbox { // Heap-allocated runtime state; `None` when not started. #[serde(skip)] runtime: Option>, + + // Fds the last `restore_interactive` could not transparently recreate. + // Runtime state: not serialized, not cloned. + #[serde(skip)] + restore_skipped: Vec, } impl std::fmt::Debug for Sandbox { @@ -521,6 +526,8 @@ impl Clone for Sandbox { work_fn: self.work_fn.clone(), // Runtime is NOT cloned — the clone starts with no runtime. runtime: None, + // Restore diagnostics belong to the original's run, not the clone. + restore_skipped: Vec::new(), } } } @@ -818,8 +825,10 @@ impl Sandbox { /// full notify stack in place (the child parks before execve), then takes the /// parked child over with ptrace and injects the checkpoint image over it via /// `restore_into`, resuming it at the saved program counter. The process comes - /// up already sandboxed. Returns the list of non-transparently-restored fd - /// paths (skipped; the caller should log them). x86_64 restore engine only. + /// up already sandboxed and running; like [`Sandbox::popen`], the returned + /// [`Process`] is the handle to it (no `start()` step). Fds that could not be + /// transparently recreated are recorded on this `Sandbox`; query them with + /// [`Sandbox::restore_skipped`]. x86_64 restore engine only. /// /// Limitation: transparent restore currently works for vDSO-free programs. /// libc/glibc programs that call vDSO functions (e.g. `clock_gettime`) crash @@ -831,7 +840,7 @@ impl Sandbox { pub async fn restore_interactive( &mut self, cp: &crate::checkpoint::Checkpoint, - ) -> Result, crate::error::SandlockError> { + ) -> Result, crate::error::SandlockError> { use crate::error::SandboxRuntimeError; // The exe to launch is the checkpoint's original binary (within the @@ -856,7 +865,7 @@ impl Sandbox { // restore_into reads only process_state + fd_table, never policy. let cp = cp.clone(); let skipped = tokio::task::spawn_blocking( - move || -> Result, crate::error::SandlockError> { + move || -> Result, crate::error::SandlockError> { // PTRACE_SEIZE + PTRACE_INTERRUPT + waitpid to reach the ptrace-stop. crate::checkpoint::capture::ptrace_seize(pid).map_err(|e| { SandboxRuntimeError::Child(format!("restore ptrace seize {pid}: {e}")) @@ -884,7 +893,16 @@ impl Sandbox { .await .map_err(|e| SandboxRuntimeError::Child(format!("restore join error: {e}")))??; - Ok(skipped) + self.restore_skipped = skipped; + Ok(Process { sandbox: self }) + } + + /// Fds that the last [`Sandbox::restore_interactive`] on this sandbox could + /// not transparently recreate (sockets, pipes, memfds, pseudo-filesystem + /// paths); the restored process runs without them. Empty if this sandbox + /// never restored a checkpoint or every fd was restored. + pub fn restore_skipped(&self) -> &[crate::checkpoint::SkippedFd] { + &self.restore_skipped } /// Wait for the child to finish `execve`. Detected by `/proc//exe` diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 0cdd3b70..432572a3 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -782,6 +782,7 @@ impl SandboxBuilder { init_fn: self.init_fn, work_fn: self.work_fn, runtime: None, + restore_skipped: Vec::new(), }) } diff --git a/crates/sandlock-core/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index 3f5b23a4..f4091fdb 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -112,10 +112,11 @@ async fn test_restore_real_program_resumes() { // Sentinel: prove the *restored* process (not a leftover original) is writing. std::fs::write(&counter, b"0\n").unwrap(); - // Restore into a fresh, fully-sandboxed process. + // Restore into a fresh, fully-sandboxed process. The returned Process is + // the handle; the sandbox owns the child, so dropping it here is fine. let mut sb2 = policy.clone().with_name("restore-dst"); - let skipped = sb2.restore_interactive(&cp).await.unwrap(); - eprintln!("restore skipped fds: {skipped:?}"); + let _ = sb2.restore_interactive(&cp).await.unwrap(); + eprintln!("restore skipped fds: {:?}", sb2.restore_skipped()); // Poll up to ~3s for the restored process to resume and advance the counter // past the checkpointed baseline. diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 33b0ee90..caee7c88 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -1231,6 +1231,62 @@ const uint8_t *sandlock_checkpoint_app_state(const sandlock_checkpoint_t *cp, ui */ void sandlock_checkpoint_free(sandlock_checkpoint_t *cp); +/** + * Restore a checkpoint into a fresh, fully-sandboxed process. + * + * Builds a new sandbox from `policy` (with the optional `name`, as in + * `sandlock_create`) and injects the checkpoint image over a parked child, + * which resumes at the saved program counter under the full confinement. + * Returns a live handle: the process is already running, so do NOT call + * `sandlock_start` on it; manage it with `sandlock_handle_kill` / + * `sandlock_handle_wait` / `sandlock_handle_free` as usual. Returns NULL on + * error (any half-built child is reaped before returning). + * + * Fds that could not be transparently restored (sockets, pipes, memfds, + * pseudo-filesystem paths) are recorded on the handle; enumerate them with + * `sandlock_handle_restore_skipped_len` / `_fd` / `_path`. + * + * x86_64 restore engine only. Transparent restore currently holds for + * vDSO-free programs; see `Sandbox::restore_interactive` in sandlock-core. + * + * # Safety + * `policy` must be a valid policy pointer and `cp` a valid checkpoint + * pointer. `name` may be NULL to auto-generate a sandbox name, or a valid + * NUL-terminated string. + */ +sandlock_handle_t *sandlock_restore_interactive(const sandlock_sandbox_t *policy, + const char *name, + const sandlock_checkpoint_t *cp); + +/** + * Number of fds the restore that produced this handle could not + * transparently recreate. 0 for a NULL handle or a handle not produced by + * `sandlock_restore_interactive`. + * + * # Safety + * `h` must be null or a valid handle. + */ +uintptr_t sandlock_handle_restore_skipped_len(const sandlock_handle_t *h); + +/** + * The fd number of the i-th skipped entry (its fd in the checkpointed + * process). Returns -1 if `h` is NULL or `i` is out of range. + * + * # Safety + * `h` must be null or a valid handle. + */ +int sandlock_handle_restore_skipped_fd(const sandlock_handle_t *h, uintptr_t i); + +/** + * The resource path of the i-th skipped entry (e.g. `pipe:[12345]`). + * Returns a malloc'd C string to free with `sandlock_string_free`, or NULL + * if `h` is NULL or `i` is out of range. + * + * # Safety + * `h` must be null or a valid handle. + */ +char *sandlock_handle_restore_skipped_path(const sandlock_handle_t *h, uintptr_t i); + /** * Query the Landlock ABI version supported by the running kernel. * Returns the ABI version (>= 1), or -1 if Landlock is unavailable. diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index af955aee..31668536 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -2567,6 +2567,127 @@ pub unsafe extern "C" fn sandlock_checkpoint_free(cp: *mut sandlock_checkpoint_t } } +/// Restore a checkpoint into a fresh, fully-sandboxed process. +/// +/// Builds a new sandbox from `policy` (with the optional `name`, as in +/// `sandlock_create`) and injects the checkpoint image over a parked child, +/// which resumes at the saved program counter under the full confinement. +/// Returns a live handle: the process is already running, so do NOT call +/// `sandlock_start` on it; manage it with `sandlock_handle_kill` / +/// `sandlock_handle_wait` / `sandlock_handle_free` as usual. Returns NULL on +/// error (any half-built child is reaped before returning). +/// +/// Fds that could not be transparently restored (sockets, pipes, memfds, +/// pseudo-filesystem paths) are recorded on the handle; enumerate them with +/// `sandlock_handle_restore_skipped_len` / `_fd` / `_path`. +/// +/// x86_64 restore engine only. Transparent restore currently holds for +/// vDSO-free programs; see `Sandbox::restore_interactive` in sandlock-core. +/// +/// # Safety +/// `policy` must be a valid policy pointer and `cp` a valid checkpoint +/// pointer. `name` may be NULL to auto-generate a sandbox name, or a valid +/// NUL-terminated string. +#[no_mangle] +pub unsafe extern "C" fn sandlock_restore_interactive( + policy: *const sandlock_sandbox_t, + name: *const c_char, + cp: *const sandlock_checkpoint_t, +) -> *mut sandlock_handle_t { + if policy.is_null() || cp.is_null() { + return ptr::null_mut(); + } + let policy = &(*policy)._private; + let name = match optional_name(name) { + Ok(n) => n, + Err(_) => return ptr::null_mut(), + }; + // Live (multi-threaded) runtime: the restored process is a long-lived + // handle whose seccomp supervisor must keep pumping between FFI calls. + let rt = match build_live_runtime() { + Some(rt) => rt, + None => return ptr::null_mut(), + }; + let mut sb = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), + }; + // Core returns a Process borrowing `sb`; it carries nothing the handle + // does not, so let it drop and move `sb` into the handle (the process + // keeps running; the Sandbox owns it). Skipped fds live on the Sandbox. + let cp_ref = &(*cp)._private; + let restored = matches!( + block_on_runtime(&rt, async { sb.restore_interactive(cp_ref).await.map(|_| ()) }), + Some(Ok(())) + ); + if !restored { + // Dropping `sb` reaps any half-built child left by a failed restore. + return ptr::null_mut(); + } + Box::into_raw(Box::new(sandlock_handle_t { + sandbox: sb, + runtime: rt, + })) +} + +/// Number of fds the restore that produced this handle could not +/// transparently recreate. 0 for a NULL handle or a handle not produced by +/// `sandlock_restore_interactive`. +/// +/// # Safety +/// `h` must be null or a valid handle. +#[no_mangle] +pub unsafe extern "C" fn sandlock_handle_restore_skipped_len( + h: *const sandlock_handle_t, +) -> usize { + if h.is_null() { + return 0; + } + (*h).sandbox.restore_skipped().len() +} + +/// The fd number of the i-th skipped entry (its fd in the checkpointed +/// process). Returns -1 if `h` is NULL or `i` is out of range. +/// +/// # Safety +/// `h` must be null or a valid handle. +#[no_mangle] +pub unsafe extern "C" fn sandlock_handle_restore_skipped_fd( + h: *const sandlock_handle_t, + i: usize, +) -> c_int { + if h.is_null() { + return -1; + } + match (*h).sandbox.restore_skipped().get(i) { + Some(f) => f.fd, + None => -1, + } +} + +/// The resource path of the i-th skipped entry (e.g. `pipe:[12345]`). +/// Returns a malloc'd C string to free with `sandlock_string_free`, or NULL +/// if `h` is NULL or `i` is out of range. +/// +/// # Safety +/// `h` must be null or a valid handle. +#[no_mangle] +pub unsafe extern "C" fn sandlock_handle_restore_skipped_path( + h: *const sandlock_handle_t, + i: usize, +) -> *mut c_char { + if h.is_null() { + return ptr::null_mut(); + } + match (*h).sandbox.restore_skipped().get(i) { + Some(f) => match CString::new(f.path.as_str()) { + Ok(cs) => cs.into_raw(), + Err(_) => ptr::null_mut(), + }, + None => ptr::null_mut(), + } +} + // ---------------------------------------------------------------- // Platform query // ---------------------------------------------------------------- diff --git a/crates/sandlock-ffi/tests/restore.rs b/crates/sandlock-ffi/tests/restore.rs new file mode 100644 index 00000000..d6e9623c --- /dev/null +++ b/crates/sandlock-ffi/tests/restore.rs @@ -0,0 +1,223 @@ +//! Integration test for the C ABI checkpoint-restore `sandlock_restore_interactive`. +//! +//! Drives the FFI symbols directly (no C compilation step of the bindings; +//! only the freestanding guest program is compiled). Mirrors the core +//! `test_restore_real_program_resumes` proof through the C ABI: checkpoint a +//! running vDSO-free counter program, kill it, restore it into a fresh +//! sandbox handle, and prove the restored process advances the counter. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_uint}; +use std::ptr; + +use sandlock_ffi::{ + sandlock_checkpoint_free, sandlock_handle_checkpoint, sandlock_handle_free, + sandlock_handle_kill, sandlock_handle_pid, sandlock_handle_restore_skipped_fd, + sandlock_handle_restore_skipped_len, sandlock_handle_restore_skipped_path, + sandlock_handle_wait, sandlock_result_free, sandlock_restore_interactive, + sandlock_sandbox_build, sandlock_sandbox_builder_fs_read, sandlock_sandbox_builder_fs_write, + sandlock_sandbox_builder_new, sandlock_sandbox_free, sandlock_sandbox_t, sandlock_start, + sandlock_string_free, +}; + +/// Freestanding x86_64 counter program (no libc, no vDSO); see the core +/// restore test for why the guest must be vDSO-free. +fn counter_source(out_path: &str) -> String { + format!( + r##" +#define SYS_write 1 +#define SYS_open 2 +#define SYS_nanosleep 35 +#define SYS_lseek 8 +#define SYS_ftruncate 77 +#define O_WRONLY 1 +#define O_CREAT 0100 +#define O_TRUNC 01000 +static long sys3(long n, long a, long b, long c){{ + long r; __asm__ volatile("syscall":"=a"(r):"a"(n),"D"(a),"S"(b),"d"(c):"rcx","r11","memory"); return r; +}} +struct ts {{ long sec; long nsec; }}; +void _start(void){{ + const char *path = "{out_path}"; + long fd = sys3(SYS_open, (long)path, O_WRONLY|O_CREAT|O_TRUNC, 0644); + unsigned long i = 0; + char buf[24]; + struct ts t; t.sec = 0; t.nsec = 20000000; + for(;;){{ + i++; + int p = 0; unsigned long v = i; char tmp[24]; int k=0; + if(v==0){{ tmp[k++]='0'; }} while(v){{ tmp[k++]='0'+(v%10); v/=10; }} + while(k>0){{ buf[p++]=tmp[--k]; }} buf[p++]='\n'; + sys3(SYS_lseek, fd, 0, 0); + sys3(SYS_ftruncate, fd, 0, 0); + sys3(SYS_write, fd, (long)buf, p); + sys3(SYS_nanosleep, (long)&t, 0, 0); + }} +}} +"## + ) +} + +fn build_policy(tmp: &str) -> *mut sandlock_sandbox_t { + let mut b = sandlock_sandbox_builder_new(); + for p in ["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", tmp] { + let c = CString::new(p).unwrap(); + b = unsafe { sandlock_sandbox_builder_fs_read(b, c.as_ptr()) }; + } + let c = CString::new(tmp).unwrap(); + b = unsafe { sandlock_sandbox_builder_fs_write(b, c.as_ptr()) }; + let mut err: c_int = 0; + let policy = unsafe { sandlock_sandbox_build(b, &mut err, ptr::null_mut()) }; + assert_eq!(err, 0, "policy build failed"); + assert!(!policy.is_null()); + policy +} + +fn which(prog: &str) -> bool { + std::env::var_os("PATH").is_some_and(|paths| { + std::env::split_paths(&paths).any(|d| d.join(prog).is_file()) + }) +} + +fn read_counter(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse::().ok()) +} + +#[test] +fn restore_interactive_resumes_via_c_abi() { + if cfg!(not(target_arch = "x86_64")) { + eprintln!("skipping: injection-based restore is x86_64-only"); + return; + } + let cc = if which("cc") { + "cc" + } else if which("gcc") { + "gcc" + } else { + eprintln!("skipping: no C compiler (cc/gcc) available"); + return; + }; + + let tmp = std::env::temp_dir().join(format!("sandlock-ffi-restore-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let src = tmp.join("counter.c"); + let bin = tmp.join("counter"); + let counter = tmp.join("counter.cnt"); + let counter_s = counter.to_str().unwrap().to_string(); + std::fs::write(&src, counter_source(&counter_s)).unwrap(); + let build = std::process::Command::new(cc) + .args(["-static", "-nostdlib", "-no-pie", "-O0", "-o"]) + .arg(&bin) + .arg(&src) + .output() + .unwrap(); + if !build.status.success() { + eprintln!( + "skipping: build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + let _ = std::fs::remove_dir_all(&tmp); + return; + } + + let tmp_s = tmp.to_str().unwrap(); + let policy = build_policy(tmp_s); + + // Spawn the counter, let it advance, checkpoint, then kill the original. + let bin_c = CString::new(bin.to_str().unwrap()).unwrap(); + let av: Vec<*const c_char> = vec![bin_c.as_ptr()]; + let h = unsafe { + sandlock_ffi::sandlock_create(policy, ptr::null(), av.as_ptr(), av.len() as c_uint) + }; + assert!(!h.is_null(), "sandlock_create failed"); + assert_eq!(unsafe { sandlock_start(h) }, 0, "sandlock_start failed"); + std::thread::sleep(std::time::Duration::from_millis(400)); + + let cp = unsafe { sandlock_handle_checkpoint(h) }; + assert!(!cp.is_null(), "sandlock_handle_checkpoint failed"); + + // A handle that was not produced by restore has no skipped fds. + assert_eq!(unsafe { sandlock_handle_restore_skipped_len(h) }, 0); + + let baseline = read_counter(&counter_s).expect("counter file should exist with a value"); + assert!(baseline > 2, "counter should have advanced, got {baseline}"); + + assert_eq!(unsafe { sandlock_handle_kill(h) }, 0); + let r = unsafe { sandlock_handle_wait(h) }; + if !r.is_null() { + unsafe { sandlock_result_free(r) }; + } + unsafe { sandlock_handle_free(h) }; + + // Sentinel: only the restored process can advance the file past baseline. + std::fs::write(&counter, b"0\n").unwrap(); + + // Restore into a fresh, fully-sandboxed handle. The handle is the only + // return value; skipped-fd diagnostics are queried from it afterwards. + let h2 = unsafe { sandlock_restore_interactive(policy, ptr::null(), cp) }; + assert!(!h2.is_null(), "sandlock_restore_interactive failed"); + assert!(unsafe { sandlock_handle_pid(h2) } > 0); + + let n = unsafe { sandlock_handle_restore_skipped_len(h2) }; + for i in 0..n { + let fd = unsafe { sandlock_handle_restore_skipped_fd(h2, i) }; + assert!(fd >= 0, "skipped entry {i} must carry a real fd, got {fd}"); + let p = unsafe { sandlock_handle_restore_skipped_path(h2, i) }; + assert!(!p.is_null(), "skipped entry {i} must carry a path"); + let s = unsafe { CStr::from_ptr(p) }.to_string_lossy().to_string(); + assert!(!s.is_empty()); + eprintln!("restore skipped fd {fd}: {s}"); + unsafe { sandlock_string_free(p) }; + } + // Out-of-range indices answer with sentinels, not UB. + assert_eq!(unsafe { sandlock_handle_restore_skipped_fd(h2, n) }, -1); + assert!(unsafe { sandlock_handle_restore_skipped_path(h2, n) }.is_null()); + + // Poll up to ~3s for the restored process to advance past baseline. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut last = 0u64; + let mut advanced = false; + while std::time::Instant::now() < deadline { + if let Some(v) = read_counter(&counter_s) { + last = v; + if v > baseline { + advanced = true; + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + // Clean up before asserting so a failure never leaks the child/files. + unsafe { + sandlock_handle_kill(h2); + let r2 = sandlock_handle_wait(h2); + if !r2.is_null() { + sandlock_result_free(r2); + } + sandlock_handle_free(h2); + sandlock_checkpoint_free(cp); + sandlock_sandbox_free(policy); + } + let _ = std::fs::remove_dir_all(&tmp); + + assert!( + advanced, + "restored process must resume and advance the counter past {baseline}; last seen {last}" + ); +} + +#[test] +fn restore_interactive_null_inputs_return_null() { + let h = unsafe { sandlock_restore_interactive(ptr::null(), ptr::null(), ptr::null()) }; + assert!(h.is_null()); +} + +#[test] +fn restore_skipped_accessors_tolerate_null_handle() { + assert_eq!(unsafe { sandlock_handle_restore_skipped_len(ptr::null()) }, 0); + assert_eq!(unsafe { sandlock_handle_restore_skipped_fd(ptr::null(), 0) }, -1); + assert!(unsafe { sandlock_handle_restore_skipped_path(ptr::null(), 0) }.is_null()); +} diff --git a/crates/sandlock-oci/src/supervisor.rs b/crates/sandlock-oci/src/supervisor.rs index 6c61987d..249915b8 100644 --- a/crates/sandlock-oci/src/supervisor.rs +++ b/crates/sandlock-oci/src/supervisor.rs @@ -1011,15 +1011,12 @@ async fn supervisor_restore_main( // Restore: forks the child under the saved policy, injects the checkpoint, // and RESUMES it. The child is already running on return — there is no // separate start step. - let skipped = match sandbox.restore_interactive(&cp).await { - Ok(s) => s, - Err(e) => { - pipe_write(pid_write_fd, &format!("ERR restore: {}", e)); - return Err(anyhow::anyhow!("sandbox restore_interactive: {}", e)); - } - }; - for path in &skipped { - eprintln!("sandlock: not transparently restored: {}", path); + if let Err(e) = sandbox.restore_interactive(&cp).await { + pipe_write(pid_write_fd, &format!("ERR restore: {}", e)); + return Err(anyhow::anyhow!("sandbox restore_interactive: {}", e)); + } + for f in sandbox.restore_skipped() { + eprintln!("sandlock: fd {} not transparently restored: {}", f.fd, f.path); } let child_pid = sandbox.pid().unwrap_or(0); diff --git a/python/README.md b/python/README.md index 194691b7..89579214 100644 --- a/python/README.md +++ b/python/README.md @@ -348,6 +348,29 @@ Raises `RuntimeError` if the sandbox is not running. Capture a checkpoint of the running sandbox. See [Checkpoint](#checkpoint). +#### `sandbox.restore_interactive(cp) -> None` + +Restore a checkpoint into a fresh, fully-sandboxed process. The +checkpoint image is injected over a parked child, which resumes at the +saved program counter under the full confinement; the sandbox is +running when this returns (no `start()` needed). Manage it with `pid`, +`pause()`, `resume()`, `kill()`, and `wait()` as usual. + +x86_64 only; transparent restore currently works for vDSO-free +programs (a glibc program resumes but crashes on its first vDSO call). + +Raises `RuntimeError` if a process is already running in this sandbox +or the restore fails. See [Checkpoint](#checkpoint). + +#### `sandbox.restore_skipped -> list[SkippedFd]` + +Fds the last `restore_interactive()` could not transparently recreate +(sockets, pipes, memfds, pseudo-filesystem paths); the restored process +runs without them. Each entry is a `SkippedFd` named tuple with `fd` +(the fd number in the checkpointed process) and `path` (the resource it +pointed at, e.g. `pipe:[12345]`). Empty if this sandbox never restored +a checkpoint or every fd was restored. + ### Result Returned by `sandbox.run()`. @@ -563,17 +586,24 @@ cp = sb.checkpoint(save_fn=lambda: my_state_bytes()) cp.save("my-snapshot") # Later: -cp2 = Checkpoint.load("my-snapshot") -Checkpoint.restore("my-snapshot", restore_fn=lambda data: rebuild(data)) +cp2 = Checkpoint.load("my-snapshot", restore_fn=lambda data: rebuild(data)) + +# Or restore the OS-level process image into a fresh sandbox +# (x86_64, vDSO-free programs): +sb2 = Sandbox(...) # same policy +sb2.restore_interactive(cp2) +for f in sb2.restore_skipped: + print(f"fd {f.fd} not restored: {f.path}") ``` | Method | Description | |--------|-------------| | `cp.save(name, store=None)` | Persist checkpoint to disk | -| `Checkpoint.load(name, store=None)` | Load from disk | -| `Checkpoint.restore(name, restore_fn=None, store=None)` | Load; if app_state was saved (via save_fn) call restore_fn with it. restore_fn and save_fn must both be present or both absent. | +| `Checkpoint.load(name, store=None, restore_fn=None)` | Load from disk; if the checkpoint carries app_state and restore_fn is given, call restore_fn with it. Neither save_fn nor restore_fn is mandatory, and they need not be paired | | `Checkpoint.list(store=None)` | List saved checkpoint names | | `Checkpoint.delete(name, store=None)` | Delete a saved checkpoint | +| `sb.restore_interactive(cp)` | Restore the process image into a fresh sandbox; see [Sandbox](#sandbox) | +| `sb.restore_skipped` | Fds the last restore could not recreate, as `SkippedFd(fd, path)` | Properties: `cp.name` (str), `cp.app_state` (bytes or None). diff --git a/python/src/sandlock/__init__.py b/python/src/sandlock/__init__.py index 2711c3f0..4579e3cf 100644 --- a/python/src/sandlock/__init__.py +++ b/python/src/sandlock/__init__.py @@ -7,7 +7,7 @@ from ._version import __version__ from ._sdk import ( - Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, + Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, SkippedFd, NamedStage, Gather, GatherPipeline, Protection, landlock_abi_version, min_landlock_abi, confine, @@ -43,6 +43,7 @@ "SyscallEvent", "PolicyContext", "Checkpoint", + "SkippedFd", "NamedStage", "Gather", "GatherPipeline", diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index c1759956..35d55c70 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from enum import IntEnum from pathlib import Path -from typing import Any, Sequence +from typing import Any, NamedTuple, Sequence from .sandbox import Sandbox as PolicyDataclass @@ -480,6 +480,18 @@ def confine(policy: "PolicyDataclass") -> None: _lib.sandlock_checkpoint_free.restype = None _lib.sandlock_checkpoint_free.argtypes = [_c_checkpoint_p] +_lib.sandlock_restore_interactive.restype = _c_handle_p +_lib.sandlock_restore_interactive.argtypes = [_c_policy_p, ctypes.c_char_p, _c_checkpoint_p] + +_lib.sandlock_handle_restore_skipped_len.restype = ctypes.c_size_t +_lib.sandlock_handle_restore_skipped_len.argtypes = [_c_handle_p] + +_lib.sandlock_handle_restore_skipped_fd.restype = ctypes.c_int +_lib.sandlock_handle_restore_skipped_fd.argtypes = [_c_handle_p, ctypes.c_size_t] + +_lib.sandlock_handle_restore_skipped_path.restype = ctypes.c_void_p +_lib.sandlock_handle_restore_skipped_path.argtypes = [_c_handle_p, ctypes.c_size_t] + # ---------------------------------------------------------------- # Handler ABI — extension handlers for seccomp-notif syscalls. @@ -753,6 +765,18 @@ class Result: _DEFAULT_STORE = Path.home() / ".sandlock" / "checkpoints" +class SkippedFd(NamedTuple): + """An fd that ``Sandbox.restore_interactive`` could not transparently + recreate (socket, pipe, memfd, deleted or pseudo-filesystem path). The + restored process runs without it; such resources fall to the + ``app_state`` hatch.""" + + fd: int + """The fd number in the checkpointed process.""" + path: str + """The resource the fd pointed at (e.g. ``pipe:[12345]``).""" + + class Checkpoint: """A frozen snapshot of sandbox state (registers, memory, fds). @@ -850,15 +874,32 @@ def save(self, name: str, *, store: Path | str | None = None) -> Path: return cp_dir @classmethod - def load(cls, name: str, *, store: Path | str | None = None) -> "Checkpoint": + def load( + cls, + name: str, + *, + store: Path | str | None = None, + restore_fn: "Callable[[bytes], None] | None" = None, + ) -> "Checkpoint": """Load a named checkpoint from disk. + ``restore_fn`` mirrors ``save_fn`` on ``Sandbox.checkpoint``: use it + to rebuild application-level state that ptrace cannot capture (caches, + session data, etc.). Neither is mandatory, and they need not be + paired: ``restore_fn`` is called with ``cp.app_state`` only when the + checkpoint carries app state, and app state left unconsumed here + remains readable via ``cp.app_state``. Restoring the OS-level process + image is separate: see ``Sandbox.restore_interactive``. + Args: name: Checkpoint name. store: Storage root. Defaults to ``~/.sandlock/checkpoints/``. + restore_fn: Optional callback receiving the saved + application-level state bytes; not called if the checkpoint + has no app state. Returns: - Checkpoint with all state restored. + The loaded Checkpoint. Raises: FileNotFoundError: If the checkpoint does not exist. @@ -871,59 +912,11 @@ def load(cls, name: str, *, store: Path | str | None = None) -> "Checkpoint": ptr = _lib.sandlock_checkpoint_load(_encode(str(cp_dir))) if not ptr: raise RuntimeError(f"Failed to load checkpoint from {cp_dir}") - return cls(ptr) - - @classmethod - def restore( - cls, - name: str, - restore_fn: "Callable[[bytes], None] | None" = None, - *, - store: "Path | str | None" = None, - ) -> "Checkpoint": - """Load a checkpoint and, if it carries app state, pass it to restore_fn. - - Convenience for ``load()`` plus calling ``restore_fn(cp.app_state)``. - - ``restore_fn`` mirrors ``save_fn`` on ``Sandbox.checkpoint``: a checkpoint - taken without ``save_fn`` has no app state and is restored without - ``restore_fn``; a checkpoint taken with ``save_fn`` carries app state and - must be restored with ``restore_fn``. Supplying exactly one of the pair - is an error. - - Args: - name: Checkpoint name. - restore_fn: Optional callback receiving the saved application-level - state bytes. Required if (and only if) the checkpoint was taken - with ``save_fn``. Use it to rebuild state that ptrace cannot - capture (caches, session data, etc.). - store: Storage root. Defaults to ``~/.sandlock/checkpoints/``. - - Returns: - The loaded Checkpoint. - - Raises: - FileNotFoundError: If the checkpoint does not exist. - ValueError: If the checkpoint has app_state but no restore_fn was - given, or a restore_fn was given but the checkpoint has no - app_state. - """ - cp = cls.load(name, store=store) - state = cp.app_state - has_state = state is not None - has_fn = restore_fn is not None - if has_state and not has_fn: - raise ValueError( - f"Checkpoint {name!r} has app_state; pass restore_fn to " - "consume it (it was created with save_fn)" - ) - if has_fn and not has_state: - raise ValueError( - f"Checkpoint {name!r} has no app_state; restore_fn was given " - "but there is nothing to pass (was it created with save_fn?)" - ) - if has_fn: - restore_fn(state) + cp = cls(ptr) + if restore_fn is not None: + state = cp.app_state + if state is not None: + restore_fn(state) return cp @classmethod diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 5ce9b290..9ec9d165 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -423,6 +423,7 @@ def __post_init__(self): self._process = None # weakref to the live popen() Process; it OWNS its # own handle (see `_popen_process`), this is only a # non-owning busy marker + self._restore_skipped = [] # SkippedFd entries from the last restore def _resolve_name(self) -> str: """Resolve sandbox name: explicit > auto-generated.""" @@ -1257,6 +1258,66 @@ def checkpoint( cp.app_state = save_fn() return cp + def restore_interactive(self, cp: "Checkpoint") -> None: + """Restore a checkpoint into a fresh, fully-sandboxed process. + + The checkpoint image is injected over a parked child, which resumes + at the saved program counter under the full confinement. The sandbox + is running when this returns (no ``start()`` needed); manage it with + ``pid`` / ``pause()`` / ``resume()`` / ``kill()`` / ``wait()`` as + usual. This restores the OS-level process image; for application + state carried in ``app_state``, see ``Checkpoint.load``'s + ``restore_fn``. Fds that + could not be transparently restored are reported by + :attr:`restore_skipped`. + + x86_64 only. Transparent restore currently works for vDSO-free + programs: a glibc program resumes but crashes on its first vDSO call + (known limitation of the injection-based restore engine). + + Args: + cp: Checkpoint to restore, from :meth:`checkpoint` or + ``Checkpoint.load``. + + Raises: + RuntimeError: If a process is already running in this sandbox, + or the restore fails. + """ + import ctypes + + from ._sdk import _lib, SkippedFd + + self._check_not_running() + native = self._ensure_native() + handle = _lib.sandlock_restore_interactive( + native.ptr, _encode(self._resolve_name()), cp._ptr, + ) + if not handle: + raise RuntimeError("checkpoint restore failed") + self._handle = handle + # Copy the skipped-fd diagnostics out eagerly: the native entries live + # on the handle, which wait() frees. + skipped = [] + for i in range(_lib.sandlock_handle_restore_skipped_len(handle)): + fd = _lib.sandlock_handle_restore_skipped_fd(handle, i) + raw = _lib.sandlock_handle_restore_skipped_path(handle, i) + path = "" + if raw: + c_str = ctypes.cast(raw, ctypes.c_char_p) + if c_str.value: + path = c_str.value.decode("utf-8", errors="replace") + _lib.sandlock_string_free(c_str) + skipped.append(SkippedFd(fd=fd, path=path)) + self._restore_skipped = skipped + + @property + def restore_skipped(self) -> "list[SkippedFd]": + """Fds the last :meth:`restore_interactive` could not transparently + recreate (sockets, pipes, memfds, pseudo-filesystem paths); the + restored process runs without them. Empty if this sandbox never + restored a checkpoint or every fd was restored.""" + return list(self._restore_skipped) + def _read_port_mappings(handle) -> dict[int, int]: """Decode {virtual: real} port mappings for a live handle. Shared by diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index b563ac0b..e092ed8f 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -4,11 +4,15 @@ from __future__ import annotations import json +import platform +import shutil +import subprocess import sys +import time import pytest -from sandlock import Sandbox, Checkpoint +from sandlock import Sandbox, Checkpoint, SkippedFd from sandlock._sdk import _encode, _lib, _make_argv @@ -120,8 +124,16 @@ def test_delete_nonexistent_raises(self, tmp_dir): Checkpoint.delete("nope", store=tmp_dir) -class TestCheckpointRestore: - def test_restore_calls_restore_fn(self, running_sandbox, tmp_dir): +class TestCheckpointLoadRestoreFn: + """load(restore_fn=...) semantics. app_state is auxiliary to the real + process-image restore, so save_fn/restore_fn pairing is NOT mandatory: + restore_fn runs only when both it and app_state are present.""" + + def test_restore_classmethod_is_gone(self): + # Folded into load(); "restore" now means process-image restore only. + assert not hasattr(Checkpoint, "restore") + + def test_load_calls_restore_fn_with_app_state(self, running_sandbox, tmp_dir): original = {"model": "gpt-4", "tokens": 99} cp = running_sandbox.checkpoint( save_fn=lambda: json.dumps(original).encode(), @@ -129,51 +141,154 @@ def test_restore_calls_restore_fn(self, running_sandbox, tmp_dir): cp.save("restorable", store=tmp_dir) restored = {} - Checkpoint.restore( + result = Checkpoint.load( "restorable", - restore_fn=lambda data: restored.update(json.loads(data)), store=tmp_dir, + restore_fn=lambda data: restored.update(json.loads(data)), ) assert restored == original - - def test_restore_returns_checkpoint(self, running_sandbox, tmp_dir): - cp = running_sandbox.checkpoint( - save_fn=lambda: b"state", - ) - cp.save("ret-test", store=tmp_dir) - - result = Checkpoint.restore("ret-test", lambda d: None, store=tmp_dir) assert isinstance(result, Checkpoint) - assert result.name == "ret-test" + assert result.name == "restorable" - def test_restore_no_app_state_no_fn_succeeds(self, running_sandbox, tmp_dir): - # save_fn absent at checkpoint, restore_fn absent at restore: both - # absent is the symmetric, valid case and must not raise. - cp = running_sandbox.checkpoint() - cp.save("no-app", store=tmp_dir) + def test_load_without_restore_fn_keeps_app_state(self, running_sandbox, tmp_dir): + # app_state present, restore_fn omitted: no error, state stays readable. + cp = running_sandbox.checkpoint(save_fn=lambda: b"state") + cp.save("app-no-fn", store=tmp_dir) - result = Checkpoint.restore("no-app", store=tmp_dir) - assert isinstance(result, Checkpoint) - assert result.app_state is None + loaded = Checkpoint.load("app-no-fn", store=tmp_dir) + assert loaded.app_state == b"state" - def test_restore_fn_without_app_state_raises(self, running_sandbox, tmp_dir): - # restore_fn given but the checkpoint has no app_state: a one-sided - # pairing, which is rejected. + def test_load_restore_fn_without_app_state_not_called( + self, running_sandbox, tmp_dir + ): + # restore_fn given, checkpoint has no app_state: fn is simply not + # called; no error. cp = running_sandbox.checkpoint() cp.save("no-app-fn", store=tmp_dir) - with pytest.raises(ValueError, match="no app_state"): - Checkpoint.restore("no-app-fn", lambda d: None, store=tmp_dir) + calls = [] + loaded = Checkpoint.load("no-app-fn", store=tmp_dir, restore_fn=calls.append) + assert calls == [] + assert loaded.app_state is None + + +# Freestanding x86_64 program (no libc, no vDSO; raw syscalls only) that opens +# an output file once, then loops forever rewriting an incrementing counter +# through that kept-open fd. Mirrors the core restore integration test: the +# injection-based restore engine does not relocate the vDSO, so the guest must +# be vDSO-free for transparent restore to hold. +_COUNTER_TEMPLATE = r""" +#define SYS_write 1 +#define SYS_open 2 +#define SYS_nanosleep 35 +#define SYS_lseek 8 +#define SYS_ftruncate 77 +#define O_WRONLY 1 +#define O_CREAT 0100 +#define O_TRUNC 01000 +static long sys3(long n, long a, long b, long c){ + long r; __asm__ volatile("syscall":"=a"(r):"a"(n),"D"(a),"S"(b),"d"(c):"rcx","r11","memory"); return r; +} +struct ts { long sec; long nsec; }; +void _start(void){ + const char *path = "@OUT_PATH@"; + long fd = sys3(SYS_open, (long)path, O_WRONLY|O_CREAT|O_TRUNC, 0644); + unsigned long i = 0; + char buf[24]; + struct ts t; t.sec = 0; t.nsec = 20000000; + for(;;){ + i++; + int p = 0; unsigned long v = i; char tmp[24]; int k=0; + if(v==0){ tmp[k++]='0'; } while(v){ tmp[k++]='0'+(v%10); v/=10; } + while(k>0){ buf[p++]=tmp[--k]; } buf[p++]='\n'; + sys3(SYS_lseek, fd, 0, 0); + sys3(SYS_ftruncate, fd, 0, 0); + sys3(SYS_write, fd, (long)buf, p); + sys3(SYS_nanosleep, (long)&t, 0, 0); + } +} +""" + + +def _build_counter(tmp_dir): + """Compile the vDSO-free counter program, or skip if this host can't.""" + if platform.machine() != "x86_64": + pytest.skip("injection-based restore is x86_64-only") + cc = shutil.which("cc") or shutil.which("gcc") + if cc is None: + pytest.skip("no C compiler (cc/gcc) available") + counter = tmp_dir / "counter.cnt" + src = tmp_dir / "counter.c" + binary = tmp_dir / "counter" + src.write_text(_COUNTER_TEMPLATE.replace("@OUT_PATH@", str(counter))) + build = subprocess.run( + [cc, "-static", "-nostdlib", "-no-pie", "-O0", "-o", str(binary), str(src)], + capture_output=True, + ) + if build.returncode != 0: + pytest.skip(f"counter build failed: {build.stderr.decode()}") + return binary, counter - def test_restore_app_state_without_fn_raises(self, running_sandbox, tmp_dir): - # app_state present but restore_fn omitted: the other one-sided pairing, - # also rejected. - cp = running_sandbox.checkpoint(save_fn=lambda: b"state") - cp.save("app-no-fn", store=tmp_dir) - with pytest.raises(ValueError, match="has app_state"): - Checkpoint.restore("app-no-fn", store=tmp_dir) +def _read_counter(path) -> int: + try: + return int(path.read_text().strip()) + except (FileNotFoundError, ValueError): + return 0 # absent or torn read mid-rewrite - def test_restore_nonexistent_raises(self, tmp_dir): - with pytest.raises(FileNotFoundError): - Checkpoint.restore("nope", lambda d: None, store=tmp_dir) + +class TestRestoreInteractive: + def test_restore_resumes_counter(self, tmp_dir): + binary, counter = _build_counter(tmp_dir) + policy_kw = dict( + fs_readable=_PYTHON_READABLE + [str(tmp_dir)], + fs_writable=[str(tmp_dir)], + ) + + sb = _policy(**policy_kw) + sb.spawn([str(binary)]) + try: + time.sleep(0.4) + cp = sb.checkpoint() + baseline = _read_counter(counter) + assert baseline > 2, f"counter should have advanced, got {baseline}" + finally: + sb.kill() + sb.wait() + + # Sentinel: only the restored process can advance the file past baseline. + counter.write_text("0\n") + + sb2 = _policy(**policy_kw) + assert sb2.restore_skipped == [] + ret = sb2.restore_interactive(cp) + assert ret is None + skipped = sb2.restore_skipped + assert isinstance(skipped, list) + for s in skipped: + assert isinstance(s, SkippedFd) + assert isinstance(s.fd, int) and s.fd >= 0 + assert isinstance(s.path, str) and s.path + try: + assert sb2.is_running + assert sb2.pid + deadline = time.monotonic() + 3 + last = 0 + advanced = False + while time.monotonic() < deadline: + last = _read_counter(counter) + if last > baseline: + advanced = True + break + time.sleep(0.05) + finally: + sb2.kill() + sb2.wait() + assert advanced, ( + f"restored process must resume and advance past {baseline}, last {last}" + ) + + def test_restore_while_running_raises(self, running_sandbox): + cp = running_sandbox.checkpoint() + with pytest.raises(RuntimeError, match="already"): + running_sandbox.restore_interactive(cp)