|
| 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"), ®_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