Skip to main content

sandlock_core/
checkpoint.rs

1use serde::{Serialize, Deserialize};
2use crate::sandbox::Sandbox;
3use crate::error::{SandlockError, SandboxRuntimeError};
4use std::io;
5use std::path::{Path, PathBuf};
6
7/// A frozen snapshot of sandbox state.
8#[derive(Debug, Serialize, Deserialize)]
9pub struct Checkpoint {
10    pub name: String,
11    pub policy: Sandbox,
12    pub process_state: ProcessState,
13    pub fd_table: Vec<FdInfo>,
14    pub cow_snapshot: Option<PathBuf>,
15    pub app_state: Option<Vec<u8>>,
16}
17
18/// Captured process state via ptrace (registers) + process_vm_readv (memory) + /proc (metadata).
19#[derive(Debug, Serialize, Deserialize)]
20pub struct ProcessState {
21    pub pid: i32,
22    pub cwd: String,
23    pub exe: String,
24    pub regs: Vec<u64>,
25    pub memory_maps: Vec<MemoryMap>,
26    pub memory_data: Vec<MemorySegment>,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
30pub struct MemorySegment {
31    pub start: u64,
32    pub data: Vec<u8>,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36pub struct MemoryMap {
37    pub start: u64,
38    pub end: u64,
39    pub perms: String,
40    pub offset: u64,
41    pub path: Option<String>,
42}
43
44impl MemoryMap {
45    pub fn writable(&self) -> bool {
46        self.perms.starts_with("rw")
47    }
48
49    pub fn private(&self) -> bool {
50        self.perms.contains('p')
51    }
52
53    pub fn is_special(&self) -> bool {
54        self.path.as_ref().map_or(false, |p| {
55            p.starts_with("[vdso]") || p.starts_with("[vvar]") || p.starts_with("[vsyscall]")
56        })
57    }
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61pub struct FdInfo {
62    pub fd: i32,
63    pub path: String,
64    pub flags: i32,
65    pub offset: u64,
66}
67
68// ---------------------------------------------------------------------------
69// ptrace helpers — PTRACE_SEIZE (doesn't auto-SIGSTOP like ATTACH)
70// ---------------------------------------------------------------------------
71
72fn ptrace_seize(pid: i32) -> io::Result<()> {
73    let ret = unsafe {
74        libc::ptrace(libc::PTRACE_SEIZE as libc::c_uint, pid, 0, 0)
75    };
76    if ret < 0 {
77        return Err(io::Error::last_os_error());
78    }
79    // PTRACE_INTERRUPT stops the tracee without SIGSTOP side effects
80    let ret = unsafe {
81        libc::ptrace(libc::PTRACE_INTERRUPT as libc::c_uint, pid, 0, 0)
82    };
83    if ret < 0 {
84        return Err(io::Error::last_os_error());
85    }
86    // Wait for the ptrace-stop
87    let mut status: i32 = 0;
88    unsafe {
89        libc::waitpid(pid, &mut status, 0);
90    }
91    Ok(())
92}
93
94fn ptrace_detach(pid: i32) -> io::Result<()> {
95    let ret = unsafe { libc::ptrace(libc::PTRACE_DETACH, pid, 0, 0) };
96    if ret < 0 {
97        return Err(io::Error::last_os_error());
98    }
99    Ok(())
100}
101
102fn ptrace_getregs(pid: i32) -> io::Result<Vec<u64>> {
103    #[cfg(target_arch = "x86_64")]
104    {
105        // user_regs_struct is 27 u64 fields on x86_64 (216 bytes)
106        let mut regs = vec![0u64; 27];
107        let ret = unsafe { libc::ptrace(libc::PTRACE_GETREGS, pid, 0, regs.as_mut_ptr()) };
108        if ret < 0 {
109            return Err(io::Error::last_os_error());
110        }
111        Ok(regs)
112    }
113
114    #[cfg(target_arch = "aarch64")]
115    {
116        // Linux arm64 exposes general-purpose registers through
117        // PTRACE_GETREGSET/NT_PRSTATUS. user_pt_regs is:
118        // x0-x30, sp, pc, pstate (34 u64 values).
119        const NT_PRSTATUS: libc::c_int = 1;
120        let mut regs = vec![0u64; 34];
121        let mut iov = libc::iovec {
122            iov_base: regs.as_mut_ptr() as *mut libc::c_void,
123            iov_len: regs.len() * std::mem::size_of::<u64>(),
124        };
125        let ret = unsafe {
126            libc::ptrace(
127                libc::PTRACE_GETREGSET,
128                pid,
129                NT_PRSTATUS as usize as *mut libc::c_void,
130                &mut iov as *mut libc::iovec as *mut libc::c_void,
131            )
132        };
133        if ret < 0 {
134            return Err(io::Error::last_os_error());
135        }
136        regs.truncate(iov.iov_len / std::mem::size_of::<u64>());
137        Ok(regs)
138    }
139
140    #[cfg(target_arch = "riscv64")]
141    {
142        // Linux riscv64 exposes general-purpose registers through
143        // PTRACE_GETREGSET/NT_PRSTATUS. struct user_regs_struct is:
144        // pc, ra, sp, gp, tp, t0-t2, s0-s1, a0-a7, s2-s11, t3-t6
145        // (32 u64 values; x0 is hardwired zero and not stored).
146        const NT_PRSTATUS: libc::c_int = 1;
147        let mut regs = vec![0u64; 32];
148        let mut iov = libc::iovec {
149            iov_base: regs.as_mut_ptr() as *mut libc::c_void,
150            iov_len: regs.len() * std::mem::size_of::<u64>(),
151        };
152        let ret = unsafe {
153            libc::ptrace(
154                libc::PTRACE_GETREGSET,
155                pid,
156                NT_PRSTATUS as usize as *mut libc::c_void,
157                &mut iov as *mut libc::iovec as *mut libc::c_void,
158            )
159        };
160        if ret < 0 {
161            return Err(io::Error::last_os_error());
162        }
163        regs.truncate(iov.iov_len / std::mem::size_of::<u64>());
164        Ok(regs)
165    }
166
167    #[cfg(not(any(
168        target_arch = "x86_64",
169        target_arch = "aarch64",
170        target_arch = "riscv64"
171    )))]
172    {
173        let _ = pid;
174        Err(io::Error::new(
175            io::ErrorKind::Unsupported,
176            "checkpoint register capture is not implemented on this architecture",
177        ))
178    }
179}
180
181// ---------------------------------------------------------------------------
182// /proc parsing
183// ---------------------------------------------------------------------------
184
185fn parse_proc_maps(pid: i32) -> io::Result<Vec<MemoryMap>> {
186    let content = std::fs::read_to_string(format!("/proc/{}/maps", pid))?;
187    let mut maps = Vec::new();
188    for line in content.lines() {
189        // Format: start-end perms offset dev inode [pathname]
190        let parts: Vec<&str> = line.splitn(6, ' ').collect();
191        if parts.len() < 5 {
192            continue;
193        }
194        let addrs: Vec<&str> = parts[0].split('-').collect();
195        if addrs.len() != 2 {
196            continue;
197        }
198        let start = u64::from_str_radix(addrs[0], 16).unwrap_or(0);
199        let end = u64::from_str_radix(addrs[1], 16).unwrap_or(0);
200        let perms = parts[1].to_string();
201        let offset = u64::from_str_radix(parts[2], 16).unwrap_or(0);
202        let path = if parts.len() >= 6 {
203            let p = parts[5].trim();
204            if p.is_empty() {
205                None
206            } else {
207                Some(p.to_string())
208            }
209        } else {
210            None
211        };
212        maps.push(MemoryMap {
213            start,
214            end,
215            perms,
216            offset,
217            path,
218        });
219    }
220    Ok(maps)
221}
222
223// ---------------------------------------------------------------------------
224// Memory capture — process_vm_readv (scatter-gather, no file I/O)
225// ---------------------------------------------------------------------------
226
227fn capture_memory(pid: i32, maps: &[MemoryMap]) -> io::Result<Vec<MemorySegment>> {
228    let mut segments = Vec::new();
229
230    for map in maps {
231        if !map.writable() || !map.private() || map.is_special() {
232            continue;
233        }
234        let size = (map.end - map.start) as usize;
235        if size > 256 * 1024 * 1024 {
236            continue; // skip segments > 256MB
237        }
238
239        let mut data = vec![0u8; size];
240
241        let local_iov = libc::iovec {
242            iov_base: data.as_mut_ptr() as *mut libc::c_void,
243            iov_len: size,
244        };
245        let remote_iov = libc::iovec {
246            iov_base: map.start as *mut libc::c_void,
247            iov_len: size,
248        };
249
250        let ret = unsafe {
251            libc::process_vm_readv(
252                pid as libc::pid_t,
253                &local_iov as *const libc::iovec,
254                1,
255                &remote_iov as *const libc::iovec,
256                1,
257                0,
258            )
259        };
260
261        if ret == size as isize {
262            segments.push(MemorySegment {
263                start: map.start,
264                data,
265            });
266        }
267        // Skip unreadable segments silently (same as old behavior)
268    }
269    Ok(segments)
270}
271
272// ---------------------------------------------------------------------------
273// FD table capture
274// ---------------------------------------------------------------------------
275
276fn capture_fd_table(pid: i32) -> io::Result<Vec<FdInfo>> {
277    let fd_dir = format!("/proc/{}/fd", pid);
278    let mut fds = Vec::new();
279
280    for entry in std::fs::read_dir(&fd_dir)? {
281        let entry = entry?;
282        let fd_str = entry.file_name().into_string().unwrap_or_default();
283        let fd: i32 = match fd_str.parse() {
284            Ok(f) => f,
285            Err(_) => continue,
286        };
287
288        let path = std::fs::read_link(entry.path())
289            .map(|p| p.display().to_string())
290            .unwrap_or_default();
291
292        // Parse fdinfo for flags and offset
293        let (flags, offset) = parse_fdinfo(pid, fd).unwrap_or((0, 0));
294
295        fds.push(FdInfo {
296            fd,
297            path,
298            flags,
299            offset,
300        });
301    }
302
303    fds.sort_by_key(|f| f.fd);
304    Ok(fds)
305}
306
307fn parse_fdinfo(pid: i32, fd: i32) -> io::Result<(i32, u64)> {
308    let content = std::fs::read_to_string(format!("/proc/{}/fdinfo/{}", pid, fd))?;
309    let mut flags = 0i32;
310    let mut pos = 0u64;
311    for line in content.lines() {
312        if let Some(val) = line.strip_prefix("flags:\t") {
313            flags = i32::from_str_radix(val.trim(), 8).unwrap_or(0);
314        }
315        if let Some(val) = line.strip_prefix("pos:\t") {
316            pos = val.trim().parse().unwrap_or(0);
317        }
318    }
319    Ok((flags, pos))
320}
321
322// ---------------------------------------------------------------------------
323// Main capture function
324// ---------------------------------------------------------------------------
325
326/// Capture a checkpoint from a running, stopped sandbox.
327/// The sandbox must already be frozen (SIGSTOP'd and fork-held).
328pub(crate) fn capture(pid: i32, policy: &Sandbox) -> Result<Checkpoint, SandlockError> {
329    // Seize via ptrace (PTRACE_SEIZE + PTRACE_INTERRUPT — doesn't auto-SIGSTOP)
330    ptrace_seize(pid).map_err(|e| {
331        SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace seize: {}", e)))
332    })?;
333
334    // Capture registers
335    let regs = ptrace_getregs(pid).map_err(|e| {
336        SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace getregs: {}", e)))
337    })?;
338
339    // Capture memory maps
340    let maps =
341        parse_proc_maps(pid).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
342
343    // Capture memory data
344    let memory_data =
345        capture_memory(pid, &maps).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
346
347    // Capture fd table
348    let fd_table =
349        capture_fd_table(pid).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
350
351    // Detach
352    ptrace_detach(pid).map_err(|e| {
353        SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace detach: {}", e)))
354    })?;
355
356    // Capture cwd and exe from /proc
357    let cwd = std::fs::read_link(format!("/proc/{}/cwd", pid))
358        .map(|p| p.display().to_string())
359        .unwrap_or_default();
360    let exe = std::fs::read_link(format!("/proc/{}/exe", pid))
361        .map(|p| p.display().to_string())
362        .unwrap_or_default();
363
364    Ok(Checkpoint {
365        name: String::new(),
366        policy: policy.clone(),
367        process_state: ProcessState {
368            pid,
369            cwd,
370            exe,
371            regs,
372            memory_maps: maps,
373            memory_data,
374        },
375        fd_table,
376        cow_snapshot: None,
377        app_state: None,
378    })
379}
380
381// ---------------------------------------------------------------------------
382// Save / Load — directory-based format
383// ---------------------------------------------------------------------------
384//
385// Layout:
386//   <dir>/
387//   ├── meta.json            # name, cow_snapshot
388//   ├── policy.dat           # bincode-serialized Sandbox
389//   ├── app_state.bin        # optional raw app state
390//   └── process/
391//       ├── info.json        # pid, cwd, exe
392//       ├── fds.json         # file descriptor table
393//       ├── memory_map.json  # region metadata
394//       ├── threads/
395//       │   └── 0.bin        # raw register bytes (main thread)
396//       └── memory/
397//           └── <index>.bin  # raw memory contents per segment
398
399fn io_err(e: impl std::fmt::Display) -> SandlockError {
400    SandlockError::Runtime(SandboxRuntimeError::Child(e.to_string()))
401}
402
403fn write_json<T: Serialize>(path: &Path, val: &T) -> Result<(), SandlockError> {
404    let json = serde_json::to_string_pretty(val).map_err(io_err)?;
405    std::fs::write(path, json).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))
406}
407
408fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, SandlockError> {
409    let data = std::fs::read_to_string(path)
410        .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
411    serde_json::from_str(&data).map_err(io_err)
412}
413
414/// JSON schema for meta.json.
415#[derive(Serialize, Deserialize)]
416struct MetaJson {
417    name: String,
418    cow_snapshot: Option<String>,
419}
420
421/// JSON schema for process/info.json.
422#[derive(Serialize, Deserialize)]
423struct InfoJson {
424    pid: i32,
425    cwd: String,
426    exe: String,
427}
428
429/// JSON schema for each entry in process/fds.json.
430#[derive(Serialize, Deserialize)]
431struct FdJson {
432    fd: i32,
433    path: String,
434    flags: i32,
435    offset: u64,
436}
437
438/// JSON schema for each entry in process/memory_map.json.
439#[derive(Serialize, Deserialize)]
440struct MemoryMapJson {
441    start: u64,
442    end: u64,
443    perms: String,
444    offset: u64,
445    path: Option<String>,
446}
447
448impl Checkpoint {
449    /// Persist this checkpoint to a directory.
450    ///
451    /// Writes atomically: creates `<dir>.tmp`, populates it, then renames.
452    pub fn save(&self, dir: &Path) -> Result<(), SandlockError> {
453        let tmp = dir.with_extension("tmp");
454        if tmp.exists() {
455            std::fs::remove_dir_all(&tmp)
456                .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
457        }
458        std::fs::create_dir_all(&tmp)
459            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
460
461        let res = self.save_inner(&tmp);
462        if res.is_err() {
463            let _ = std::fs::remove_dir_all(&tmp);
464            return res;
465        }
466
467        // Atomic rename into place
468        if dir.exists() {
469            std::fs::remove_dir_all(dir)
470                .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
471        }
472        std::fs::rename(&tmp, dir)
473            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
474
475        Ok(())
476    }
477
478    fn save_inner(&self, dir: &Path) -> Result<(), SandlockError> {
479        // meta.json
480        write_json(&dir.join("meta.json"), &MetaJson {
481            name: self.name.clone(),
482            cow_snapshot: self.cow_snapshot.as_ref().map(|p| p.display().to_string()),
483        })?;
484
485        // policy.dat (bincode — complex struct, not human-readable anyway)
486        let policy_bytes = bincode::serialize(&self.policy).map_err(io_err)?;
487        std::fs::write(dir.join("policy.dat"), &policy_bytes)
488            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
489
490        // app_state.bin
491        if let Some(ref state) = self.app_state {
492            std::fs::write(dir.join("app_state.bin"), state)
493                .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
494        }
495
496        // process/
497        let proc_dir = dir.join("process");
498        std::fs::create_dir(&proc_dir)
499            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
500
501        // process/info.json
502        write_json(&proc_dir.join("info.json"), &InfoJson {
503            pid: self.process_state.pid,
504            cwd: self.process_state.cwd.clone(),
505            exe: self.process_state.exe.clone(),
506        })?;
507
508        // process/fds.json
509        let fds: Vec<FdJson> = self.fd_table.iter().map(|f| FdJson {
510            fd: f.fd,
511            path: f.path.clone(),
512            flags: f.flags,
513            offset: f.offset,
514        }).collect();
515        write_json(&proc_dir.join("fds.json"), &fds)?;
516
517        // process/memory_map.json — only captured segments (1:1 with memory/*.bin)
518        // Build map entries for each captured segment by matching start address
519        let maps: Vec<MemoryMapJson> = self.process_state.memory_data.iter().map(|seg| {
520            // Find the corresponding full map entry
521            let map = self.process_state.memory_maps.iter()
522                .find(|m| m.start == seg.start);
523            match map {
524                Some(m) => MemoryMapJson {
525                    start: m.start,
526                    end: m.end,
527                    perms: m.perms.clone(),
528                    offset: m.offset,
529                    path: m.path.clone(),
530                },
531                None => MemoryMapJson {
532                    start: seg.start,
533                    end: seg.start + seg.data.len() as u64,
534                    perms: "rw-p".to_string(),
535                    offset: 0,
536                    path: None,
537                },
538            }
539        }).collect();
540        write_json(&proc_dir.join("memory_map.json"), &maps)?;
541
542        // process/threads/0.bin — main thread register state
543        let threads_dir = proc_dir.join("threads");
544        std::fs::create_dir(&threads_dir)
545            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
546        let reg_bytes: Vec<u8> = self.process_state.regs.iter()
547            .flat_map(|r| r.to_le_bytes())
548            .collect();
549        std::fs::write(threads_dir.join("0.bin"), &reg_bytes)
550            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
551
552        // process/memory/<index>.bin — 1:1 with memory_map.json entries
553        let mem_dir = proc_dir.join("memory");
554        std::fs::create_dir(&mem_dir)
555            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
556        for (i, seg) in self.process_state.memory_data.iter().enumerate() {
557            std::fs::write(mem_dir.join(format!("{}.bin", i)), &seg.data)
558                .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
559        }
560
561        Ok(())
562    }
563
564    /// Load a checkpoint from a directory.
565    pub fn load(dir: &Path) -> Result<Self, SandlockError> {
566        if !dir.is_dir() {
567            return Err(SandlockError::Runtime(SandboxRuntimeError::Child(
568                format!("Checkpoint not found: {}", dir.display()),
569            )));
570        }
571
572        // meta.json
573        let meta: MetaJson = read_json(&dir.join("meta.json"))?;
574
575        // policy.dat
576        let policy_bytes = std::fs::read(dir.join("policy.dat"))
577            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
578        let policy: Sandbox = bincode::deserialize(&policy_bytes).map_err(io_err)?;
579
580        // app_state.bin
581        let app_state_path = dir.join("app_state.bin");
582        let app_state = if app_state_path.exists() {
583            Some(std::fs::read(&app_state_path)
584                .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?)
585        } else {
586            None
587        };
588
589        // process/
590        let proc_dir = dir.join("process");
591
592        // process/info.json
593        let info: InfoJson = read_json(&proc_dir.join("info.json"))?;
594
595        // process/fds.json
596        let fds_json: Vec<FdJson> = read_json(&proc_dir.join("fds.json"))?;
597        let fd_table: Vec<FdInfo> = fds_json.into_iter().map(|f| FdInfo {
598            fd: f.fd,
599            path: f.path,
600            flags: f.flags,
601            offset: f.offset,
602        }).collect();
603
604        // process/memory_map.json — 1:1 with memory/<i>.bin
605        let maps_json: Vec<MemoryMapJson> = read_json(&proc_dir.join("memory_map.json"))?;
606        let memory_maps: Vec<MemoryMap> = maps_json.iter().map(|m| MemoryMap {
607            start: m.start,
608            end: m.end,
609            perms: m.perms.clone(),
610            offset: m.offset,
611            path: m.path.clone(),
612        }).collect();
613
614        // process/threads/0.bin
615        let reg_bytes = std::fs::read(proc_dir.join("threads").join("0.bin"))
616            .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
617        let regs: Vec<u64> = reg_bytes.chunks_exact(8)
618            .map(|chunk| u64::from_le_bytes(chunk.try_into().unwrap()))
619            .collect();
620
621        // process/memory/<i>.bin — 1:1 with memory_map.json
622        let mem_dir = proc_dir.join("memory");
623        let mut memory_data = Vec::new();
624        for (i, map) in maps_json.iter().enumerate() {
625            let seg_path = mem_dir.join(format!("{}.bin", i));
626            let data = std::fs::read(&seg_path)
627                .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
628            memory_data.push(MemorySegment {
629                start: map.start,
630                data,
631            });
632        }
633
634        Ok(Checkpoint {
635            name: meta.name,
636            policy,
637            process_state: ProcessState {
638                pid: info.pid,
639                cwd: info.cwd,
640                exe: info.exe,
641                regs,
642                memory_maps,
643                memory_data,
644            },
645            fd_table,
646            cow_snapshot: meta.cow_snapshot.map(PathBuf::from),
647            app_state,
648        })
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use std::process::Command;
656
657    /// `ptrace_getregs` captures a full register file with a plausible,
658    /// non-zero program counter from a live, seized child on the host
659    /// architecture. This exercises the architecture-specific register
660    /// capture path without requiring a full sandbox launch (no Landlock).
661    #[test]
662    fn ptrace_getregs_captures_program_counter() {
663        let mut child = Command::new("sleep")
664            .arg("30")
665            .spawn()
666            .expect("spawn sleep child");
667        let pid = child.id() as i32;
668
669        let result = (|| -> io::Result<Vec<u64>> {
670            ptrace_seize(pid)?;
671            let regs = ptrace_getregs(pid)?;
672            ptrace_detach(pid)?;
673            Ok(regs)
674        })();
675
676        let _ = child.kill();
677        let _ = child.wait();
678
679        let regs = result.expect("register capture should succeed on this architecture");
680
681        // Architecture-specific register-file width.
682        #[cfg(target_arch = "x86_64")]
683        assert_eq!(regs.len(), 27, "x86_64 user_regs_struct is 27 u64");
684        #[cfg(target_arch = "aarch64")]
685        assert_eq!(regs.len(), 34, "aarch64 user_pt_regs is 34 u64");
686        #[cfg(target_arch = "riscv64")]
687        assert_eq!(regs.len(), 32, "riscv64 user_regs_struct is 32 u64");
688
689        // The program counter must be a non-zero userspace address; its index
690        // into the register file differs per architecture.
691        #[cfg(target_arch = "x86_64")]
692        let pc = regs[16]; // rip
693        #[cfg(target_arch = "aarch64")]
694        let pc = regs[32]; // pc, after x0-x30 and sp
695        #[cfg(target_arch = "riscv64")]
696        let pc = regs[0]; // pc is first in riscv user_regs_struct
697
698        #[cfg(any(
699            target_arch = "x86_64",
700            target_arch = "aarch64",
701            target_arch = "riscv64"
702        ))]
703        assert!(pc != 0, "captured program counter should be non-zero, got {:#x}", pc);
704    }
705
706    /// Full capture -> save -> load roundtrip against a live child. `capture()`
707    /// only ptraces and reads `/proc`, so this exercises the architecture-specific
708    /// register arm plus the on-disk save/load format end to end WITHOUT a sandbox
709    /// launch (no Landlock) — the coverage the sandbox-launch integration test
710    /// cannot provide on kernels below the required Landlock ABI.
711    #[test]
712    fn capture_save_load_roundtrips() {
713        let mut child = Command::new("sleep")
714            .arg("30")
715            .spawn()
716            .expect("spawn sleep child");
717        let pid = child.id() as i32;
718
719        let policy = Sandbox::builder().build().expect("build policy");
720        let captured = capture(pid, &policy);
721
722        let _ = child.kill();
723        let _ = child.wait();
724
725        let cp = captured.expect("capture should succeed on this architecture");
726        assert!(!cp.process_state.regs.is_empty(), "captured registers");
727        assert!(!cp.process_state.memory_maps.is_empty(), "captured memory maps");
728        assert!(!cp.fd_table.is_empty(), "captured fd table");
729
730        // Save to a temp dir, load it back, and confirm the round-trip is faithful.
731        let dir = std::env::temp_dir()
732            .join(format!("sandlock-cp-roundtrip-{}", std::process::id()));
733        cp.save(&dir).expect("save checkpoint");
734        let loaded = Checkpoint::load(&dir).expect("load checkpoint");
735        let _ = std::fs::remove_dir_all(&dir);
736
737        assert_eq!(loaded.process_state.regs, cp.process_state.regs, "regs roundtrip");
738        assert_eq!(
739            loaded.process_state.memory_data.len(),
740            cp.process_state.memory_data.len(),
741            "memory segment count roundtrip"
742        );
743        assert_eq!(loaded.fd_table.len(), cp.fd_table.len(), "fd count roundtrip");
744        assert_eq!(loaded.process_state.pid, cp.process_state.pid, "pid roundtrip");
745        assert!(!loaded.process_state.exe.is_empty(), "exe path captured");
746    }
747}