1use crate::sandbox::{ByteSize, Sandbox};
2use crate::error::SandlockError;
3use serde::Deserialize;
4use std::path::PathBuf;
5use std::collections::HashMap;
6use std::time::SystemTime;
7
8#[derive(Debug, Clone, Default, PartialEq)]
11pub struct ProgramSpec {
12 pub exec: Option<PathBuf>,
13 pub args: Vec<String>,
14}
15
16#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
18#[serde(deny_unknown_fields, default)]
19pub struct ProfileInput {
20 pub config: ConfigSection,
21 pub determinism: DeterminismSection,
22 pub program: ProgramSection,
23 pub filesystem: FilesystemSection,
24 pub network: NetworkSection,
25 pub http: HttpSection,
26 pub syscalls: SyscallsSection,
27 pub limits: LimitsSection,
28}
29
30#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
32#[serde(deny_unknown_fields, default)]
33pub struct ConfigSection {
34 pub http_ca: Option<PathBuf>,
35 pub http_key: Option<PathBuf>,
36 pub fs_storage: Option<PathBuf>,
37 pub workdir: Option<PathBuf>,
38}
39
40#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
41#[serde(deny_unknown_fields, default)]
42pub struct DeterminismSection {
43 pub random_seed: Option<u64>,
44 pub time_start: Option<String>,
46 pub deterministic_dirs: bool,
47 pub no_randomize_memory: bool,
48}
49
50#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
51#[serde(deny_unknown_fields, default)]
52pub struct ProgramSection {
53 pub exec: Option<PathBuf>,
54 pub args: Vec<String>,
55 pub env: HashMap<String, String>,
56 pub cwd: Option<PathBuf>,
57 pub uid: Option<u32>,
58 pub clean_env: bool,
59 pub no_coredump: bool,
60 pub no_huge_pages: bool,
61}
62
63#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
64#[serde(deny_unknown_fields, default)]
65pub struct FilesystemSection {
66 pub read: Vec<PathBuf>,
67 pub write: Vec<PathBuf>,
68 pub deny: Vec<PathBuf>,
69 pub isolation: Option<String>,
71 pub chroot: Option<PathBuf>,
72 pub mount: Vec<String>,
74 pub on_exit: Option<String>,
76 pub on_error: Option<String>,
78}
79
80#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
81#[serde(deny_unknown_fields, default)]
82pub struct NetworkSection {
83 pub bind: Vec<u16>,
84 pub allow: Vec<String>,
85 pub port_remap: bool,
86}
87
88#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
89#[serde(deny_unknown_fields, default)]
90pub struct HttpSection {
91 pub ports: Vec<u16>,
92 pub allow: Vec<String>,
93 pub deny: Vec<String>,
94}
95
96#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
97#[serde(deny_unknown_fields, default)]
98pub struct SyscallsSection {
99 pub extra_allow: Vec<String>,
100 pub extra_deny: Vec<String>,
101}
102
103#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
107#[serde(deny_unknown_fields, default)]
108pub struct LimitsSection {
109 pub memory: Option<String>,
112 pub processes: Option<u32>,
113 pub open_files: Option<u32>,
114 pub cpu: Option<u8>,
116 pub disk: Option<String>,
119 pub gpu_devices: Option<Vec<u32>>,
120 pub cpu_cores: Option<Vec<u32>>,
121 pub num_cpus: Option<u32>,
122}
123
124pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
131 let mut b = Sandbox::builder();
132
133 if let Some(p) = input.config.http_ca { b = b.http_ca(p); }
135 if let Some(p) = input.config.http_key { b = b.http_key(p); }
136 if let Some(p) = input.config.fs_storage { b = b.fs_storage(p); }
137 if let Some(p) = input.config.workdir { b = b.workdir(p); }
138
139 if let Some(s) = input.determinism.random_seed { b = b.random_seed(s); }
141 if let Some(s) = input.determinism.time_start.as_deref() {
142 b = b.time_start(parse_time_start(s)?);
143 }
144 if input.determinism.deterministic_dirs { b = b.deterministic_dirs(true); }
145 if input.determinism.no_randomize_memory { b = b.no_randomize_memory(true); }
146
147 for (k, v) in input.program.env.iter() { b = b.env_var(k, v); }
149 if let Some(c) = input.program.cwd { b = b.cwd(c); }
150 if let Some(u) = input.program.uid { b = b.uid(u); }
151 if input.program.clean_env { b = b.clean_env(true); }
152 if input.program.no_coredump { b = b.no_coredump(true); }
153 if input.program.no_huge_pages { b = b.no_huge_pages(true); }
154
155 for p in input.filesystem.read.iter() { b = b.fs_read(p); }
157 for p in input.filesystem.write.iter() { b = b.fs_write(p); }
158 for p in input.filesystem.deny.iter() { b = b.fs_deny(p); }
159 if let Some(s) = input.filesystem.isolation.as_deref() {
160 b = b.fs_isolation(parse_fs_isolation(s)?);
161 }
162 if let Some(c) = input.filesystem.chroot { b = b.chroot(c); }
163 for spec in input.filesystem.mount.iter() {
164 let (virt, host) = parse_mount_spec(spec)?;
165 b = b.fs_mount(virt, host);
166 }
167 if let Some(s) = input.filesystem.on_exit.as_deref() { b = b.on_exit(parse_branch_action(s)?); }
168 if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); }
169
170 for p in input.network.bind.iter() { b = b.net_bind_port(*p); }
172 for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); }
173 if input.network.port_remap { b = b.port_remap(true); }
174
175 for p in input.http.ports.iter() { b = b.http_port(*p); }
177 for r in input.http.allow.iter() { b = b.http_allow(r); }
178 for r in input.http.deny.iter() { b = b.http_deny(r); }
179
180 if !input.syscalls.extra_allow.is_empty() {
182 b = b.extra_allow_syscalls(input.syscalls.extra_allow);
183 }
184 if !input.syscalls.extra_deny.is_empty() {
185 b = b.extra_deny_syscalls(input.syscalls.extra_deny);
186 }
187
188 if let Some(s) = input.limits.memory.as_deref() {
190 b = b.max_memory(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
191 }
192 if let Some(n) = input.limits.processes { b = b.max_processes(n); }
193 if let Some(n) = input.limits.open_files { b = b.max_open_files(n); }
194 if let Some(p) = input.limits.cpu { b = b.max_cpu(p); }
195 if let Some(s) = input.limits.disk.as_deref() {
196 b = b.max_disk(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
197 }
198 if let Some(g) = input.limits.gpu_devices { b = b.gpu_devices(g); }
199 if let Some(c) = input.limits.cpu_cores { b = b.cpu_cores(c); }
200 if let Some(n) = input.limits.num_cpus { b = b.num_cpus(n); }
201
202 let policy = b.build()?;
203 let spec = ProgramSpec { exec: input.program.exec, args: input.program.args };
204 Ok((policy, spec))
205}
206
207fn parse_fs_isolation(s: &str) -> Result<crate::sandbox::FsIsolation, SandlockError> {
209 use crate::error::SandboxError;
210 use crate::sandbox::FsIsolation;
211 Ok(match s {
212 "none" => FsIsolation::None,
213 "overlayfs" => FsIsolation::OverlayFs,
214 "branchfs" => FsIsolation::BranchFs,
215 other => return Err(SandlockError::Sandbox(SandboxError::Invalid(
216 format!("invalid fs isolation {other:?}; expected \"none\" | \"overlayfs\" | \"branchfs\""),
217 ))),
218 })
219}
220
221fn parse_branch_action(s: &str) -> Result<crate::sandbox::BranchAction, SandlockError> {
223 use crate::error::SandboxError;
224 use crate::sandbox::BranchAction;
225 Ok(match s {
226 "commit" => BranchAction::Commit,
227 "abort" => BranchAction::Abort,
228 "keep" => BranchAction::Keep,
229 other => return Err(SandlockError::Sandbox(SandboxError::Invalid(
230 format!("invalid branch action {other:?}; expected \"commit\" | \"abort\" | \"keep\""),
231 ))),
232 })
233}
234
235fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf), SandlockError> {
237 use crate::error::SandboxError;
238 let (virt, host) = s.split_once(':').ok_or_else(|| SandlockError::Sandbox(SandboxError::Invalid(
239 format!("invalid mount spec {s:?}; expected \"VIRTUAL:HOST\""),
240 )))?;
241 if virt.is_empty() || host.is_empty() {
242 return Err(SandlockError::Sandbox(SandboxError::Invalid(
243 format!("invalid mount spec {s:?}; both VIRTUAL and HOST must be non-empty"),
244 )));
245 }
246 Ok((PathBuf::from(virt), PathBuf::from(host)))
247}
248
249fn parse_time_start(s: &str) -> Result<SystemTime, SandlockError> {
251 use crate::error::SandboxError;
252 let ts: jiff::Timestamp = s.parse().map_err(|e| {
253 SandlockError::Sandbox(SandboxError::Invalid(
254 format!("invalid [determinism].time_start {s:?}: {e}"),
255 ))
256 })?;
257 Ok(ts.into())
258}
259
260pub fn profile_dir() -> PathBuf {
262 dirs_or_fallback().join("profiles")
263}
264
265fn dirs_or_fallback() -> PathBuf {
266 std::env::var("XDG_CONFIG_HOME")
267 .map(PathBuf::from)
268 .unwrap_or_else(|_| {
269 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
270 PathBuf::from(home).join(".config")
271 })
272 .join("sandlock")
273}
274
275pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
277 let input: ProfileInput = toml::from_str(content)
278 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
279 format!("TOML parse error: {e}"),
280 )))?;
281 parse_input(input)
282}
283
284pub fn load_profile(name: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
286 let path = profile_dir().join(format!("{}.toml", name));
287 let content = std::fs::read_to_string(&path)
288 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
289 format!("profile '{}': {}", name, e),
290 )))?;
291 parse_profile(&content)
292}
293
294pub fn list_profiles() -> Result<Vec<String>, SandlockError> {
296 let dir = profile_dir();
297 if !dir.exists() { return Ok(Vec::new()); }
298 let mut names = Vec::new();
299 for entry in std::fs::read_dir(&dir)
300 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(format!("read dir: {}", e))))? {
301 if let Ok(entry) = entry {
302 if let Some(name) = entry.path().file_stem() {
303 if entry.path().extension().map_or(false, |e| e == "toml") {
304 names.push(name.to_string_lossy().into_owned());
305 }
306 }
307 }
308 }
309 names.sort();
310 Ok(names)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn list_profiles_empty_dir() {
319 std::env::set_var("XDG_CONFIG_HOME", "/tmp/sandlock-test-nonexistent");
321 let profiles = list_profiles().unwrap();
322 assert!(profiles.is_empty());
323 std::env::remove_var("XDG_CONFIG_HOME");
324 }
325
326 #[test]
327 fn profile_input_deserializes_minimal() {
328 let toml = r#"
329 [program]
330 exec = "/bin/true"
331 "#;
332 let parsed: ProfileInput = toml::from_str(toml).unwrap();
333 assert_eq!(parsed.program.exec, Some("/bin/true".into()));
334 assert!(parsed.program.args.is_empty());
335 assert_eq!(parsed.config, ConfigSection::default());
336 assert_eq!(parsed.filesystem, FilesystemSection::default());
337 }
338
339 #[test]
340 fn config_section_maps_to_policy_http_fields() {
341 let toml = r#"
342 [config]
343 http_ca = "/tmp/ca.pem"
344 http_key = "/tmp/ca.key"
345 [program]
346 exec = "/bin/true"
347 "#;
348 let input: ProfileInput = toml::from_str(toml).unwrap();
349 let (policy, _spec) = parse_input(input).unwrap();
350 assert_eq!(policy.http_ca.as_deref(), Some(std::path::Path::new("/tmp/ca.pem")));
351 assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key")));
352 }
353
354 #[test]
355 fn syscalls_extra_allow_sysv_ipc_sets_vec() {
356 let toml = r#"
357 [program]
358 exec = "/bin/true"
359 [syscalls]
360 extra_allow = ["sysv_ipc"]
361 extra_deny = ["ptrace"]
362 "#;
363 let input: ProfileInput = toml::from_str(toml).unwrap();
364 let (policy, _spec) = parse_input(input).unwrap();
365 assert!(policy.allows_sysv_ipc());
366 assert_eq!(policy.extra_deny_syscalls, vec!["ptrace".to_string()]);
367 }
368
369 #[test]
370 fn parse_mount_spec_rejects_missing_colon() {
371 let toml = r#"
372 [program]
373 exec = "/bin/true"
374 [filesystem]
375 mount = ["nocolon"]
376 "#;
377 let input: ProfileInput = toml::from_str(toml).unwrap();
378 let err = parse_input(input).unwrap_err();
379 let msg = format!("{err}");
380 assert!(msg.contains("VIRTUAL:HOST"), "got: {msg}");
381 }
382
383 #[test]
384 fn parse_mount_spec_rejects_empty_half() {
385 let toml = r#"
386 [program]
387 exec = "/bin/true"
388 [filesystem]
389 mount = [":/host"]
390 "#;
391 let input: ProfileInput = toml::from_str(toml).unwrap();
392 let err = parse_input(input).unwrap_err();
393 let msg = format!("{err}");
394 assert!(msg.contains("non-empty"), "got: {msg}");
395 }
396
397 #[test]
398 fn parse_profile_full_example() {
399 let toml = r#"
400 [config]
401 http_ca = "/etc/sandlock/ca.pem"
402 http_key = "/etc/sandlock/ca.key"
403 fs_storage = "/var/sandlock/redis-worker"
404 workdir = "/var/sandlock/redis-worker/work"
405
406 [determinism]
407 random_seed = 42
408 deterministic_dirs = true
409 no_randomize_memory = true
410
411 [program]
412 exec = "/usr/bin/redis-cli"
413 args = ["-h", "cache.internal", "-p", "6379"]
414 cwd = "/var/lib/redis"
415 uid = 1000
416 clean_env = true
417 no_coredump = true
418
419 [filesystem]
420 read = ["/usr", "/etc/redis"]
421 write = ["/var/lib/redis/state"]
422 deny = ["/proc/sys"]
423 isolation = "overlayfs"
424 chroot = "/var/lib/redis-rootfs"
425 mount = ["/data:/srv/redis-data"]
426 on_exit = "commit"
427 on_error = "abort"
428
429 [network]
430 bind = [8080]
431 allow = ["tcp://cache.internal:6379"]
432 port_remap = true
433
434 [http]
435 ports = [80, 443]
436 allow = ["GET api.internal/v1/*"]
437 deny = ["* */admin/*"]
438
439 [syscalls]
440 extra_allow = ["sysv_ipc"]
441 extra_deny = ["ptrace", "mount"]
442
443 [limits]
444 memory = "512M"
445 processes = 32
446 cpu = 80
447 "#;
448
449 let (policy, spec) = parse_profile(toml).unwrap();
450 assert_eq!(spec.exec.as_deref(), Some(std::path::Path::new("/usr/bin/redis-cli")));
451 assert_eq!(spec.args.len(), 4);
452 assert!(policy.allows_sysv_ipc());
453 assert_eq!(policy.extra_deny_syscalls.len(), 2);
454 assert_eq!(policy.fs_readable.len(), 2);
455 assert!(policy.net_allow.len() >= 2);
459 assert_eq!(policy.http_allow.len(), 1);
460 assert_eq!(policy.fs_mount.len(), 1);
461 }
462
463 #[test]
464 fn parse_profile_unknown_section_field_is_error() {
465 let toml = r#"
466 [program]
467 exec = "/bin/true"
468 bogus = 1
469 "#;
470 let err = parse_profile(toml).unwrap_err();
471 let msg = format!("{err}");
472 assert!(msg.contains("unknown field"), "got: {msg}");
473 }
474
475 #[test]
476 fn parse_profile_old_flat_format_is_error() {
477 let toml = r#"
479 fs_readable = ["/usr"]
480 "#;
481 let err = parse_profile(toml).unwrap_err();
482 let msg = format!("{err}");
483 assert!(msg.contains("unknown field"), "got: {msg}");
484 }
485
486 #[test]
487 fn parse_profile_time_start_sets_policy_field() {
488 let toml = r#"
489 [program]
490 exec = "/bin/true"
491 [determinism]
492 time_start = "2026-01-01T00:00:00Z"
493 "#;
494 let (policy, _spec) = parse_profile(toml).unwrap();
495 assert!(policy.time_start.is_some());
496 }
497
498 #[test]
499 fn parse_profile_invalid_time_start_is_error() {
500 let toml = r#"
501 [program]
502 exec = "/bin/true"
503 [determinism]
504 time_start = "not-a-time"
505 "#;
506 let err = parse_profile(toml).unwrap_err();
507 let msg = format!("{err}");
508 assert!(msg.contains("time_start"), "got: {msg}");
509 }
510
511 #[test]
512 fn isolation_overlayfs_without_workdir_is_error() {
513 let toml = r#"
514 [program]
515 exec = "/bin/true"
516 [filesystem]
517 isolation = "overlayfs"
518 "#;
519 let err = parse_profile(toml).unwrap_err();
520 let msg = format!("{err}");
521 assert!(
522 msg.to_lowercase().contains("workdir"),
523 "expected error to mention workdir; got: {msg}"
524 );
525 }
526
527 #[test]
528 fn isolation_none_without_workdir_is_ok() {
529 let toml = r#"
530 [program]
531 exec = "/bin/true"
532 [filesystem]
533 isolation = "none"
534 "#;
535 let (_p, _s) = parse_profile(toml).unwrap();
536 }
537
538 #[test]
539 fn isolation_overlayfs_with_workdir_is_ok() {
540 let toml = r#"
541 [program]
542 exec = "/bin/true"
543 [config]
544 workdir = "/tmp/wd"
545 [filesystem]
546 isolation = "overlayfs"
547 "#;
548 let (_p, _s) = parse_profile(toml).unwrap();
549 }
550}