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 http_inject_ca: Vec<PathBuf>,
37 pub http_ca_out: Option<PathBuf>,
38 pub fs_storage: Option<PathBuf>,
39 pub workdir: Option<PathBuf>,
40}
41
42#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
43#[serde(deny_unknown_fields, default)]
44pub struct DeterminismSection {
45 pub random_seed: Option<u64>,
46 pub time_start: Option<String>,
48 pub deterministic_dirs: bool,
49 pub no_randomize_memory: bool,
50}
51
52#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
53#[serde(deny_unknown_fields, default)]
54pub struct ProgramSection {
55 pub exec: Option<PathBuf>,
56 pub args: Vec<String>,
57 pub env: HashMap<String, String>,
58 pub cwd: Option<PathBuf>,
59 pub uid: Option<u32>,
60 pub gid: Option<u32>,
61 pub clean_env: bool,
62 pub no_coredump: bool,
63 pub no_huge_pages: bool,
64}
65
66#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
67#[serde(deny_unknown_fields, default)]
68pub struct FilesystemSection {
69 pub read: Vec<PathBuf>,
70 pub write: Vec<PathBuf>,
71 pub deny: Vec<PathBuf>,
72 pub chroot: Option<PathBuf>,
73 pub mount: Vec<String>,
75 pub on_exit: Option<String>,
77 pub on_error: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize, PartialEq)]
86#[serde(untagged)]
87pub enum PortSpec {
88 Port(u16),
89 Spec(String),
90}
91
92#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
93#[serde(deny_unknown_fields, default)]
94pub struct NetworkSection {
95 pub allow_bind: Vec<PortSpec>,
96 pub deny_bind: Vec<PortSpec>,
97 pub allow: Vec<String>,
98 pub deny: Vec<String>,
99 pub port_remap: bool,
100}
101
102#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
103#[serde(deny_unknown_fields, default)]
104pub struct HttpSection {
105 pub ports: Vec<u16>,
106 pub allow: Vec<String>,
107 pub deny: Vec<String>,
108}
109
110#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
111#[serde(deny_unknown_fields, default)]
112pub struct SyscallsSection {
113 pub extra_allow: Vec<String>,
114 pub extra_deny: Vec<String>,
115}
116
117#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
121#[serde(deny_unknown_fields, default)]
122pub struct LimitsSection {
123 pub memory: Option<String>,
126 pub processes: Option<u32>,
127 pub open_files: Option<u32>,
128 pub cpu: Option<u8>,
130 pub disk: Option<String>,
133 pub gpu_devices: Option<Vec<u32>>,
134 pub cpu_cores: Option<Vec<u32>>,
135 pub num_cpus: Option<u32>,
136}
137
138pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
145 let mut b = Sandbox::builder();
146
147 if let Some(p) = input.config.http_ca { b = b.http_ca(p); }
149 if let Some(p) = input.config.http_key { b = b.http_key(p); }
150 for p in input.config.http_inject_ca { b = b.http_inject_ca(p); }
151 if let Some(p) = input.config.http_ca_out { b = b.http_ca_out(p); }
152 if let Some(p) = input.config.fs_storage { b = b.fs_storage(p); }
153 if let Some(p) = input.config.workdir { b = b.workdir(p); }
154
155 if let Some(s) = input.determinism.random_seed { b = b.random_seed(s); }
157 if let Some(s) = input.determinism.time_start.as_deref() {
158 b = b.time_start(parse_time_start(s)?);
159 }
160 if input.determinism.deterministic_dirs { b = b.deterministic_dirs(true); }
161 if input.determinism.no_randomize_memory { b = b.no_randomize_memory(true); }
162
163 for (k, v) in input.program.env.iter() { b = b.env_var(k, v); }
165 if let Some(c) = input.program.cwd { b = b.cwd(c); }
166 match (input.program.uid, input.program.gid) {
167 (Some(u), Some(g)) => b = b.user(u, g),
168 (None, None) => {}
169 _ => return Err(SandlockError::Sandbox(crate::error::SandboxError::Invalid(
170 "program.uid and program.gid must both be set".into(),
171 ))),
172 }
173 if input.program.clean_env { b = b.clean_env(true); }
174 if input.program.no_coredump { b = b.no_coredump(true); }
175 if input.program.no_huge_pages { b = b.no_huge_pages(true); }
176
177 for p in input.filesystem.read.iter() { b = b.fs_read(p); }
179 for p in input.filesystem.write.iter() { b = b.fs_write(p); }
180 for p in input.filesystem.deny.iter() { b = b.fs_deny(p); }
181 if let Some(c) = input.filesystem.chroot { b = b.chroot(c); }
182 for spec in input.filesystem.mount.iter() {
183 let (virt, host, read_only) = parse_mount_spec(spec)?;
184 b = if read_only { b.fs_mount_ro(virt, host) } else { b.fs_mount(virt, host) };
185 }
186 if let Some(s) = input.filesystem.on_exit.as_deref() { b = b.on_exit(parse_branch_action(s)?); }
187 if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); }
188
189 for entry in input.network.allow_bind.iter() {
191 b = match entry {
192 PortSpec::Port(p) => b.net_allow_bind_port(*p),
193 PortSpec::Spec(s) => b.net_allow_bind(s),
194 };
195 }
196 for entry in input.network.deny_bind.iter() {
197 b = match entry {
198 PortSpec::Port(p) => b.net_deny_bind_port(*p),
199 PortSpec::Spec(s) => b.net_deny_bind(s),
200 };
201 }
202 for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); }
203 for r in input.network.deny.iter() { b = b.net_deny(r.as_str()); }
204 if input.network.port_remap { b = b.port_remap(true); }
205
206 for p in input.http.ports.iter() { b = b.http_port(*p); }
208 for r in input.http.allow.iter() { b = b.http_allow(r); }
209 for r in input.http.deny.iter() { b = b.http_deny(r); }
210
211 if !input.syscalls.extra_allow.is_empty() {
213 b = b.extra_allow_syscalls(input.syscalls.extra_allow);
214 }
215 if !input.syscalls.extra_deny.is_empty() {
216 b = b.extra_deny_syscalls(input.syscalls.extra_deny);
217 }
218
219 if let Some(s) = input.limits.memory.as_deref() {
221 b = b.max_memory(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
222 }
223 if let Some(n) = input.limits.processes { b = b.max_processes(n); }
224 if let Some(n) = input.limits.open_files { b = b.max_open_files(n); }
225 if let Some(p) = input.limits.cpu { b = b.max_cpu(p); }
226 if let Some(s) = input.limits.disk.as_deref() {
227 b = b.max_disk(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
228 }
229 if let Some(g) = input.limits.gpu_devices { b = b.gpu_devices(g); }
230 if let Some(c) = input.limits.cpu_cores { b = b.cpu_cores(c); }
231 if let Some(n) = input.limits.num_cpus { b = b.num_cpus(n); }
232
233 let policy = b.build()?;
234 let spec = ProgramSpec { exec: input.program.exec, args: input.program.args };
235 Ok((policy, spec))
236}
237
238fn parse_branch_action(s: &str) -> Result<crate::sandbox::BranchAction, SandlockError> {
240 use crate::error::SandboxError;
241 use crate::sandbox::BranchAction;
242 Ok(match s {
243 "commit" => BranchAction::Commit,
244 "abort" => BranchAction::Abort,
245 "keep" => BranchAction::Keep,
246 other => return Err(SandlockError::Sandbox(SandboxError::Invalid(
247 format!("invalid branch action {other:?}; expected \"commit\" | \"abort\" | \"keep\""),
248 ))),
249 })
250}
251
252pub fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf, bool), SandlockError> {
257 use crate::error::SandboxError;
258 let (body, read_only) = if let Some(b) = s.strip_suffix(":ro") {
259 (b, true)
260 } else if let Some(b) = s.strip_suffix(":rw") {
261 (b, false)
262 } else {
263 (s, false)
264 };
265 let (virt, host) = body.split_once(':').ok_or_else(|| SandlockError::Sandbox(SandboxError::Invalid(
266 format!("invalid mount spec {s:?}; expected \"VIRTUAL:HOST[:ro]\""),
267 )))?;
268 if virt.is_empty() || host.is_empty() {
269 return Err(SandlockError::Sandbox(SandboxError::Invalid(
270 format!("invalid mount spec {s:?}; both VIRTUAL and HOST must be non-empty"),
271 )));
272 }
273 Ok((PathBuf::from(virt), PathBuf::from(host), read_only))
274}
275
276fn parse_time_start(s: &str) -> Result<SystemTime, SandlockError> {
278 use crate::error::SandboxError;
279 let ts: jiff::Timestamp = s.parse().map_err(|e| {
280 SandlockError::Sandbox(SandboxError::Invalid(
281 format!("invalid [determinism].time_start {s:?}: {e}"),
282 ))
283 })?;
284 Ok(ts.into())
285}
286
287pub fn profile_dir() -> PathBuf {
289 dirs_or_fallback().join("profiles")
290}
291
292fn dirs_or_fallback() -> PathBuf {
293 std::env::var("XDG_CONFIG_HOME")
294 .map(PathBuf::from)
295 .unwrap_or_else(|_| {
296 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
297 PathBuf::from(home).join(".config")
298 })
299 .join("sandlock")
300}
301
302pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
304 let input: ProfileInput = toml::from_str(content)
305 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
306 format!("TOML parse error: {e}"),
307 )))?;
308 parse_input(input)
309}
310
311pub fn load_profile(name: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
313 let path = profile_dir().join(format!("{}.toml", name));
314 let content = std::fs::read_to_string(&path)
315 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
316 format!("profile '{}': {}", name, e),
317 )))?;
318 parse_profile(&content)
319}
320
321pub fn list_profiles() -> Result<Vec<String>, SandlockError> {
323 let dir = profile_dir();
324 if !dir.exists() { return Ok(Vec::new()); }
325 let mut names = Vec::new();
326 for entry in std::fs::read_dir(&dir)
327 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(format!("read dir: {}", e))))? {
328 if let Ok(entry) = entry {
329 if let Some(name) = entry.path().file_stem() {
330 if entry.path().extension().map_or(false, |e| e == "toml") {
331 names.push(name.to_string_lossy().into_owned());
332 }
333 }
334 }
335 }
336 names.sort();
337 Ok(names)
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn list_profiles_empty_dir() {
346 std::env::set_var("XDG_CONFIG_HOME", "/tmp/sandlock-test-nonexistent");
348 let profiles = list_profiles().unwrap();
349 assert!(profiles.is_empty());
350 std::env::remove_var("XDG_CONFIG_HOME");
351 }
352
353 #[test]
354 fn profile_input_deserializes_minimal() {
355 let toml = r#"
356 [program]
357 exec = "/bin/true"
358 "#;
359 let parsed: ProfileInput = toml::from_str(toml).unwrap();
360 assert_eq!(parsed.program.exec, Some("/bin/true".into()));
361 assert!(parsed.program.args.is_empty());
362 assert_eq!(parsed.config, ConfigSection::default());
363 assert_eq!(parsed.filesystem, FilesystemSection::default());
364 }
365
366 #[test]
367 fn config_section_maps_to_policy_http_fields() {
368 let toml = r#"
369 [config]
370 http_ca = "/tmp/ca.pem"
371 http_key = "/tmp/ca.key"
372 [program]
373 exec = "/bin/true"
374 "#;
375 let input: ProfileInput = toml::from_str(toml).unwrap();
376 let (policy, _spec) = parse_input(input).unwrap();
377 assert_eq!(policy.http_ca.as_deref(), Some(std::path::Path::new("/tmp/ca.pem")));
378 assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key")));
379 }
380
381 #[test]
382 fn parses_http_inject_ca_and_ca_out() {
383 let toml = r#"
384 [config]
385 http_inject_ca = ["/etc/ssl/certs/ca-certificates.crt"]
386 http_ca_out = "/tmp/ca.pem"
387 [http]
388 allow = ["GET example.com/*"]
389 [program]
390 exec = "/bin/true"
391 "#;
392 let input: ProfileInput = toml::from_str(toml).unwrap();
393 let (policy, _prog) = parse_input(input).unwrap();
394 assert_eq!(policy.http_inject_ca.len(), 1);
395 assert_eq!(policy.http_ca_out.as_deref(), Some(std::path::Path::new("/tmp/ca.pem")));
396 }
397
398 #[test]
399 fn syscalls_extra_allow_sysv_ipc_sets_vec() {
400 let toml = r#"
401 [program]
402 exec = "/bin/true"
403 [syscalls]
404 extra_allow = ["sysv_ipc"]
405 extra_deny = ["ptrace"]
406 "#;
407 let input: ProfileInput = toml::from_str(toml).unwrap();
408 let (policy, _spec) = parse_input(input).unwrap();
409 assert!(policy.allows_sysv_ipc());
410 assert_eq!(policy.extra_deny_syscalls, vec!["ptrace".to_string()]);
411 }
412
413 #[test]
414 fn parse_mount_spec_ro_suffix() {
415 let (v, h, ro) = parse_mount_spec("/work:/host").unwrap();
416 assert_eq!((v.to_str().unwrap(), h.to_str().unwrap(), ro), ("/work", "/host", false));
417 let (_, _, ro) = parse_mount_spec("/work:/host:rw").unwrap();
418 assert!(!ro);
419 let (v, h, ro) = parse_mount_spec("/work:/host:ro").unwrap();
420 assert_eq!((v.to_str().unwrap(), h.to_str().unwrap(), ro), ("/work", "/host", true));
421 let (_, h, ro) = parse_mount_spec("/v:/a:b:ro").unwrap();
423 assert_eq!((h.to_str().unwrap(), ro), ("/a:b", true));
424 assert!(parse_mount_spec("nocolon").is_err());
425 }
426
427 #[test]
428 fn parse_mount_spec_rejects_missing_colon() {
429 let toml = r#"
430 [program]
431 exec = "/bin/true"
432 [filesystem]
433 mount = ["nocolon"]
434 "#;
435 let input: ProfileInput = toml::from_str(toml).unwrap();
436 let err = parse_input(input).unwrap_err();
437 let msg = format!("{err}");
438 assert!(msg.contains("VIRTUAL:HOST"), "got: {msg}");
439 }
440
441 #[test]
442 fn parse_mount_spec_rejects_empty_half() {
443 let toml = r#"
444 [program]
445 exec = "/bin/true"
446 [filesystem]
447 mount = [":/host"]
448 "#;
449 let input: ProfileInput = toml::from_str(toml).unwrap();
450 let err = parse_input(input).unwrap_err();
451 let msg = format!("{err}");
452 assert!(msg.contains("non-empty"), "got: {msg}");
453 }
454
455 #[test]
456 fn parse_profile_full_example() {
457 let toml = r#"
458 [config]
459 http_ca = "/etc/sandlock/ca.pem"
460 http_key = "/etc/sandlock/ca.key"
461 fs_storage = "/var/sandlock/redis-worker"
462 workdir = "/var/sandlock/redis-worker/work"
463
464 [determinism]
465 random_seed = 42
466 deterministic_dirs = true
467 no_randomize_memory = true
468
469 [program]
470 exec = "/usr/bin/redis-cli"
471 args = ["-h", "cache.internal", "-p", "6379"]
472 cwd = "/var/lib/redis"
473 uid = 1000
474 gid = 1000
475 clean_env = true
476 no_coredump = true
477
478 [filesystem]
479 read = ["/usr", "/etc/redis"]
480 write = ["/var/lib/redis/state"]
481 deny = ["/proc/sys"]
482 chroot = "/var/lib/redis-rootfs"
483 mount = ["/data:/srv/redis-data"]
484 on_exit = "commit"
485 on_error = "abort"
486
487 [network]
488 allow_bind = [8080, "9000-9002"]
489 allow = ["tcp://cache.internal:6379"]
490 port_remap = true
491
492 [http]
493 ports = [80, 443]
494 allow = ["GET api.internal/v1/*"]
495 deny = ["* */admin/*"]
496
497 [syscalls]
498 extra_allow = ["sysv_ipc"]
499 extra_deny = ["ptrace", "mount"]
500
501 [limits]
502 memory = "512M"
503 processes = 32
504 cpu = 80
505 "#;
506
507 let (policy, spec) = parse_profile(toml).unwrap();
508 assert_eq!(spec.exec.as_deref(), Some(std::path::Path::new("/usr/bin/redis-cli")));
509 assert_eq!(spec.args.len(), 4);
510 assert!(policy.allows_sysv_ipc());
511 assert_eq!(policy.extra_deny_syscalls.len(), 2);
512 assert_eq!(policy.fs_readable.len(), 2);
513 assert!(policy.net_allow.len() >= 2);
517 assert_eq!(
519 policy.net_allow_bind,
520 crate::sandbox::BindPorts::Ports(vec![8080, 9000, 9001, 9002])
521 );
522 assert_eq!(policy.http_allow.len(), 1);
523 assert_eq!(policy.fs_mount.len(), 1);
524 }
525
526 #[test]
527 fn parse_profile_unknown_section_field_is_error() {
528 let toml = r#"
529 [program]
530 exec = "/bin/true"
531 bogus = 1
532 "#;
533 let err = parse_profile(toml).unwrap_err();
534 let msg = format!("{err}");
535 assert!(msg.contains("unknown field"), "got: {msg}");
536 }
537
538 #[test]
539 fn parse_profile_old_flat_format_is_error() {
540 let toml = r#"
542 fs_readable = ["/usr"]
543 "#;
544 let err = parse_profile(toml).unwrap_err();
545 let msg = format!("{err}");
546 assert!(msg.contains("unknown field"), "got: {msg}");
547 }
548
549 #[test]
550 fn parse_profile_time_start_sets_policy_field() {
551 let toml = r#"
552 [program]
553 exec = "/bin/true"
554 [determinism]
555 time_start = "2026-01-01T00:00:00Z"
556 "#;
557 let (policy, _spec) = parse_profile(toml).unwrap();
558 assert!(policy.time_start.is_some());
559 }
560
561 #[test]
562 fn parse_profile_invalid_time_start_is_error() {
563 let toml = r#"
564 [program]
565 exec = "/bin/true"
566 [determinism]
567 time_start = "not-a-time"
568 "#;
569 let err = parse_profile(toml).unwrap_err();
570 let msg = format!("{err}");
571 assert!(msg.contains("time_start"), "got: {msg}");
572 }
573
574 #[test]
575 fn profile_network_deny_parses() {
576 let toml = r#"
577 [network]
578 deny = ["10.0.0.0/8", "192.168.0.0/16"]
579 "#;
580 let (policy, _spec) = parse_profile(toml).unwrap();
581 assert!(policy.net_deny.len() > 1);
582 }
583
584 #[test]
585 fn profile_network_deny_bind_parses() {
586 let toml = r#"
588 [network]
589 deny_bind = [8080, "9000-9002"]
590 "#;
591 let (policy, _spec) = parse_profile(toml).unwrap();
592 assert_eq!(policy.net_deny_bind, vec![8080, 9000, 9001, 9002]);
593 assert!(policy.net_allow_bind.is_default());
594 }
595
596 #[test]
597 fn isolation_key_is_rejected() {
598 let toml = r#"
599 [program]
600 exec = "/bin/true"
601 [filesystem]
602 isolation = "none"
603 "#;
604 let err = parse_profile(toml).unwrap_err();
605 let msg = format!("{err}");
606 assert!(msg.contains("unknown field"), "got: {msg}");
607 }
608}