From c09031efb8290db1457e7eab358fd4fdcae0bd63 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Fri, 7 Aug 2026 13:17:10 +0000 Subject: [PATCH 1/9] checkpoint: port restore engine to riscv64 Add riscv64 support to the checkpoint/restore path: - restore_blob.rs: rearm_restartable_syscall (a0 return, a7 syscall_nr, 4-byte ecall rewind) and build_fpstate_image (raw fpregs passthrough) - restore-stub.c: full riscv64 #ifdef port with signal frame layout, syscall numbers, sc6 wrapper, rt_sigreturn, and _start entry - sandbox.rs: relax restore gate to include riscv64 - build.rs: detect riscv64 target and try cross-compilers - resume.rs: generalize stub_links_at_the_reserved_base cfg gate and add riscv64 synthetic-image restore test --- crates/sandlock-core/build.rs | 23 ++- .../src/checkpoint/restore-stub.c | 157 ++++++++++++++++-- .../src/checkpoint/restore_blob.rs | 51 ++++-- crates/sandlock-core/src/checkpoint/resume.rs | 136 ++++++++++++++- crates/sandlock-core/src/sandbox.rs | 4 +- 5 files changed, 343 insertions(+), 28 deletions(-) diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index f2622896..5e39753e 100644 --- a/crates/sandlock-core/build.rs +++ b/crates/sandlock-core/build.rs @@ -27,13 +27,32 @@ 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"); + let target = std::env::var("TARGET").unwrap_or_default(); + let (ccs, warn) = if target.starts_with("riscv64") + || target.starts_with("riscv64gc") + { + ( + &["riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "cannot compile restore-stub for riscv64: its restore tests will be \ + skipped. Install a riscv64 cross-compiler (e.g. riscv64-linux-gnu-gcc).", + ) + } else { + ( + &["cc"][..], + "cannot compile restore-stub: its restore tests will be skipped.", + ) + }; build_static( &stub_src, &stub_bin, - &["cc"], + ccs, &[ "-static", "-nostdlib", @@ -47,7 +66,7 @@ fn main() { "-fno-tree-loop-distribute-patterns", "-Wl,-Ttext-segment=0x30000000000", ], - "cannot compile restore-stub: its restore tests will be skipped.", + warn, ); // Emit the path every run (rustc-env is not cached across build-script runs), // whether or not the binary was just (re)built. diff --git a/crates/sandlock-core/src/checkpoint/restore-stub.c b/crates/sandlock-core/src/checkpoint/restore-stub.c index 2a8ec089..9e5e780d 100644 --- a/crates/sandlock-core/src/checkpoint/restore-stub.c +++ b/crates/sandlock-core/src/checkpoint/restore-stub.c @@ -1,5 +1,5 @@ /* - * restore-stub: freestanding self-restore stub (x86_64). + * restore-stub: freestanding self-restore stub (x86_64, riscv64). * * This is a core component of the checkpoint restore engine, not a test * fixture: the supervisor execs this stub into a fresh, fully-sandboxed process @@ -21,7 +21,8 @@ * 6. mprotect the anonymous regions down to their checkpointed protections; * 7. unmap the leftovers of its own startup that the image did not overwrite; * 8. reopen the fd table at its saved numbers and offsets; - * 9. restore the thread pointer, which the signal frame cannot carry; + * 9. (x86_64) restore fs_base/gs_base via arch_prctl; (riscv64) tp is carried + * in the signal frame gregs, so nothing to do; * 10. rt_sigreturn into the checkpoint's register context. * * Two address-space hazards drive the layout, and both are why this file avoids @@ -48,8 +49,8 @@ * * Exit codes (all _exit): 2 blob read, 3 bad magic/version/size, 4 map region, * 5 open region file, 6 ready write, 7 go read, 8 mprotect, 9 vdso mremap, - * 10 fd reopen, 12 sweep entry overlapping the stub's own image, 13 arch_prctl. - * rt_sigreturn does not return; if it does, exit 11. + * 10 fd reopen, 12 sweep entry overlapping the stub's own image, 13 arch_prctl + * (x86_64 only). rt_sigreturn does not return; if it does, exit 11. */ #define CTRL_FD 3 #define READY_FD 4 @@ -61,6 +62,21 @@ #define STUB_BASE 0x30000000000UL #define STUB_SPAN 0x400000UL +#ifdef __riscv +#define SYS_read 63 +#define SYS_write 64 +#define SYS_close 57 +#define SYS_lseek 62 +#define SYS_mmap 222 +#define SYS_mprotect 226 +#define SYS_munmap 215 +#define SYS_mremap 216 +#define SYS_dup3 24 +#define SYS_exit 93 +#define SYS_openat 56 +#define SYS_rt_sigreturn 139 +/* No SYS_arch_prctl on riscv64 — tp is in the signal frame gregs. */ +#elif __x86_64__ #define SYS_read 0 #define SYS_write 1 #define SYS_close 3 @@ -74,9 +90,15 @@ #define SYS_openat 257 #define SYS_rt_sigreturn 15 #define SYS_arch_prctl 158 +#else +#error "unsupported architecture" +#endif +/* x86_64 only: thread-pointer restore constants. */ +#ifdef __x86_64__ #define ARCH_SET_GS 0x1001 #define ARCH_SET_FS 0x1002 +#endif #define PROT_READ 0x1 #define PROT_WRITE 0x2 @@ -93,8 +115,13 @@ * bytes per mapping and the string table one copy of each distinct mapped * path. Rust fails the restore rather than truncating if a blob exceeds it. */ #define CTRL_MAX (1 << 20) -/* Upper bound on a signal-frame FP image: AMX-sized xstate plus magic2. */ +/* Upper bound on a signal-frame FP image. x86_64: AMX-sized xstate plus + * magic2. riscv64: 544 bytes for Q-extension __riscv_fp_state. */ +#if defined(__x86_64__) #define FP_MAX 16384 +#elif defined(__riscv) +#define FP_MAX 544 +#endif #define STACK_SIZE 65536 /* Leftover mappings the supervisor may ask the stub to unmap. A freshly * execve'd stub has only its own image and its startup stack, so this is far @@ -110,6 +137,7 @@ typedef unsigned long u64; typedef unsigned int u32; typedef long i64; +#ifdef __x86_64__ static i64 sc6(long n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f) { i64 r; register u64 r10 __asm__("r10") = d; @@ -120,6 +148,21 @@ static i64 sc6(long n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f) { : "rcx", "r11", "memory"); return r; } +#elif defined(__riscv) +static i64 sc6(long n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f) { + register long nr __asm__("a7") = n; + register u64 a0 __asm__("a0") = a; + register u64 a1 __asm__("a1") = b; + register u64 a2 __asm__("a2") = c; + register u64 a3 __asm__("a3") = d; + register u64 a4 __asm__("a4") = e; + register u64 a5 __asm__("a5") = f; + __asm__ volatile("ecall" : "+r"(a0) + : "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5), "r"(nr) + : "memory"); + return (i64)a0; +} +#endif #define SC1(n,a) sc6(n,(u64)(a),0,0,0,0,0) #define SC2(n,a,b) sc6(n,(u64)(a),(u64)(b),0,0,0,0) #define SC3(n,a,b,c) sc6(n,(u64)(a),(u64)(b),(u64)(c),0,0,0) @@ -144,8 +187,8 @@ void *memcpy(void *d, const void *s, unsigned long n) { return d; } -/* Blob layout mirror (little-endian; we run on x86_64 LE so struct reads work). - * Must match checkpoint/restore_blob.rs byte for byte. */ +/* Blob layout mirror (little-endian; both x86_64 and riscv64 are LE, so native + * struct reads work). Must match checkpoint/restore_blob.rs byte for byte. */ struct blob_header { u32 magic, version, n_regions, n_fds; u64 regs_off; u32 regs_len, fpstate_len; @@ -165,11 +208,15 @@ struct blob_vdso { i64 delta; u64 len; u64 target; }; #define SRC_ANON 0 #define SRC_FILE 1 -/* ---- x86_64 rt_sigreturn frame ------------------------------------------- - * rt_sigreturn reads the ucontext at rsp (kernel does frame = rsp - 8; uc is at - * frame+8 = rsp). We build a ucontext, set rsp to &uc, and syscall rt_sigreturn. - * mcontext gregs order (x86_64): see REG_* below. - */ +/* ---- Architecture-specific signal frame layouts -------------------------- + * The stub builds the frame on its private stack and rt_sigreturns into it. + * Registers in the blob are in ptrace order (capture::ptrace_getregs). */ + +#ifdef __x86_64__ + +/* x86_64: rt_sigreturn reads the ucontext at rsp (kernel does frame = rsp - 8; + * uc is at frame+8 = rsp). We build a ucontext, set rsp to &uc, and syscall + * rt_sigreturn. mcontext gregs order: see REG_* below. */ enum { R8=0,R9,R10,R11,R12,R13,R14,R15,RDI,RSI,RBP,RBX,RDX,RAX,RCX,RSP,RIP, EFL,CSGSFS,ERR,TRAPNO,OLDMASK,CR2 }; /* 23 gregs */ struct sigctx { u64 gregs[23]; u64 fpstate; u64 reserved[8]; }; @@ -193,11 +240,47 @@ enum { UR_R15=0,UR_R14,UR_R13,UR_R12,UR_RBP,UR_RBX,UR_R11,UR_R10,UR_R9,UR_R8, UR_RAX,UR_RCX,UR_RDX,UR_RSI,UR_RDI,UR_ORIG_RAX,UR_RIP,UR_CS,UR_EFLAGS, UR_RSP,UR_SS,UR_FS_BASE,UR_GS_BASE,UR_DS,UR_ES,UR_FS,UR_GS }; +#elif defined(__riscv) + +/* riscv64: rt_sigreturn reads frame at sp = (struct rt_sigframe *)sp. + * siginfo (128 bytes) + ucontext. uc_mcontext is at uc+0xA8 (168). + * sc_regs[32] at sigcontext+0x00, sc_fpregs at sigcontext+0x100. + * The gp[] blob order is ptrace: pc=0, ra=1, sp=2, gp=3, tp=4, t0-t2=5-7, + * s0-s1=8-9, a0-a7=10-17, s2-s11=18-27, t3-t6=28-31 — a 1:1 mapping to + * sc_regs[32], so no remap is needed. tp (thread pointer) is carried by the + * signal frame's sc_regs[4]; nothing needs arch_prctl. */ +struct sigctx { + u64 gregs[32]; /* sc_regs: 32 gregs, 256 bytes */ + u8 fpregs[544]; /* sc_fpregs: union __riscv_fp_state (max Q ext) */ +}; + +struct uctx { + u64 uc_flags; /* 0x00 */ + u64 uc_link; /* 0x08 */ + u64 ss_sp; /* 0x10 */ + u32 ss_flags; /* 0x18 */ + u32 _pad; /* 0x1C */ + u64 ss_size; /* 0x20 */ + u64 uc_sigmask; /* 0x28 */ + u8 __unused[120]; /* 0x30 */ + struct sigctx mc; /* 0xA8 (168) */ +}; + +/* rt_sigframe: struct siginfo (zeroed, 128 bytes) + ucontext. */ +struct rt_sf { + u8 info[128]; + struct uctx uc; +}; + +#endif + /* These all live in .bss at STUB_BASE, out of reach of any MAP_FIXED region. * stub_stack is global so the module-level asm below can reference it. */ static char ctrl_buf[CTRL_MAX] __attribute__((aligned(16))); +#ifdef __x86_64__ /* xrstor requires the signal frame's FP image to be 64-byte aligned. */ static char fp_buf[FP_MAX] __attribute__((aligned(64))); +#endif /* Interleaved (start, len) pairs of the mappings to shed. */ static u64 sweep[MAX_SWEEP * 2]; char stub_stack[STACK_SIZE]; @@ -356,12 +439,18 @@ static void _start_c(u64 *sp) { i64 fd = SC4(SYS_openat, AT_FDCWD, strings + f->path_off, f->flags, 0); if (fd < 0) die(10); if ((u32)fd != f->fd) { +#ifdef __riscv + /* 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 +462,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 +505,33 @@ static void _start_c(u64 *sp) { : : "r"(&uc), "r"(rax) : "memory"); + +#elif defined(__riscv) + /* 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) { + if (h->fpstate_len > sizeof(sf.uc.mc.fpregs)) + h->fpstate_len = (u32)sizeof(sf.uc.mc.fpregs); + 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 +539,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 +551,17 @@ __asm__( " call _start_c\n" " hlt\n" ); +#elif defined(__riscv) +/* 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..db6252c1 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -345,15 +345,15 @@ fn to_child_path( /// 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 (-ERESTARTSYS / -ERESTARTNOINTR / -ERESTARTNOHAND / +/// -ERESTART_RESTARTBLOCK). At the ptrace stop, the PC still points just PAST +/// the syscall instruction. The kernel's restart fixup (rewind PC, reload 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 @@ -374,6 +374,21 @@ fn rearm_restartable_syscall(regs: &mut [u64]) { } } +#[cfg(target_arch = "riscv64")] +fn rearm_restartable_syscall(regs: &mut [u64]) { + // riscv64 user_regs_struct layout indices (32 u64): + // pc, ra, sp, gp, tp, t0-t2, s0-s1, a0-a7, s2-s11, t3-t6 + const A0: usize = 10; + const A7: usize = 17; + const PC: usize = 0; + if let (Some(&a0), Some(&a7)) = (regs.get(A0), regs.get(A7)) { + if matches!(a0 as i64, -512 | -513 | -514 | -516) { + regs[A0] = a7; // restore original syscall number + regs[PC] = regs[PC].wrapping_sub(4); // rewind past ecall (4 bytes) + } + } +} + /// Build the FP image the stub points the signal frame's `fpstate` at. /// /// The kernel decides between `xrstor` and legacy `fxrstor` by reading a @@ -440,12 +455,26 @@ 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"))] +#[cfg(not(any(target_arch = "x86_64", target_arch = "riscv64")))] fn rearm_restartable_syscall(_regs: &mut [u64]) {} /// Interns NUL-terminated strings into the blob's string table, deduplicating diff --git a/crates/sandlock-core/src/checkpoint/resume.rs b/crates/sandlock-core/src/checkpoint/resume.rs index 7c7b9cce..960277e7 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 (0x300_0000_0000) and the vDSO (near the top). + const CODE: u64 = 0x200_0000_0000; + const STACK: u64 = 0x200_0001_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..3063deef 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1087,9 +1087,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()); } From ab2ff32731e165f92bacf357321e63a857f45017 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Fri, 7 Aug 2026 13:26:29 +0000 Subject: [PATCH 2/9] checkpoint: add riscv64 test equivalents for rearm, fpstate, and restore gate Add three riscv64 unit tests in restore_blob.rs that mirror the existing x86_64 tests: restart_sentinel_rewinds_pc_onto_the_ecall (a0=-514, a7=34, pc rewound by 4), non_restart_errno_is_left_alone (-515 ignored), and fpstate_image_passes_through_raw_fpregs (identity passthrough). Relax the runtime gate in test_restore_glibc_vdso_program_resumes from cfg!(not(x86_64)) to cfg!(not(any(x86_64, riscv64))) so the integration test runs on riscv64 as well. --- .../src/checkpoint/restore_blob.rs | 40 +++++++++++++++++++ .../tests/integration/test_restore.rs | 4 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/sandlock-core/src/checkpoint/restore_blob.rs b/crates/sandlock-core/src/checkpoint/restore_blob.rs index db6252c1..7253905b 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -907,6 +907,33 @@ mod tests { assert_eq!(regs[16], 0x4010_0000); } + #[test] + #[cfg(target_arch = "riscv64")] + fn restart_sentinel_rewinds_pc_onto_the_ecall() { + // a0 = -ERESTARTNOHAND with a7 = pause(34): the restored context + // must re-enter the syscall rather than resume past it with the sentinel. + let mut regs = vec![0u64; 32]; + regs[10] = (-514i64) as u64; // a0 + regs[17] = 34; // a7 (original syscall number) + regs[0] = 0x4010_0000; // pc, just past the `ecall` + rearm_restartable_syscall(&mut regs); + assert_eq!(regs[10], 34, "a0 reloaded with the original syscall number"); + assert_eq!(regs[0], 0x4010_0000 - 4, "pc rewound onto the 4-byte ecall"); + } + + #[test] + #[cfg(target_arch = "riscv64")] + fn non_restart_errno_is_left_alone() { + // -515 (ENOIOCTLCMD) looks like a sentinel but is not one. + let mut regs = vec![0u64; 32]; + regs[10] = (-515i64) as u64; + regs[17] = 34; + regs[0] = 0x4010_0000; + rearm_restartable_syscall(&mut regs); + assert_eq!(regs[10], (-515i64) as u64); + assert_eq!(regs[0], 0x4010_0000); + } + #[test] #[cfg(target_arch = "x86_64")] fn fpstate_image_frames_a_full_xstate_for_xrstor() { @@ -959,6 +986,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/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index f4ac4372..e404e190 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -54,8 +54,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; } From 87aa834d8887fd51d114432b6a2fe87664bbd5c4 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 8 Aug 2026 10:38:40 +0000 Subject: [PATCH 3/9] fix(checkpoint): address all PR #192 riscv64 review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore-stub.c: - add missing `typedef unsigned char u8;` - fix uc_mcontext offset: 0xA8→0xB0 (sigcontext is 16-aligned, widen __unused 120→128) - guard __riscv with __riscv_xlen==64 so RV32 is not silently miscompiled - fix FP_MAX 544→516 (last safe byte before sc_extdesc.reserved); fpregs array 544→528 (kernel union size); remove dead clamp - make STUB_BASE arch-conditional: riscv64 uses 0x3000000000 (192 GiB, below Sv39 ceiling) restore_blob.rs: - remove the broken riscv64 rearm_restartable_syscall that wrote a7 into a0 (corrupting the restarted call). orig_a0 is not recoverable from ptrace_getregs on riscv64. Fall through to the no-op fallback instead. - remove the unit tests that asserted the wrong behaviour - split STUB_BASE per-arch (x86_64 0x300_0000_0000, riscv64 0x30_0000_0000) build.rs: - when HOST is riscv64, include cc in the compiler search list so a native build finds the host compiler - pass the correct -Wl,-Ttext-segment= per architecture resume.rs: - fix CODE constant (2 TiB→0x2000_0000) and STACK constant so the synthetic-image test addresses stay within Sv39 test_restore.rs: split STUB_BASE per-arch sandbox.rs, sandbox.py: update comments from "x86_64 only" to acknowledge riscv64 support test files (restore.rs, integration.rs, test_checkpoint.py): clarify that the counter program is x86_64-only, not checkpoint/restore itself --- crates/sandlock-core/build.rs | 40 ++++++++----- .../src/checkpoint/restore-stub.c | 37 +++++++----- .../src/checkpoint/restore_blob.rs | 58 ++++++------------- crates/sandlock-core/src/checkpoint/resume.rs | 6 +- crates/sandlock-core/src/sandbox.rs | 2 +- .../tests/integration/test_restore.rs | 4 ++ crates/sandlock-ffi/tests/restore.rs | 2 +- crates/sandlock-oci/tests/integration.rs | 4 +- python/src/sandlock/sandbox.py | 2 +- python/tests/test_checkpoint.py | 2 +- 10 files changed, 76 insertions(+), 81 deletions(-) diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index 5e39753e..f77f110c 100644 --- a/crates/sandlock-core/build.rs +++ b/crates/sandlock-core/build.rs @@ -34,21 +34,36 @@ fn main() { 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"); + let host = std::env::var("HOST").unwrap_or_default(); let target = std::env::var("TARGET").unwrap_or_default(); - let (ccs, warn) = if target.starts_with("riscv64") - || target.starts_with("riscv64gc") - { - ( - &["riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], - "cannot compile restore-stub for riscv64: its restore tests will be \ - skipped. Install a riscv64 cross-compiler (e.g. riscv64-linux-gnu-gcc).", - ) + let is_riscv64 = target.starts_with("riscv64"); + let (ccs, warn) = if is_riscv64 { + if host.starts_with("riscv64") { + ( + &["cc", "riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "cannot compile restore-stub for riscv64: its restore tests will be \ + skipped. Install gcc.", + ) + } else { + ( + &["riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "cannot compile restore-stub for riscv64: its restore tests will be \ + skipped. Install a riscv64 cross-compiler (e.g. riscv64-linux-gnu-gcc).", + ) + } } else { ( &["cc"][..], "cannot compile restore-stub: its restore tests will be skipped.", ) }; + // 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" + }; build_static( &stub_src, &stub_bin, @@ -58,19 +73,12 @@ fn main() { "-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, ], warn, ); - // 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 diff --git a/crates/sandlock-core/src/checkpoint/restore-stub.c b/crates/sandlock-core/src/checkpoint/restore-stub.c index 9e5e780d..ecdd8dc8 100644 --- a/crates/sandlock-core/src/checkpoint/restore-stub.c +++ b/crates/sandlock-core/src/checkpoint/restore-stub.c @@ -59,10 +59,14 @@ /* The window this stub is linked into, mirroring restore_blob::STUB_BASE and * STUB_SPAN and the -Wl,-Ttext-segment= flag in build.rs. Used only to refuse a * sweep entry that would unmap the stub out from under itself. */ +#ifdef __riscv +#define STUB_BASE 0x3000000000UL +#else #define STUB_BASE 0x30000000000UL +#endif #define STUB_SPAN 0x400000UL -#ifdef __riscv +#if defined(__riscv) && __riscv_xlen == 64 #define SYS_read 63 #define SYS_write 64 #define SYS_close 57 @@ -116,11 +120,12 @@ * path. Rust fails the restore rather than truncating if a blob exceeds it. */ #define CTRL_MAX (1 << 20) /* Upper bound on a signal-frame FP image. x86_64: AMX-sized xstate plus - * magic2. riscv64: 544 bytes for Q-extension __riscv_fp_state. */ + * magic2. riscv64: 516 bytes, the last safe byte before sc_extdesc.reserved; + * the kernel union __riscv_fp_state is 528 bytes. */ #if defined(__x86_64__) #define FP_MAX 16384 -#elif defined(__riscv) -#define FP_MAX 544 +#elif defined(__riscv) && __riscv_xlen == 64 +#define FP_MAX 516 #endif #define STACK_SIZE 65536 /* Leftover mappings the supervisor may ask the stub to unmap. A freshly @@ -136,6 +141,7 @@ typedef unsigned long u64; typedef unsigned int u32; typedef long i64; +typedef unsigned char u8; #ifdef __x86_64__ static i64 sc6(long n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f) { @@ -148,7 +154,7 @@ static i64 sc6(long n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f) { : "rcx", "r11", "memory"); return r; } -#elif defined(__riscv) +#elif defined(__riscv) && __riscv_xlen == 64 static i64 sc6(long n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f) { register long nr __asm__("a7") = n; register u64 a0 __asm__("a0") = a; @@ -240,18 +246,19 @@ enum { UR_R15=0,UR_R14,UR_R13,UR_R12,UR_RBP,UR_RBX,UR_R11,UR_R10,UR_R9,UR_R8, UR_RAX,UR_RCX,UR_RDX,UR_RSI,UR_RDI,UR_ORIG_RAX,UR_RIP,UR_CS,UR_EFLAGS, UR_RSP,UR_SS,UR_FS_BASE,UR_GS_BASE,UR_DS,UR_ES,UR_FS,UR_GS }; -#elif defined(__riscv) +#elif defined(__riscv) && __riscv_xlen == 64 /* riscv64: rt_sigreturn reads frame at sp = (struct rt_sigframe *)sp. - * siginfo (128 bytes) + ucontext. uc_mcontext is at uc+0xA8 (168). - * sc_regs[32] at sigcontext+0x00, sc_fpregs at sigcontext+0x100. + * siginfo (128 bytes) + ucontext. uc_mcontext is at uc+0xB0 (176). + * sc_regs[32] at sigcontext+0x00, sc_fpregs at sigcontext+0x100; + * sigcontext is 16-aligned, hence the pad from 0xA8 to 0xB0. * The gp[] blob order is ptrace: pc=0, ra=1, sp=2, gp=3, tp=4, t0-t2=5-7, * s0-s1=8-9, a0-a7=10-17, s2-s11=18-27, t3-t6=28-31 — a 1:1 mapping to * sc_regs[32], so no remap is needed. tp (thread pointer) is carried by the * signal frame's sc_regs[4]; nothing needs arch_prctl. */ struct sigctx { u64 gregs[32]; /* sc_regs: 32 gregs, 256 bytes */ - u8 fpregs[544]; /* sc_fpregs: union __riscv_fp_state (max Q ext) */ + u8 fpregs[528]; /* sc_fpregs: union __riscv_fp_state (kernel 528 byte union) */ }; struct uctx { @@ -262,8 +269,8 @@ struct uctx { u32 _pad; /* 0x1C */ u64 ss_size; /* 0x20 */ u64 uc_sigmask; /* 0x28 */ - u8 __unused[120]; /* 0x30 */ - struct sigctx mc; /* 0xA8 (168) */ + u8 __unused[128]; /* 0x30 */ + struct sigctx mc; /* 0xB0 (176) */ }; /* rt_sigframe: struct siginfo (zeroed, 128 bytes) + ucontext. */ @@ -439,7 +446,7 @@ static void _start_c(u64 *sp) { i64 fd = SC4(SYS_openat, AT_FDCWD, strings + f->path_off, f->flags, 0); if (fd < 0) die(10); if ((u32)fd != f->fd) { -#ifdef __riscv +#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 @@ -506,7 +513,7 @@ static void _start_c(u64 *sp) { : "r"(&uc), "r"(rax) : "memory"); -#elif defined(__riscv) +#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, @@ -518,8 +525,6 @@ static void _start_c(u64 *sp) { if (nregs > 32) nregs = 32; memcpy(sf.uc.mc.gregs, gp, nregs * sizeof(u64)); if (h->fpstate_len) { - if (h->fpstate_len > sizeof(sf.uc.mc.fpregs)) - h->fpstate_len = (u32)sizeof(sf.uc.mc.fpregs); memcpy(sf.uc.mc.fpregs, ctrl_buf + h->fpstate_off, h->fpstate_len); } @@ -551,7 +556,7 @@ __asm__( " call _start_c\n" " hlt\n" ); -#elif defined(__riscv) +#elif defined(__riscv) && __riscv_xlen == 64 /* riscv64: a0 = sp (first argument), switch to stub_stack, align, call. */ __asm__( ".global _start\n" diff --git a/crates/sandlock-core/src/checkpoint/restore_blob.rs b/crates/sandlock-core/src/checkpoint/restore_blob.rs index 7253905b..dfb87248 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -42,7 +42,13 @@ 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; pub(crate) const STUB_SPAN: u64 = 0x40_0000; /// x86_64 signal-frame FP image constants. `FP_XSTATE_MAGIC1` in the @@ -374,20 +380,15 @@ fn rearm_restartable_syscall(regs: &mut [u64]) { } } -#[cfg(target_arch = "riscv64")] -fn rearm_restartable_syscall(regs: &mut [u64]) { - // riscv64 user_regs_struct layout indices (32 u64): - // pc, ra, sp, gp, tp, t0-t2, s0-s1, a0-a7, s2-s11, t3-t6 - const A0: usize = 10; - const A7: usize = 17; - const PC: usize = 0; - if let (Some(&a0), Some(&a7)) = (regs.get(A0), regs.get(A7)) { - if matches!(a0 as i64, -512 | -513 | -514 | -516) { - regs[A0] = a7; // restore original syscall number - regs[PC] = regs[PC].wrapping_sub(4); // rewind past ecall (4 bytes) - } - } -} +// riscv64 rearm is not implemented: on riscv64 a0 carries the return +// value (not the syscall number, which stays in a7), so the saved +// register file does not contain the original a0 argument needed for +// a correct restart. The kernel's own restart fixup also sets a7 to +// __NR_restart_syscall for ERESTART_RESTARTBLOCK rather than re-running +// the original number. Both facts make a correct rearm from the +// ptrace-captured register file alone infeasible; restore on riscv64 +// will therefore restart any interrupted syscall with a zero return +// value (harmless for most calls) rather than with corrupted arguments. /// Build the FP image the stub points the signal frame's `fpstate` at. /// @@ -907,32 +908,9 @@ mod tests { assert_eq!(regs[16], 0x4010_0000); } - #[test] - #[cfg(target_arch = "riscv64")] - fn restart_sentinel_rewinds_pc_onto_the_ecall() { - // a0 = -ERESTARTNOHAND with a7 = pause(34): the restored context - // must re-enter the syscall rather than resume past it with the sentinel. - let mut regs = vec![0u64; 32]; - regs[10] = (-514i64) as u64; // a0 - regs[17] = 34; // a7 (original syscall number) - regs[0] = 0x4010_0000; // pc, just past the `ecall` - rearm_restartable_syscall(&mut regs); - assert_eq!(regs[10], 34, "a0 reloaded with the original syscall number"); - assert_eq!(regs[0], 0x4010_0000 - 4, "pc rewound onto the 4-byte ecall"); - } - - #[test] - #[cfg(target_arch = "riscv64")] - fn non_restart_errno_is_left_alone() { - // -515 (ENOIOCTLCMD) looks like a sentinel but is not one. - let mut regs = vec![0u64; 32]; - regs[10] = (-515i64) as u64; - regs[17] = 34; - regs[0] = 0x4010_0000; - rearm_restartable_syscall(&mut regs); - assert_eq!(regs[10], (-515i64) as u64); - assert_eq!(regs[0], 0x4010_0000); - } + // riscv64 rearm is not implemented (see comment above), so these + // tests are omitted — the cfg(not(any(...))) no-op fallback handles + // both the sentinel and non-restart cases correctly. #[test] #[cfg(target_arch = "x86_64")] diff --git a/crates/sandlock-core/src/checkpoint/resume.rs b/crates/sandlock-core/src/checkpoint/resume.rs index 960277e7..90528e25 100644 --- a/crates/sandlock-core/src/checkpoint/resume.rs +++ b/crates/sandlock-core/src/checkpoint/resume.rs @@ -584,9 +584,9 @@ mod tests { // 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 (0x300_0000_0000) and the vDSO (near the top). - const CODE: u64 = 0x200_0000_0000; - const STACK: u64 = 0x200_0001_0000; + // 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; diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 3063deef..ce2a5ada 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1072,7 +1072,7 @@ 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. /// /// The kernel vDSO is relocated onto the checkpoint-recorded base during /// restore, so ordinary libc/glibc programs that call vDSO functions (e.g. diff --git a/crates/sandlock-core/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index e404e190..0d46dca7 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -16,7 +16,11 @@ 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; const STUB_SPAN: u64 = 0x40_0000; /// Parse `/proc//maps` into `(start, end, path)` triples. 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") From 1e6efd53730b0d2c965ead96edafb95207ab3da0 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 8 Aug 2026 10:53:38 +0000 Subject: [PATCH 4/9] fix(build): restore accidentally dropped cargo:rustc-env line The previous edit to build.rs lost the println! that emits RESTORE_STUB_PATH, causing CI to fail at compile time with "environment variable RESTORE_STUB_PATH not defined". --- crates/sandlock-core/build.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index f77f110c..829f27df 100644 --- a/crates/sandlock-core/build.rs +++ b/crates/sandlock-core/build.rs @@ -79,6 +79,9 @@ fn main() { ], warn, ); + // 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 From 5d0aa433ade77d2632cb91b51efcb55bdc41b7ca Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 8 Aug 2026 10:56:25 +0000 Subject: [PATCH 5/9] fix(checkpoint): cover riscv64 in rearm fallback cfg gate The riscv64 rearm_restartable_syscall was removed because it wrote a7 into a0, corrupting the restarted call. But the fallback no-op was gated on cfg(not(any(x86_64, riscv64))), so on riscv64 no rearm_restartable_syscall existed at all and the cross-build failed. Broaden the fallback to cfg(not(target_arch = "x86_64")) so riscv64 (and any future arch) gets the no-op. --- crates/sandlock-core/src/checkpoint/restore_blob.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sandlock-core/src/checkpoint/restore_blob.rs b/crates/sandlock-core/src/checkpoint/restore_blob.rs index dfb87248..233eb28a 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -475,7 +475,7 @@ fn build_fpstate_image(_fpregs: &[u8]) -> Vec { Vec::new() } -#[cfg(not(any(target_arch = "x86_64", target_arch = "riscv64")))] +#[cfg(not(target_arch = "x86_64"))] fn rearm_restartable_syscall(_regs: &mut [u64]) {} /// Interns NUL-terminated strings into the blob's string table, deduplicating From 1a5ef13ebdcfbf74cb2f5068938c1e4c7fc85a74 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 8 Aug 2026 11:07:56 +0000 Subject: [PATCH 6/9] fix(checkpoint): add STUB_BASE fallback for non-x86_64/riscv64 arches STUB_BASE was split per-arch (x86_64=0x300_0000_0000, riscv64=0x30_0000_0000), but aarch64 builds (ubuntu-24.04-arm) had no definition. Add a 0-valued fallback for all other arches; STUB_BASE is only referenced in the reserved-window check which is dead on arches where the restore gate rejects at runtime. --- crates/sandlock-core/src/checkpoint/restore_blob.rs | 2 ++ crates/sandlock-core/tests/integration/test_restore.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/sandlock-core/src/checkpoint/restore_blob.rs b/crates/sandlock-core/src/checkpoint/restore_blob.rs index 233eb28a..16a49482 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -49,6 +49,8 @@ const SRC_FILE: u8 = 1; 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 diff --git a/crates/sandlock-core/tests/integration/test_restore.rs b/crates/sandlock-core/tests/integration/test_restore.rs index 0d46dca7..83a26605 100644 --- a/crates/sandlock-core/tests/integration/test_restore.rs +++ b/crates/sandlock-core/tests/integration/test_restore.rs @@ -21,6 +21,8 @@ fn helper_binary() -> PathBuf { 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. From 4ce6634bbec08e59abd0a0603934527b8a8e0772 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 8 Aug 2026 11:37:31 +0000 Subject: [PATCH 7/9] fix: skip stub-window keep interval when STUB_BASE=0 (aarch64) STUB_BASE=0 on architectures without a restore stub created a false keep interval (0, STUB_SPAN) that overlapped real user-space mappings, causing plan_sweep to return empty sweep lists and plan() to reject legitimate checkpoint regions. - plan_sweep: only chain stub window into keep set when STUB_BASE > 0 - plan: only check stub-window overlap when STUB_BASE > 0 - Tests using STUB_BASE in data are now cfg-guarded to stub-capable archs Fixes sweep_takes_only_the_uncovered_part_of_a_partly_recorded_mapping and blob_interns_a_repeated_mapping_path_once on aarch64/ubuntu-24.04-arm. --- .../src/checkpoint/restore_blob.rs | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/sandlock-core/src/checkpoint/restore_blob.rs b/crates/sandlock-core/src/checkpoint/restore_blob.rs index 16a49482..55571596 100644 --- a/crates/sandlock-core/src/checkpoint/restore_blob.rs +++ b/crates/sandlock-core/src/checkpoint/restore_blob.rs @@ -106,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(); @@ -517,17 +520,17 @@ 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); @@ -724,6 +727,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 @@ -869,7 +873,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( From 883f94642dc8075a4a9d29139deb6d7bb3da993b Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 22 Aug 2026 22:34:58 +0000 Subject: [PATCH 8/9] fix(checkpoint): reject riscv64 checkpoints caught in a restartable syscall riscv64 restore is publicly enabled, but a checkpoint captured while the process was blocked in a restartable syscall (nanosleep, futex, read, ...) resumed with the raw -ERESTART* sentinel in a0 instead of a valid result. x86_64 re-arms such a syscall at restore (orig_rax is ptrace-exposed), but riscv64's a0 is both the first argument and the return value, and orig_a0 is not part of the ptrace-exposed user_regs_struct, so a correct re-arm is infeasible. On kernels >= 6.6 the kernel has already applied the restart fixup before the ptrace stop, so a0 is already the original argument and no re-arm is needed; on older kernels the sentinel is visible. Reject the checkpoint instead of resuming corrupt state: at capture time (fail fast, before the memory dump) and defensively at restore planning. --- .../sandlock-core/src/checkpoint/capture.rs | 13 ++ .../src/checkpoint/restore_blob.rs | 127 +++++++++++++----- crates/sandlock-core/src/sandbox.rs | 5 +- 3 files changed, 114 insertions(+), 31 deletions(-) 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) -> Result 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 the return -/// register (-ERESTARTSYS / -ERESTARTNOINTR / -ERESTARTNOHAND / -/// -ERESTART_RESTARTBLOCK). At the ptrace stop, the PC still points just PAST -/// the syscall instruction. The kernel's restart fixup (rewind PC, reload 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). +/// 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 rearm is not implemented: on riscv64 a0 carries the return -// value (not the syscall number, which stays in a7), so the saved -// register file does not contain the original a0 argument needed for -// a correct restart. The kernel's own restart fixup also sets a7 to -// __NR_restart_syscall for ERESTART_RESTARTBLOCK rather than re-running -// the original number. Both facts make a correct rearm from the -// ptrace-captured register file alone infeasible; restore on riscv64 -// will therefore restart any interrupted syscall with a zero return -// value (harmless for most calls) rather than with corrupted arguments. +/// 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. /// @@ -480,8 +516,10 @@ 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). @@ -537,7 +575,7 @@ pub(crate) fn plan( 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(); @@ -630,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![ @@ -896,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"); } @@ -909,14 +976,14 @@ 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 rearm is not implemented (see comment above), so these - // tests are omitted — the cfg(not(any(...))) no-op fallback handles - // both the sentinel and non-restart cases correctly. + // 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")] diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index ce2a5ada..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 and riscv64 restore engines supported. + /// 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. From d55380f4b622f058cfd40c7b062853fcc435a5e9 Mon Sep 17 00:00:00 2001 From: sachin kumar Date: Sat, 22 Aug 2026 22:44:16 +0000 Subject: [PATCH 9/9] fix(build): fail the build when the restore-stub does not compile The restore-stub is a core component of checkpoint restore on x86_64 and riscv64, but build_static downgraded a compiler failure to a cargo:warning, so CI stayed green while every restore test silently skipped on !stub.exists(). That invisibility is how all six riscv64 blockers in PR #192 slipped through. Make the stub build failure fatal on the arches that claim restore support (x86_64, riscv64): build_static now reports success/failure instead of printing a warning, and the restore-stub call panics on failure there. The rootfs-helper fixture and non-restore arches still downgrade to a warning. --- crates/sandlock-core/build.rs | 54 ++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index 829f27df..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 @@ -37,24 +40,29 @@ fn main() { 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"); - let (ccs, warn) = if is_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"][..], - "cannot compile restore-stub for riscv64: its restore tests will be \ - skipped. Install 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"][..], - "cannot compile restore-stub for riscv64: its restore tests will be \ - skipped. Install a riscv64 cross-compiler (e.g. riscv64-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"][..], - "cannot compile restore-stub: its restore tests will be skipped.", + "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 @@ -64,7 +72,7 @@ fn main() { } else { "-Wl,-Ttext-segment=0x30000000000" }; - build_static( + if !build_static( &stub_src, &stub_bin, ccs, @@ -77,26 +85,32 @@ fn main() { "-fno-tree-loop-distribute-patterns", text_segment, ], - warn, - ); + ) { + 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; } } } @@ -111,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 }