Skip to content

Commit 9e89bf1

Browse files
authored
Merge pull request #105 from multikernel/checkpoint-restore
Checkpoint/restore for sandlock sandboxes (transparent ptrace-injection)
2 parents c590134 + eccfa17 commit 9e89bf1

16 files changed

Lines changed: 2665 additions & 385 deletions

File tree

crates/sandlock-core/src/checkpoint.rs renamed to crates/sandlock-core/src/checkpoint/capture.rs

Lines changed: 64 additions & 343 deletions
Large diffs are not rendered by default.
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
use serde::{Serialize, Deserialize};
2+
use std::path::{Path, PathBuf};
3+
use super::{Checkpoint, ProcessState, MemorySegment, MemoryMap, FdInfo};
4+
use crate::error::{SandlockError, SandboxRuntimeError};
5+
use crate::sandbox::Sandbox;
6+
7+
// ---------------------------------------------------------------------------
8+
// Save / Load -- directory-based format
9+
// ---------------------------------------------------------------------------
10+
//
11+
// Layout:
12+
// <dir>/
13+
// ├── meta.json # name, cow_snapshot
14+
// ├── policy.dat # bincode-serialized Sandbox
15+
// ├── app_state.bin # optional raw app state
16+
// └── process/
17+
// ├── info.json # pid, cwd, exe
18+
// ├── fds.json # file descriptor table
19+
// ├── memory_map.json # FULL region metadata (every mapping)
20+
// ├── threads/
21+
// │ └── 0.bin # raw register bytes (main thread)
22+
// └── memory/
23+
// └── <start_hex>.bin # captured bytes for a region, keyed by its
24+
// # start address (only regions with data)
25+
//
26+
// `memory_map.json` lists *every* mapping (not just captured ones) so that
27+
// restore can `RemapFromFile` the file-backed regions (e.g. the executable
28+
// text) that were never captured into `memory/`. Each `memory/<start_hex>.bin`
29+
// is re-associated with its map entry by matching start address.
30+
31+
const IMAGE_VERSION: u32 = 2;
32+
33+
fn io_err(e: impl std::fmt::Display) -> SandlockError {
34+
SandlockError::Runtime(SandboxRuntimeError::Child(e.to_string()))
35+
}
36+
37+
fn write_json<T: Serialize>(path: &Path, val: &T) -> Result<(), SandlockError> {
38+
let json = serde_json::to_string_pretty(val).map_err(io_err)?;
39+
std::fs::write(path, json).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))
40+
}
41+
42+
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, SandlockError> {
43+
let data = std::fs::read_to_string(path)
44+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
45+
serde_json::from_str(&data).map_err(io_err)
46+
}
47+
48+
/// JSON schema for meta.json.
49+
#[derive(Serialize, Deserialize)]
50+
struct MetaJson {
51+
name: String,
52+
cow_snapshot: Option<String>,
53+
#[serde(default)]
54+
version: u32,
55+
}
56+
57+
/// JSON schema for process/info.json.
58+
#[derive(Serialize, Deserialize)]
59+
struct InfoJson {
60+
pid: i32,
61+
cwd: String,
62+
exe: String,
63+
}
64+
65+
/// JSON schema for each entry in process/fds.json.
66+
#[derive(Serialize, Deserialize)]
67+
struct FdJson {
68+
fd: i32,
69+
path: String,
70+
flags: i32,
71+
offset: u64,
72+
}
73+
74+
/// JSON schema for each entry in process/memory_map.json.
75+
#[derive(Serialize, Deserialize)]
76+
struct MemoryMapJson {
77+
start: u64,
78+
end: u64,
79+
perms: String,
80+
offset: u64,
81+
path: Option<String>,
82+
}
83+
84+
impl Checkpoint {
85+
/// Persist this checkpoint to a directory.
86+
///
87+
/// Writes atomically: creates `<dir>.tmp`, populates it, then renames.
88+
pub fn save(&self, dir: &Path) -> Result<(), SandlockError> {
89+
let tmp = dir.with_extension("tmp");
90+
if tmp.exists() {
91+
std::fs::remove_dir_all(&tmp)
92+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
93+
}
94+
std::fs::create_dir_all(&tmp)
95+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
96+
97+
let res = self.save_inner(&tmp);
98+
if res.is_err() {
99+
let _ = std::fs::remove_dir_all(&tmp);
100+
return res;
101+
}
102+
103+
// Atomic rename into place
104+
if dir.exists() {
105+
std::fs::remove_dir_all(dir)
106+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
107+
}
108+
std::fs::rename(&tmp, dir)
109+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
110+
111+
Ok(())
112+
}
113+
114+
fn save_inner(&self, dir: &Path) -> Result<(), SandlockError> {
115+
// meta.json
116+
write_json(&dir.join("meta.json"), &MetaJson {
117+
name: self.name.clone(),
118+
cow_snapshot: self.cow_snapshot.as_ref().map(|p| p.display().to_string()),
119+
version: IMAGE_VERSION,
120+
})?;
121+
122+
// policy.dat (bincode -- complex struct, not human-readable anyway)
123+
let policy_bytes = bincode::serialize(&self.policy).map_err(io_err)?;
124+
std::fs::write(dir.join("policy.dat"), &policy_bytes)
125+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
126+
127+
// app_state.bin
128+
if let Some(ref state) = self.app_state {
129+
std::fs::write(dir.join("app_state.bin"), state)
130+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
131+
}
132+
133+
// process/
134+
let proc_dir = dir.join("process");
135+
std::fs::create_dir(&proc_dir)
136+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
137+
138+
// process/info.json
139+
write_json(&proc_dir.join("info.json"), &InfoJson {
140+
pid: self.process_state.pid,
141+
cwd: self.process_state.cwd.clone(),
142+
exe: self.process_state.exe.clone(),
143+
})?;
144+
145+
// process/fds.json
146+
let fds: Vec<FdJson> = self.fd_table.iter().map(|f| FdJson {
147+
fd: f.fd,
148+
path: f.path.clone(),
149+
flags: f.flags,
150+
offset: f.offset,
151+
}).collect();
152+
write_json(&proc_dir.join("fds.json"), &fds)?;
153+
154+
// process/memory_map.json -- FULL region metadata (every mapping), so
155+
// restore can RemapFromFile the file-backed regions (e.g. the
156+
// executable text) that were never captured into memory_data.
157+
let maps: Vec<MemoryMapJson> = self.process_state.memory_maps.iter().map(|m| MemoryMapJson {
158+
start: m.start,
159+
end: m.end,
160+
perms: m.perms.clone(),
161+
offset: m.offset,
162+
path: m.path.clone(),
163+
}).collect();
164+
write_json(&proc_dir.join("memory_map.json"), &maps)?;
165+
166+
// process/threads/0.bin -- main thread register state
167+
let threads_dir = proc_dir.join("threads");
168+
std::fs::create_dir(&threads_dir)
169+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
170+
let reg_bytes: Vec<u8> = self.process_state.regs.iter()
171+
.flat_map(|r| r.to_le_bytes())
172+
.collect();
173+
std::fs::write(threads_dir.join("0.bin"), &reg_bytes)
174+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
175+
// process/threads/fpregs.bin -- FPU/extended register state
176+
std::fs::write(threads_dir.join("fpregs.bin"), &self.process_state.fpregs)
177+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
178+
179+
// process/memory/<start_hex>.bin -- captured segment bytes, keyed by
180+
// start address so load can re-associate each blob with its (full) map
181+
// entry regardless of how many uncaptured maps sit between them.
182+
let mem_dir = proc_dir.join("memory");
183+
std::fs::create_dir(&mem_dir)
184+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
185+
for seg in self.process_state.memory_data.iter() {
186+
std::fs::write(mem_dir.join(format!("{:x}.bin", seg.start)), &seg.data)
187+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
188+
}
189+
190+
Ok(())
191+
}
192+
193+
/// Load a checkpoint from a directory.
194+
pub fn load(dir: &Path) -> Result<Self, SandlockError> {
195+
if !dir.is_dir() {
196+
return Err(SandlockError::Runtime(SandboxRuntimeError::Child(
197+
format!("Checkpoint not found: {}", dir.display()),
198+
)));
199+
}
200+
201+
// meta.json
202+
let meta: MetaJson = read_json(&dir.join("meta.json"))?;
203+
if meta.version != IMAGE_VERSION {
204+
return Err(SandlockError::Runtime(SandboxRuntimeError::Child(
205+
format!("unsupported checkpoint image version {} (expected {})",
206+
meta.version, IMAGE_VERSION),
207+
)));
208+
}
209+
210+
// policy.dat
211+
let policy_bytes = std::fs::read(dir.join("policy.dat"))
212+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
213+
let policy: Sandbox = bincode::deserialize(&policy_bytes).map_err(io_err)?;
214+
215+
// app_state.bin
216+
let app_state_path = dir.join("app_state.bin");
217+
let app_state = if app_state_path.exists() {
218+
Some(std::fs::read(&app_state_path)
219+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?)
220+
} else {
221+
None
222+
};
223+
224+
// process/
225+
let proc_dir = dir.join("process");
226+
227+
// process/info.json
228+
let info: InfoJson = read_json(&proc_dir.join("info.json"))?;
229+
230+
// process/fds.json
231+
let fds_json: Vec<FdJson> = read_json(&proc_dir.join("fds.json"))?;
232+
let fd_table: Vec<FdInfo> = fds_json.into_iter().map(|f| FdInfo {
233+
fd: f.fd,
234+
path: f.path,
235+
flags: f.flags,
236+
offset: f.offset,
237+
}).collect();
238+
239+
// process/memory_map.json -- FULL region list (every mapping).
240+
let maps_json: Vec<MemoryMapJson> = read_json(&proc_dir.join("memory_map.json"))?;
241+
let memory_maps: Vec<MemoryMap> = maps_json.iter().map(|m| MemoryMap {
242+
start: m.start,
243+
end: m.end,
244+
perms: m.perms.clone(),
245+
offset: m.offset,
246+
path: m.path.clone(),
247+
}).collect();
248+
249+
// process/threads/0.bin
250+
let threads_dir = proc_dir.join("threads");
251+
let reg_bytes = std::fs::read(threads_dir.join("0.bin"))
252+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
253+
let regs: Vec<u64> = reg_bytes.chunks_exact(8)
254+
.map(|chunk| u64::from_le_bytes(chunk.try_into().unwrap()))
255+
.collect();
256+
// Read FP/extended registers; tolerate absence defensively.
257+
let fpregs = std::fs::read(threads_dir.join("fpregs.bin")).unwrap_or_default();
258+
259+
// process/memory/<start_hex>.bin -- captured segments only, matched to
260+
// their map entry by start address. Maps without a blob (file-backed
261+
// regions like the executable text) carry no data and are restored via
262+
// RemapFromFile during resume.
263+
let mem_dir = proc_dir.join("memory");
264+
let mut memory_data = Vec::new();
265+
for map in &maps_json {
266+
let seg_path = mem_dir.join(format!("{:x}.bin", map.start));
267+
if seg_path.exists() {
268+
let data = std::fs::read(&seg_path)
269+
.map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?;
270+
memory_data.push(MemorySegment {
271+
start: map.start,
272+
data,
273+
});
274+
}
275+
}
276+
277+
Ok(Checkpoint {
278+
name: meta.name,
279+
policy,
280+
process_state: ProcessState {
281+
pid: info.pid,
282+
cwd: info.cwd,
283+
exe: info.exe,
284+
regs,
285+
fpregs,
286+
memory_maps,
287+
memory_data,
288+
},
289+
fd_table,
290+
cow_snapshot: meta.cow_snapshot.map(PathBuf::from),
291+
app_state,
292+
})
293+
}
294+
}
295+
296+
#[cfg(test)]
297+
mod tests {
298+
use super::Checkpoint;
299+
300+
#[test]
301+
fn image_rejects_wrong_version() {
302+
let dir = std::env::temp_dir().join(format!("sandlock-ver-{}", std::process::id()));
303+
let _ = std::fs::remove_dir_all(&dir);
304+
std::fs::create_dir_all(dir.join("process/threads")).unwrap();
305+
std::fs::create_dir_all(dir.join("process/memory")).unwrap();
306+
std::fs::write(dir.join("meta.json"),
307+
br#"{"name":"x","cow_snapshot":null,"version":999}"#).unwrap();
308+
let res = Checkpoint::load(&dir);
309+
let _ = std::fs::remove_dir_all(&dir);
310+
let msg = res.unwrap_err().to_string();
311+
assert!(msg.contains("version"), "error should mention version, got: {msg}");
312+
}
313+
}

0 commit comments

Comments
 (0)