diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index f2622896..a40456ae 100644 --- a/crates/sandlock-core/build.rs +++ b/crates/sandlock-core/build.rs @@ -8,14 +8,17 @@ fn main() { // rootfs-helper: an ordinary static-libc test fixture (chroot tests). It // lives in tests/ and its binary sits beside it (a git-ignored artifact). - build_static( + if !build_static( &repo_root.join("tests/rootfs-helper.c"), &repo_root.join("tests/rootfs-helper"), &["musl-gcc", "cc"], &["-static", "-O2"], - "cannot compile tests/rootfs-helper: chroot tests will fail. \ - Install musl-tools or static libc.", - ); + ) { + println!( + "cargo:warning=cannot compile tests/rootfs-helper: chroot tests will \ + fail. Install musl-tools or static libc." + ); + } // restore-stub: a core component of the restore engine (the supervisor execs // it to reconstruct a checkpoint), freestanding, no libc, no PIE. It lives @@ -27,46 +30,87 @@ fn main() { // text and stack have to sit outside the address range programs occupy. The // default -no-pie base (0x400000) is exactly where a static ET_EXEC workload // loads, so the checkpoint's own text would be mapped over the running stub. + // + // Cross-compilation: when TARGET is riscv64gc-unknown-linux-gnu (or any + // riscv64* variant), look for a riscv64 cross-compiler. On the host it + // uses plain `cc` as before. let stub_src = manifest_dir.join("src/checkpoint/restore-stub.c"); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let stub_bin = out_dir.join("restore-stub"); - build_static( + let host = std::env::var("HOST").unwrap_or_default(); + let target = std::env::var("TARGET").unwrap_or_default(); + let is_riscv64 = target.starts_with("riscv64"); + // Checkpoint restore is claimed only on x86_64 and riscv64 (see + // `restore_interactive`); on those arches a stub build failure is fatal, not + // a silent skip — a green build with no stub is how regressions slip past CI. + let is_restore_arch = target.starts_with("x86_64") || is_riscv64; + let (ccs, fail_msg) = if is_riscv64 { + if host.starts_with("riscv64") { + ( + &["cc", "riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "failed to compile restore-stub for riscv64: no working C compiler \ + (install gcc); checkpoint restore is unavailable", + ) + } else { + ( + &["riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "failed to compile restore-stub for riscv64: no working cross-compiler \ + (install riscv64-linux-gnu-gcc); checkpoint restore is unavailable", + ) + } + } else { + ( + &["cc"][..], + "failed to compile restore-stub: no working C compiler \ + (install cc/gcc); checkpoint restore is unavailable", + ) + }; + // The link address must match restore_blob::STUB_BASE and must sit below + // the Sv39 user ceiling (256 GiB) on riscv64. x86_64 uses 0x300_0000_0000. + let text_segment = if is_riscv64 { + "-Wl,-Ttext-segment=0x3000000000" + } else { + "-Wl,-Ttext-segment=0x30000000000" + }; + if !build_static( &stub_src, &stub_bin, - &["cc"], + ccs, &[ "-static", "-nostdlib", "-no-pie", "-O2", - // Without these, loop-idiom recognition rewrites the stub's own - // hand-written memset/memcpy bodies into calls to memset/memcpy, - // i.e. into infinite self-recursion. There is no libc to fall back - // on, so the stub must keep its byte loops as byte loops. "-ffreestanding", "-fno-tree-loop-distribute-patterns", - "-Wl,-Ttext-segment=0x30000000000", + text_segment, ], - "cannot compile restore-stub: its restore tests will be skipped.", - ); + ) { + if is_restore_arch { + panic!("{fail_msg}"); + } + println!("cargo:warning={fail_msg}"); + } // Emit the path every run (rustc-env is not cached across build-script runs), // whether or not the binary was just (re)built. println!("cargo:rustc-env=RESTORE_STUB_PATH={}", stub_bin.display()); } /// Compile `src` to `bin` with the first working compiler in `ccs`, skipping the -/// work when `bin` is newer than `src`. Emits `warn` (as a cargo warning) if no -/// compiler succeeds. A missing source is silently skipped (packaged crate). -fn build_static(src: &Path, bin: &Path, ccs: &[&str], args: &[&str], warn: &str) { +/// work when `bin` is newer than `src`. Returns `false` only when the source is +/// present, newer than `bin`, and no compiler in `ccs` succeeded; a missing +/// source (a packaged crate) or an up-to-date `bin` reports success. The caller +/// decides whether that failure is a hard error or a warning. +fn build_static(src: &Path, bin: &Path, ccs: &[&str], args: &[&str]) -> bool { println!("cargo:rerun-if-changed={}", src.display()); if !src.exists() { - return; + return true; } if bin.exists() { if let (Ok(s), Ok(b)) = (src.metadata(), bin.metadata()) { if let (Ok(st), Ok(bt)) = (s.modified(), b.modified()) { if bt >= st { - return; + return true; } } } @@ -81,8 +125,8 @@ fn build_static(src: &Path, bin: &Path, ccs: &[&str], args: &[&str], warn: &str) .map(|s| s.success()) .unwrap_or(false); if ok { - return; + return true; } } - println!("cargo:warning={warn}"); + false } diff --git a/crates/sandlock-core/src/checkpoint/capture.rs b/crates/sandlock-core/src/checkpoint/capture.rs index 4be952a5..a0bdbca5 100644 --- a/crates/sandlock-core/src/checkpoint/capture.rs +++ b/crates/sandlock-core/src/checkpoint/capture.rs @@ -418,6 +418,19 @@ pub(crate) fn capture(pid: i32, policy: &Sandbox) -> Resultpath_off, f->flags, 0); if (fd < 0) die(10); if ((u32)fd != f->fd) { +#if defined(__riscv) && __riscv_xlen == 64 + /* riscv64 has no SYS_dup2 — use dup3 with flags=0. */ + if (SC3(SYS_dup3, fd, f->fd, 0) != (i64)f->fd) die(10); +#else if (SC2(SYS_dup2, fd, f->fd) != (i64)f->fd) die(10); +#endif SC1(SYS_close, fd); } SC3(SYS_lseek, f->fd, f->offset, SEEK_SET); } +#ifdef __x86_64__ /* 9. Restore the thread pointer. The x86_64 signal frame has 23 gregs and * none of them is fs_base, so rt_sigreturn cannot carry it and the resumed * program would inherit this stub's, which is zero because a -nostdlib @@ -373,10 +469,12 @@ static void _start_c(u64 *sp) { * ordinary user programs; set it only when the checkpoint recorded one. */ if (SC2(SYS_arch_prctl, ARCH_SET_FS, gp[UR_FS_BASE]) != 0) die(13); if (gp[UR_GS_BASE] && SC2(SYS_arch_prctl, ARCH_SET_GS, gp[UR_GS_BASE]) != 0) die(13); +#endif /* 10. Build the rt_sigframe on our private stack and rt_sigreturn into the * checkpoint. The frame must be readable when the kernel consumes it; the * stub stack is a plain .bss mapping at STUB_BASE, so it always is. */ +#ifdef __x86_64__ struct uctx uc; memset(&uc, 0, sizeof uc); struct sigctx *m = &uc.mc; @@ -414,6 +512,31 @@ static void _start_c(u64 *sp) { : : "r"(&uc), "r"(rax) : "memory"); + +#elif defined(__riscv) && __riscv_xlen == 64 + /* riscv64: build a struct rt_sigframe on the stack. gp[] order is 1:1 with + * sc_regs (both ptrace order), so copy the register file directly. + * The FP state is embedded inline in sc_fpregs (no pointer indirection, + * no magic framing — restore_blob.rs sends the raw __riscv_d_ext_state). */ + struct rt_sf sf; + memset(&sf, 0, sizeof sf); + /* gp has regs_len / 8 entries; copy all of them into sc_regs[32]. */ + u32 nregs = h->regs_len / 8; + if (nregs > 32) nregs = 32; + memcpy(sf.uc.mc.gregs, gp, nregs * sizeof(u64)); + if (h->fpstate_len) { + memcpy(sf.uc.mc.fpregs, ctrl_buf + h->fpstate_off, h->fpstate_len); + } + + /* Set sp = &sf, then ecall rt_sigreturn. */ + register u64 a7 __asm__("a7") = SYS_rt_sigreturn; + __asm__ volatile( + "mv sp, %0\n\t" + "ecall\n\t" + : + : "r"(&sf), "r"(a7) + : "memory"); +#endif die(11); /* rt_sigreturn must not return */ } @@ -421,6 +544,7 @@ static void _start_c(u64 *sp) { * _start_c as its argument (auxv lives there), then switch to the private .bss * stack, because the checkpoint's [stack] region is mapped over the address the * kernel picked for ours. */ +#ifdef __x86_64__ __asm__( ".global _start\n" "_start:\n" @@ -432,3 +556,17 @@ __asm__( " call _start_c\n" " hlt\n" ); +#elif defined(__riscv) && __riscv_xlen == 64 +/* riscv64: a0 = sp (first argument), switch to stub_stack, align, call. */ +__asm__( + ".global _start\n" + "_start:\n" + " mv a0, sp\n" + " la sp, stub_stack\n" + " li t0, " STR(STACK_SIZE) "\n" + " add sp, sp, t0\n" + " andi sp, sp, -16\n" + " call _start_c\n" + " unimp\n" +); +#endif diff --git a/crates/sandlock-core/src/checkpoint/restore_blob.rs b/crates/sandlock-core/src/checkpoint/restore_blob.rs index bdcd5667..981e86bb 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -42,7 +42,15 @@ const SRC_FILE: u8 = 1; /// through the stub, because the stub's own text would be clobbered while it /// runs (this is exactly the collision a `-no-pie` stub at the default 0x400000 /// hits against any static `ET_EXEC` workload). +/// +/// x86_64 uses 3 TiB, far above any ordinary user address space. riscv64 must +/// stay below the Sv39 ceiling of 256 GiB. +#[cfg(target_arch = "x86_64")] pub(crate) const STUB_BASE: u64 = 0x300_0000_0000; +#[cfg(target_arch = "riscv64")] +pub(crate) const STUB_BASE: u64 = 0x30_0000_0000; +#[cfg(not(any(target_arch = "x86_64", target_arch = "riscv64")))] +pub(crate) const STUB_BASE: u64 = 0; pub(crate) const STUB_SPAN: u64 = 0x40_0000; /// x86_64 signal-frame FP image constants. `FP_XSTATE_MAGIC1` in the @@ -98,12 +106,15 @@ pub(crate) struct RestorePlan { /// whether a mapping falls inside one recorded range would call that merged VMA /// a stray and unmap the restored program's own memory. pub(crate) fn plan_sweep(current: &[MemoryMap], cp: &[MemoryMap]) -> Vec<(u64, u64)> { - // Keep set: every recorded region plus the stub's reserved window, merged - // into disjoint ascending intervals. + let base: Vec<(u64, u64)> = if STUB_BASE > 0 { + vec![(STUB_BASE, STUB_BASE + STUB_SPAN)] + } else { + Vec::new() + }; let mut keep: Vec<(u64, u64)> = cp .iter() .map(|m| (m.start, m.end)) - .chain(std::iter::once((STUB_BASE, STUB_BASE + STUB_SPAN))) + .chain(base) .filter(|(lo, hi)| lo < hi) .collect(); keep.sort_unstable(); @@ -342,36 +353,82 @@ fn to_child_path( } } +/// The kernel's restart sentinels for an aborted restartable syscall: +/// -ERESTARTSYS / -ERESTARTNOINTR / -ERESTARTNOHAND / -ERESTART_RESTARTBLOCK. +/// -515 (ENOIOCTLCMD) is NOT a restart code and must not be matched. +#[cfg_attr( + not(any(target_arch = "x86_64", target_arch = "riscv64")), + allow(dead_code) +)] +fn is_restart_sentinel(v: i64) -> bool { + matches!(v, -512 | -513 | -514 | -516) +} + +/// Return the restart sentinel a riscv64 register file holds in `a0`, if any. +/// +/// On riscv64 `a0` is both the first syscall argument and the return value, so +/// an aborted restartable syscall overwrites the original argument with the +/// sentinel. The original (`orig_a0`) is not part of the ptrace-exposed +/// `user_regs_struct`, so neither capture nor restore can recover it. Callers +/// reject the checkpoint rather than resume with a corrupt return value. +#[cfg(target_arch = "riscv64")] +pub(crate) fn restart_sentinel_in_a0(regs: &[u64]) -> Option { + // riscv64 user_regs_struct order: pc, ra, sp, gp, tp, t0-t2, s0-s1, + // a0-a7, s2-s11, t3-t6 — so a0 is index 10. + const A0: usize = 10; + let a0 = *regs.get(A0)? as i64; + if is_restart_sentinel(a0) { Some(a0) } else { None } +} + /// Re-arm an interrupted, restartable syscall in the saved register file. /// /// When the checkpoint was taken (via `PTRACE_INTERRUPT`) while the process sat -/// in a syscall, the kernel aborted it with a restart sentinel in rax -/// (-ERESTARTSYS / -ERESTARTNOINTR / -ERESTARTNOHAND / -ERESTART_RESTARTBLOCK). -/// At the ptrace stop, rip still points just PAST the `syscall` instruction. The -/// kernel's restart fixup (rewind rip onto the 2-byte `syscall`, reload rax with -/// the original syscall number) normally runs on the syscall-return path, which a -/// restore bypasses. Without it, userspace resumes one instruction past the -/// syscall with the raw sentinel (e.g. -514) in rax and faults. Applying the -/// fixup here re-executes the syscall cleanly with its arguments still in -/// registers (this is what CRIU does). +/// in a syscall, the kernel aborted it with a restart sentinel in the return +/// register. At the ptrace stop the PC still points just PAST the syscall +/// instruction. The kernel's restart fixup (rewind PC, reload the return +/// register with the original syscall number) normally runs on the +/// syscall-return path, which a restore bypasses. Without it, userspace resumes +/// one instruction past the syscall with the raw sentinel (e.g. -514) in the +/// return register and faults. Applying the fixup here re-executes the syscall +/// cleanly with its arguments still in registers (this is what CRIU does). /// -/// -515 (ENOIOCTLCMD) is NOT a restart code and must not be matched. For -/// ERESTART_RESTARTBLOCK (-516) the original syscall is re-run rather than the -/// kernel's `restart_syscall` path (restart_block is not captured), so +/// For ERESTART_RESTARTBLOCK (-516) the original syscall is re-run rather than +/// the kernel's `restart_syscall` path (restart_block is not captured), so /// timeout-bearing syscalls restart with their full original timeout: an /// accepted approximation for fresh-process restore. #[cfg(target_arch = "x86_64")] -fn rearm_restartable_syscall(regs: &mut [u64]) { +fn rearm_restartable_syscall(regs: &mut [u64]) -> Result<(), String> { // x86_64 user_regs_struct layout indices. const RAX: usize = 10; const ORIG_RAX: usize = 15; const RIP: usize = 16; if let (Some(&rax), Some(&orig_rax)) = (regs.get(RAX), regs.get(ORIG_RAX)) { - if matches!(rax as i64, -512 | -513 | -514 | -516) { + if is_restart_sentinel(rax as i64) { regs[RAX] = orig_rax; regs[RIP] = regs[RIP].wrapping_sub(2); } } + Ok(()) +} + +/// riscv64 re-arm is deliberately not implemented (see `restart_sentinel_in_a0`): +/// a correct restart needs the original `a0` argument, which ptrace does not +/// expose. On kernels ≥ 6.6 the kernel has already applied the restart fixup +/// before the ptrace stop, so `a0` is the original argument and this check is a +/// no-op. On older kernels the sentinel is visible here, and rejecting is the +/// only safe answer — resuming would hand userspace a raw `-ERESTART*` as the +/// syscall result. +#[cfg(target_arch = "riscv64")] +fn rearm_restartable_syscall(regs: &mut [u64]) -> Result<(), String> { + if let Some(sentinel) = restart_sentinel_in_a0(regs) { + return Err(format!( + "checkpoint captured an interrupted restartable syscall \ + (a0 = {sentinel}); riscv64 cannot recover its original argument, \ + so restore would resume with a corrupt return value. Retry the \ + checkpoint while the workload is not blocked in a syscall" + )); + } + Ok(()) } /// Build the FP image the stub points the signal frame's `fpstate` at. @@ -440,13 +497,29 @@ fn build_fpstate_image(fpregs: &[u8]) -> Vec { img } -#[cfg(not(target_arch = "x86_64"))] +/// Build the FP image for the riscv64 signal frame. Unlike x86_64, riscv64 has +/// no xstate/magic framing — the kernel stores the FPU context inline in +/// `uc.uc_mcontext.__fpregs` as a raw `struct __riscv_d_ext_state` (or +/// `__riscv_f_ext_state` for single-precision). The stub copies it verbatim into +/// the ucontext's fp slot; the kernel reads it back from the signal frame on +/// rt_sigreturn. +#[cfg(target_arch = "riscv64")] +fn build_fpstate_image(fpregs: &[u8]) -> Vec { + if fpregs.is_empty() { + return Vec::new(); + } + fpregs.to_vec() +} + +#[cfg(not(any(target_arch = "x86_64", target_arch = "riscv64")))] fn build_fpstate_image(_fpregs: &[u8]) -> Vec { Vec::new() } -#[cfg(not(target_arch = "x86_64"))] -fn rearm_restartable_syscall(_regs: &mut [u64]) {} +#[cfg(not(any(target_arch = "x86_64", target_arch = "riscv64")))] +fn rearm_restartable_syscall(_regs: &mut [u64]) -> Result<(), String> { + Ok(()) +} /// Interns NUL-terminated strings into the blob's string table, deduplicating /// repeats (a multi-segment ELF mapping names the same file once per segment). @@ -485,24 +558,24 @@ pub(crate) fn plan( let regions = build_memory_plan(&ps.memory_maps, &ps.memory_data); // The stub's own text/data/bss/stack live at a fixed far base. A checkpoint - // that occupies that window would have its region mapped over the running - // stub, so refuse rather than crash mid-restore. - if let Some(r) = regions - .iter() - .find(|r| r.start() < STUB_BASE + STUB_SPAN && STUB_BASE < r.end()) - { - return Err(format!( - "checkpoint region {:#x}-{:#x} overlaps the restore-stub's reserved \ - window {:#x}-{:#x}", - r.start(), r.end(), STUB_BASE, STUB_BASE + STUB_SPAN, - )); + if STUB_BASE > 0 { + if let Some(r) = regions + .iter() + .find(|r| r.start() < STUB_BASE + STUB_SPAN && STUB_BASE < r.end()) + { + return Err(format!( + "checkpoint region {:#x}-{:#x} overlaps the restore-stub's reserved \ + window {:#x}-{:#x}", + r.start(), r.end(), STUB_BASE, STUB_BASE + STUB_SPAN, + )); + } } let (restorable_fds, skipped) = build_fd_plan(&cp.fd_table); let vdso = plan_vdso_moves(&ps.memory_maps); let mut regs = ps.regs.clone(); - rearm_restartable_syscall(&mut regs); + rearm_restartable_syscall(&mut regs)?; let fpstate = build_fpstate_image(&ps.fpregs); let mut strings = StringTable::default(); @@ -595,6 +668,35 @@ mod tests { MemoryMap { start, end, perms: "rw-p".into(), offset: 0, path: path.map(Into::into) } } + #[test] + fn restart_sentinel_matches_only_restart_codes() { + assert!(is_restart_sentinel(-512)); // ERESTARTSYS + assert!(is_restart_sentinel(-513)); // ERESTARTNOINTR + assert!(is_restart_sentinel(-514)); // ERESTARTNOHAND + assert!(is_restart_sentinel(-516)); // ERESTART_RESTARTBLOCK + assert!(!is_restart_sentinel(-515)); // ENOIOCTLCMD, not a restart code + assert!(!is_restart_sentinel(0)); + assert!(!is_restart_sentinel(-1)); // EPERM + } + + #[test] + #[cfg(target_arch = "riscv64")] + fn riscv64_rearm_rejects_an_in_flight_restartable_syscall() { + // riscv64 user_regs_struct order: a0 is index 10. + let mut regs = vec![0u64; 32]; + + regs[10] = (-514i64) as u64; // ERESTARTNOHAND + let err = rearm_restartable_syscall(&mut regs) + .expect_err("a restart sentinel in a0 must be rejected"); + assert!(err.contains("restartable"), "names the cause: {err}"); + + regs[10] = (-515i64) as u64; // ENOIOCTLCMD is not a restart code + rearm_restartable_syscall(&mut regs).expect("ENOIOCTLCMD must not be rejected"); + + regs[10] = 7; // ordinary return value + rearm_restartable_syscall(&mut regs).expect("a normal return value passes"); + } + #[test] fn fd_plan_keeps_regular_files_only() { let fds = vec![ @@ -692,6 +794,7 @@ mod tests { assert!(verify_special_mappings(¤t, &cp).is_ok()); } + #[cfg(any(target_arch = "x86_64", target_arch = "riscv64"))] #[test] fn sweep_removes_a_leftover_stack_but_spares_the_image_and_the_stub() { // The layout the stub is in at READY: the checkpoint's regions, the @@ -837,7 +940,7 @@ mod tests { let strings_len = u32::from_le_bytes(blob[48..52].try_into().unwrap()) as usize; assert_eq!(&blob[strings_off..strings_off + strings_len], b"/bin/app\0"); } - + #[cfg(any(target_arch = "x86_64", target_arch = "riscv64"))] #[test] fn plan_rejects_a_checkpoint_overlapping_the_stub_window() { let cp = tiny_checkpoint( @@ -860,7 +963,7 @@ mod tests { regs[10] = (-514i64) as u64; // rax regs[15] = 34; // orig_rax regs[16] = 0x4010_0000; // rip, just past the `syscall` - rearm_restartable_syscall(&mut regs); + rearm_restartable_syscall(&mut regs).unwrap(); assert_eq!(regs[10], 34, "rax reloaded with the original syscall number"); assert_eq!(regs[16], 0x4010_0000 - 2, "rip rewound onto the 2-byte syscall"); } @@ -873,11 +976,15 @@ mod tests { regs[10] = (-515i64) as u64; regs[15] = 34; regs[16] = 0x4010_0000; - rearm_restartable_syscall(&mut regs); + rearm_restartable_syscall(&mut regs).unwrap(); assert_eq!(regs[10], (-515i64) as u64); assert_eq!(regs[16], 0x4010_0000); } + // riscv64 has no re-arm to test: it rejects a restart sentinel instead (see + // `riscv64_rearm_rejects_an_in_flight_restartable_syscall` above), so only + // x86_64 exercises the rewind here. + #[test] #[cfg(target_arch = "x86_64")] fn fpstate_image_frames_a_full_xstate_for_xrstor() { @@ -930,6 +1037,19 @@ mod tests { assert!(img[464..512].iter().all(|&b| b == 0), "sw_reserved cleared"); } + #[test] + #[cfg(target_arch = "riscv64")] + fn fpstate_image_passes_through_raw_fpregs() { + // riscv64 has no xstate framing: the kernel stores + // __riscv_d_ext_state directly in sc_fpregs. build_fpstate_image + // returns the capture verbatim so the stub copies it as-is into the + // ucontext's fp slot. + let fp = vec![0xA5u8; 264]; + let img = build_fpstate_image(&fp); + assert_eq!(img, fp, "riscv64 fpstate is a raw passthrough"); + assert_eq!(img.len(), 264); + } + #[test] fn fpstate_image_empty_when_nothing_was_captured() { assert!(build_fpstate_image(&[]).is_empty()); diff --git a/crates/sandlock-core/src/checkpoint/resume.rs b/crates/sandlock-core/src/checkpoint/resume.rs index 7c7b9cce..90528e25 100644 --- a/crates/sandlock-core/src/checkpoint/resume.rs +++ b/crates/sandlock-core/src/checkpoint/resume.rs @@ -394,7 +394,7 @@ mod tests { /// it. If the two ever drift apart, a restore silently maps a checkpoint /// region over the running stub instead of being refused. #[test] - #[cfg(target_arch = "x86_64")] + #[cfg(any(target_arch = "x86_64", target_arch = "riscv64"))] fn stub_links_at_the_reserved_base() { use crate::checkpoint::restore_blob::{STUB_BASE, STUB_SPAN}; @@ -572,4 +572,138 @@ mod tests { ); assert_eq!(got[0], SENTINEL, "the restored program ran from its checkpoint rip"); } + + /// End-to-end proof for riscv64: same protocol as the x86_64 test above, + /// but with riscv64 machine code (a7+ecall convention), a 32-register file, + /// and addresses within the Sv39 256 GiB user-space ceiling. + #[test] + #[cfg(target_arch = "riscv64")] + fn restore_stub_reconstructs_a_synthetic_image() { + use crate::checkpoint::{Checkpoint, MemoryMap, MemorySegment, ProcessState}; + use crate::checkpoint::restore_blob; + + // riscv64 Sv39 gives 256 GiB of user virtual space, so the addresses + // must stay below 0x40_0000_0000. Pick a region that stays clear of + // the stub (0x30_0000_0000) and the vDSO (near the top). + const CODE: u64 = 0x2000_0000; + const STACK: u64 = 0x2001_0000; + const OUT_FD: i32 = 10; // sentinel pipe write end, inherited by the child + const SENTINEL: u8 = 0x5A; + const PAGE: u64 = 0x1000; + + let stub = stub_path(); + if !stub.exists() { + eprintln!("skip: restore-stub not built ({})", stub.display()); + return; + } + + // riscv64: write(OUT_FD, CODE+64, 1); exit(write-retval). + // + // addi a0, zero, OUT_FD # a0 = fd + // lui a1, 0x20000 # upper 20 bits of CODE+64 + // addi a1, a1, 0x040 # lower 12 bits of CODE+64 + // addi a2, zero, 1 # count + // addi a7, zero, 64 # __NR_write + // ecall + // addi a7, zero, 93 # __NR_exit (a0 still holds write's ret) + // ecall + let mut code_page = vec![0u8; PAGE as usize]; + { + let c = &mut code_page; + let mut w = 0usize; + let mut put = |bytes: &[u8]| { c[w..w + bytes.len()].copy_from_slice(bytes); w += bytes.len(); }; + put(&0x00A00513u32.to_le_bytes()); // addi a0, zero, 10 + put(&0x200005B7u32.to_le_bytes()); // lui a1, 0x20000 + put(&0x04058593u32.to_le_bytes()); // addi a1, a1, 0x40 + put(&0x00100613u32.to_le_bytes()); // addi a2, zero, 1 + put(&0x04000893u32.to_le_bytes()); // addi a7, zero, 64 + put(&0x00000073u32.to_le_bytes()); // ecall + put(&0x05D00893u32.to_le_bytes()); // addi a7, zero, 93 + put(&0x00000073u32.to_le_bytes()); // ecall + } + code_page[64] = SENTINEL; + + // riscv64 user_regs_struct: 32 × u64. + // Index 0=pc, 2=sp; all others zero. + let mut regs = vec![0u64; 32]; + regs[0] = CODE; // pc + regs[2] = STACK + 0xF00; // sp + + // The code page is r-x in the checkpoint, so the stub has to map it + // writable for the fill and mprotect it back before handing control over. + let cp = Checkpoint { + name: String::new(), + policy: crate::Sandbox::builder().build().unwrap(), + process_state: ProcessState { + pid: 0, + cwd: "/".into(), + exe: String::new(), + regs, + fpregs: Vec::new(), + memory_maps: vec![ + MemoryMap { start: CODE, end: CODE + PAGE, perms: "r-xp".into(), offset: 0, path: None }, + MemoryMap { start: STACK, end: STACK + PAGE, perms: "rw-p".into(), offset: 0, path: None }, + ], + memory_data: vec![ + MemorySegment { start: CODE, data: code_page }, + MemorySegment { start: STACK, data: vec![0u8; PAGE as usize] }, + ], + }, + fd_table: Vec::new(), + cow_snapshot: None, + app_state: None, + }; + + let plan = restore_blob::plan(&cp, None, &[]).expect("plan"); + let channel = StubChannel::new(&plan.blob).expect("channel"); + + let stub_path = std::ffi::CString::new(stub.to_str().unwrap()).unwrap(); + + let mut pipefd = [0i32; 2]; + assert_eq!(unsafe { libc::pipe(pipefd.as_mut_ptr()) }, 0); + let pipe_r = relocate_above(pipefd[0], OUT_FD + 1).expect("relocate pipe read end"); + let pipe_w = relocate_above(pipefd[1], OUT_FD + 1).expect("relocate pipe write end"); + let (pipe_r, pipe_w) = (pipe_r.into_raw_fd(), pipe_w.into_raw_fd()); + + let (ctrl, ready, go) = + (channel.ctrl.as_raw_fd(), channel.ready.as_raw_fd(), channel.go_r.as_raw_fd()); + let child = unsafe { libc::fork() }; + assert!(child >= 0, "fork"); + if child == 0 { + unsafe { + libc::dup2(ctrl, CTRL_FD); + libc::dup2(ready, READY_FD); + libc::dup2(go, GO_FD); + libc::dup2(pipe_w, OUT_FD); + let argv = [stub_path.as_ptr(), std::ptr::null()]; + let envp = [std::ptr::null()]; + libc::execve(stub_path.as_ptr(), argv.as_ptr(), envp.as_ptr()); + libc::_exit(127); + } + } + unsafe { libc::close(pipe_w) }; + + let restored = finish_restore(child, &channel, &plan); + + let mut got = [0u8; 1]; + let n = if restored.is_ok() && wait_readable(pipe_r, 5000).unwrap_or(false) { + unsafe { libc::read(pipe_r, got.as_mut_ptr() as *mut libc::c_void, 1) } + } else { + 0 + }; + let stalled_in = std::fs::read_to_string(format!("/proc/{child}/syscall")) + .unwrap_or_else(|e| e.to_string()); + unsafe { libc::kill(child, libc::SIGKILL) }; + let mut st = 0i32; + unsafe { libc::waitpid(child, &mut st, 0) }; + unsafe { libc::close(pipe_r) }; + + restored.expect("finish_restore"); + assert_eq!( + n, 1, + "restored code must write exactly one sentinel byte; child exit status \ + {st:#x} (payload exits with write()'s return value), /proc syscall {stalled_in}", + ); + assert_eq!(got[0], SENTINEL, "the restored program ran from its checkpoint pc"); + } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index fa519dc1..34159ac1 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1072,7 +1072,10 @@ impl Sandbox { /// [`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. + /// x86_64 and riscv64 restore engines supported. A riscv64 checkpoint taken + /// while the process was blocked in a restartable syscall (nanosleep, futex, + /// read, ...) is rejected: its original first argument is not recoverable + /// from the register file, so that resume cannot be made correct. /// /// The kernel vDSO is relocated onto the checkpoint-recorded base during /// restore, so ordinary libc/glibc programs that call vDSO functions (e.g. @@ -1087,9 +1090,9 @@ impl Sandbox { use crate::checkpoint::{restore_blob, resume}; use crate::error::SandboxRuntimeError; - if cfg!(not(target_arch = "x86_64")) { + if cfg!(not(any(target_arch = "x86_64", target_arch = "riscv64"))) { return Err(SandboxRuntimeError::Child( - "checkpoint restore is only implemented on x86_64".into(), + "checkpoint restore is only implemented on x86_64 and riscv64".into(), ) .into()); } diff --git a/crates/sandlock-core/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index f4ac4372..83a26605 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -16,7 +16,13 @@ fn helper_binary() -> PathBuf { /// The address range the restore-stub's own image is linked into. Must match /// `checkpoint::restore_blob::STUB_BASE`/`STUB_SPAN`, which is crate-private; /// `stub_links_at_the_reserved_base` guards the constant against the binary. +/// x86_64 uses 3 TiB; riscv64 uses 192 GiB (below Sv39 ceiling). +#[cfg(target_arch = "x86_64")] const STUB_BASE: u64 = 0x300_0000_0000; +#[cfg(target_arch = "riscv64")] +const STUB_BASE: u64 = 0x30_0000_0000; +#[cfg(not(any(target_arch = "x86_64", target_arch = "riscv64")))] +const STUB_BASE: u64 = 0; const STUB_SPAN: u64 = 0x40_0000; /// Parse `/proc//maps` into `(start, end, path)` triples. @@ -54,8 +60,8 @@ fn read_maps(pid: i32) -> Vec<(u64, u64, String)> { /// stack and heap stayed mapped and reachable. #[tokio::test] async fn test_restore_glibc_vdso_program_resumes() { - if cfg!(not(target_arch = "x86_64")) { - eprintln!("skipping: the restore engine is x86_64-only"); + if cfg!(not(any(target_arch = "x86_64", target_arch = "riscv64"))) { + eprintln!("skipping: the restore engine is x86_64/riscv64 only"); return; } diff --git a/crates/sandlock-ffi/tests/restore.rs b/crates/sandlock-ffi/tests/restore.rs index 4fc5504c..aed48fed 100644 --- a/crates/sandlock-ffi/tests/restore.rs +++ b/crates/sandlock-ffi/tests/restore.rs @@ -90,7 +90,7 @@ fn read_counter(path: &str) -> Option { #[test] fn restore_interactive_resumes_via_c_abi() { if cfg!(not(target_arch = "x86_64")) { - eprintln!("skipping: checkpoint restore is x86_64-only"); + eprintln!("skipping: this test's counter program is x86_64-only"); return; } let cc = if which("cc") { diff --git a/crates/sandlock-oci/tests/integration.rs b/crates/sandlock-oci/tests/integration.rs index 4c588a81..032c75d4 100644 --- a/crates/sandlock-oci/tests/integration.rs +++ b/crates/sandlock-oci/tests/integration.rs @@ -253,7 +253,7 @@ void _start(void){{ #[tokio::test(flavor = "multi_thread")] async fn oci_restore_resumes_vdso_free_program() { if cfg!(not(target_arch = "x86_64")) { - eprintln!("skipping: checkpoint restore is x86_64-only"); + eprintln!("skipping: this test is x86_64-only (counter program)"); return; } if sandlock_core::landlock_abi_version().is_err() { @@ -415,7 +415,7 @@ fn build_counter(bin: &Path, src: &Path, out_path: &str) -> bool { #[tokio::test(flavor = "multi_thread")] async fn oci_checkpoint_of_running_container() { if cfg!(not(target_arch = "x86_64")) { - eprintln!("skipping: checkpoint/restore is x86_64-only"); + eprintln!("skipping: this test is x86_64-only (counter program)"); return; } if sandlock_core::landlock_abi_version().is_err() { diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 37a69ac0..c7534a63 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -1315,7 +1315,7 @@ def restore_interactive(self, cp: "Checkpoint") -> None: could not be transparently restored are reported by :attr:`restore_skipped`. - x86_64 only. The checkpoint is rebuilt by ``execve``-ing a + x86_64 and riscv64. The checkpoint is rebuilt by ``execve``-ing a freestanding restore stub into a fresh, already-confined process, so the restored program gets an address space holding only its own image and a fresh kernel vDSO. The vDSO is relocated onto the diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index fe7aad20..a1c856b3 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -214,7 +214,7 @@ def test_load_restore_fn_without_app_state_not_called( 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("checkpoint restore is x86_64-only") + pytest.skip("this test is x86_64-only (counter program)") cc = shutil.which("cc") or shutil.which("gcc") if cc is None: pytest.skip("no C compiler (cc/gcc) available")