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