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 chroot: Option<PathBuf>,
70 pub mount: Vec<String>,
72 pub on_exit: Option<String>,
74 pub on_error: Option<String>,
76}
77
78#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
79#[serde(deny_unknown_fields, default)]
80pub struct NetworkSection {
81 pub bind: Vec<u16>,
82 pub allow: Vec<String>,
83 pub port_remap: bool,
84}
85
86#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
87#[serde(deny_unknown_fields, default)]
88pub struct HttpSection {
89 pub ports: Vec<u16>,
90 pub allow: Vec<String>,
91 pub deny: Vec<String>,
92}
93
94#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
95#[serde(deny_unknown_fields, default)]
96pub struct SyscallsSection {
97 pub extra_allow: Vec<String>,
98 pub extra_deny: Vec<String>,
99}
100
101#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
105#[serde(deny_unknown_fields, default)]
106pub struct LimitsSection {
107 pub memory: Option<String>,
110 pub processes: Option<u32>,
111 pub open_files: Option<u32>,
112 pub cpu: Option<u8>,
114 pub disk: Option<String>,
117 pub gpu_devices: Option<Vec<u32>>,
118 pub cpu_cores: Option<Vec<u32>>,
119 pub num_cpus: Option<u32>,
120}
121
122pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
129 let mut b = Sandbox::builder();
130
131 if let Some(p) = input.config.http_ca { b = b.http_ca(p); }
133 if let Some(p) = input.config.http_key { b = b.http_key(p); }
134 if let Some(p) = input.config.fs_storage { b = b.fs_storage(p); }
135 if let Some(p) = input.config.workdir { b = b.workdir(p); }
136
137 if let Some(s) = input.determinism.random_seed { b = b.random_seed(s); }
139 if let Some(s) = input.determinism.time_start.as_deref() {
140 b = b.time_start(parse_time_start(s)?);
141 }
142 if input.determinism.deterministic_dirs { b = b.deterministic_dirs(true); }
143 if input.determinism.no_randomize_memory { b = b.no_randomize_memory(true); }
144
145 for (k, v) in input.program.env.iter() { b = b.env_var(k, v); }
147 if let Some(c) = input.program.cwd { b = b.cwd(c); }
148 if let Some(u) = input.program.uid { b = b.uid(u); }
149 if input.program.clean_env { b = b.clean_env(true); }
150 if input.program.no_coredump { b = b.no_coredump(true); }
151 if input.program.no_huge_pages { b = b.no_huge_pages(true); }
152
153 for p in input.filesystem.read.iter() { b = b.fs_read(p); }
155 for p in input.filesystem.write.iter() { b = b.fs_write(p); }
156 for p in input.filesystem.deny.iter() { b = b.fs_deny(p); }
157 if let Some(c) = input.filesystem.chroot { b = b.chroot(c); }
158 for spec in input.filesystem.mount.iter() {
159 let (virt, host) = parse_mount_spec(spec)?;
160 b = b.fs_mount(virt, host);
161 }
162 if let Some(s) = input.filesystem.on_exit.as_deref() { b = b.on_exit(parse_branch_action(s)?); }
163 if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); }
164
165 for p in input.network.bind.iter() { b = b.net_bind_port(*p); }
167 for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); }
168 if input.network.port_remap { b = b.port_remap(true); }
169
170 for p in input.http.ports.iter() { b = b.http_port(*p); }
172 for r in input.http.allow.iter() { b = b.http_allow(r); }
173 for r in input.http.deny.iter() { b = b.http_deny(r); }
174
175 if !input.syscalls.extra_allow.is_empty() {
177 b = b.extra_allow_syscalls(input.syscalls.extra_allow);
178 }
179 if !input.syscalls.extra_deny.is_empty() {
180 b = b.extra_deny_syscalls(input.syscalls.extra_deny);
181 }
182
183 if let Some(s) = input.limits.memory.as_deref() {
185 b = b.max_memory(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
186 }
187 if let Some(n) = input.limits.processes { b = b.max_processes(n); }
188 if let Some(n) = input.limits.open_files { b = b.max_open_files(n); }
189 if let Some(p) = input.limits.cpu { b = b.max_cpu(p); }
190 if let Some(s) = input.limits.disk.as_deref() {
191 b = b.max_disk(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
192 }
193 if let Some(g) = input.limits.gpu_devices { b = b.gpu_devices(g); }
194 if let Some(c) = input.limits.cpu_cores { b = b.cpu_cores(c); }
195 if let Some(n) = input.limits.num_cpus { b = b.num_cpus(n); }
196
197 let policy = b.build()?;
198 let spec = ProgramSpec { exec: input.program.exec, args: input.program.args };
199 Ok((policy, spec))
200}
201
202fn parse_branch_action(s: &str) -> Result<crate::sandbox::BranchAction, SandlockError> {
204 use crate::error::SandboxError;
205 use crate::sandbox::BranchAction;
206 Ok(match s {
207 "commit" => BranchAction::Commit,
208 "abort" => BranchAction::Abort,
209 "keep" => BranchAction::Keep,
210 other => return Err(SandlockError::Sandbox(SandboxError::Invalid(
211 format!("invalid branch action {other:?}; expected \"commit\" | \"abort\" | \"keep\""),
212 ))),
213 })
214}
215
216fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf), SandlockError> {
218 use crate::error::SandboxError;
219 let (virt, host) = s.split_once(':').ok_or_else(|| SandlockError::Sandbox(SandboxError::Invalid(
220 format!("invalid mount spec {s:?}; expected \"VIRTUAL:HOST\""),
221 )))?;
222 if virt.is_empty() || host.is_empty() {
223 return Err(SandlockError::Sandbox(SandboxError::Invalid(
224 format!("invalid mount spec {s:?}; both VIRTUAL and HOST must be non-empty"),
225 )));
226 }
227 Ok((PathBuf::from(virt), PathBuf::from(host)))
228}
229
230fn parse_time_start(s: &str) -> Result<SystemTime, SandlockError> {
232 use crate::error::SandboxError;
233 let ts: jiff::Timestamp = s.parse().map_err(|e| {
234 SandlockError::Sandbox(SandboxError::Invalid(
235 format!("invalid [determinism].time_start {s:?}: {e}"),
236 ))
237 })?;
238 Ok(ts.into())
239}
240
241pub fn profile_dir() -> PathBuf {
243 dirs_or_fallback().join("profiles")
244}
245
246fn dirs_or_fallback() -> PathBuf {
247 std::env::var("XDG_CONFIG_HOME")
248 .map(PathBuf::from)
249 .unwrap_or_else(|_| {
250 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
251 PathBuf::from(home).join(".config")
252 })
253 .join("sandlock")
254}
255
256pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
258 let input: ProfileInput = toml::from_str(content)
259 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
260 format!("TOML parse error: {e}"),
261 )))?;
262 parse_input(input)
263}
264
265pub fn load_profile(name: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
267 let path = profile_dir().join(format!("{}.toml", name));
268 let content = std::fs::read_to_string(&path)
269 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
270 format!("profile '{}': {}", name, e),
271 )))?;
272 parse_profile(&content)
273}
274
275pub fn list_profiles() -> Result<Vec<String>, SandlockError> {
277 let dir = profile_dir();
278 if !dir.exists() { return Ok(Vec::new()); }
279 let mut names = Vec::new();
280 for entry in std::fs::read_dir(&dir)
281 .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(format!("read dir: {}", e))))? {
282 if let Ok(entry) = entry {
283 if let Some(name) = entry.path().file_stem() {
284 if entry.path().extension().map_or(false, |e| e == "toml") {
285 names.push(name.to_string_lossy().into_owned());
286 }
287 }
288 }
289 }
290 names.sort();
291 Ok(names)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn list_profiles_empty_dir() {
300 std::env::set_var("XDG_CONFIG_HOME", "/tmp/sandlock-test-nonexistent");
302 let profiles = list_profiles().unwrap();
303 assert!(profiles.is_empty());
304 std::env::remove_var("XDG_CONFIG_HOME");
305 }
306
307 #[test]
308 fn profile_input_deserializes_minimal() {
309 let toml = r#"
310 [program]
311 exec = "/bin/true"
312 "#;
313 let parsed: ProfileInput = toml::from_str(toml).unwrap();
314 assert_eq!(parsed.program.exec, Some("/bin/true".into()));
315 assert!(parsed.program.args.is_empty());
316 assert_eq!(parsed.config, ConfigSection::default());
317 assert_eq!(parsed.filesystem, FilesystemSection::default());
318 }
319
320 #[test]
321 fn config_section_maps_to_policy_http_fields() {
322 let toml = r#"
323 [config]
324 http_ca = "/tmp/ca.pem"
325 http_key = "/tmp/ca.key"
326 [program]
327 exec = "/bin/true"
328 "#;
329 let input: ProfileInput = toml::from_str(toml).unwrap();
330 let (policy, _spec) = parse_input(input).unwrap();
331 assert_eq!(policy.http_ca.as_deref(), Some(std::path::Path::new("/tmp/ca.pem")));
332 assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key")));
333 }
334
335 #[test]
336 fn syscalls_extra_allow_sysv_ipc_sets_vec() {
337 let toml = r#"
338 [program]
339 exec = "/bin/true"
340 [syscalls]
341 extra_allow = ["sysv_ipc"]
342 extra_deny = ["ptrace"]
343 "#;
344 let input: ProfileInput = toml::from_str(toml).unwrap();
345 let (policy, _spec) = parse_input(input).unwrap();
346 assert!(policy.allows_sysv_ipc());
347 assert_eq!(policy.extra_deny_syscalls, vec!["ptrace".to_string()]);
348 }
349
350 #[test]
351 fn parse_mount_spec_rejects_missing_colon() {
352 let toml = r#"
353 [program]
354 exec = "/bin/true"
355 [filesystem]
356 mount = ["nocolon"]
357 "#;
358 let input: ProfileInput = toml::from_str(toml).unwrap();
359 let err = parse_input(input).unwrap_err();
360 let msg = format!("{err}");
361 assert!(msg.contains("VIRTUAL:HOST"), "got: {msg}");
362 }
363
364 #[test]
365 fn parse_mount_spec_rejects_empty_half() {
366 let toml = r#"
367 [program]
368 exec = "/bin/true"
369 [filesystem]
370 mount = [":/host"]
371 "#;
372 let input: ProfileInput = toml::from_str(toml).unwrap();
373 let err = parse_input(input).unwrap_err();
374 let msg = format!("{err}");
375 assert!(msg.contains("non-empty"), "got: {msg}");
376 }
377
378 #[test]
379 fn parse_profile_full_example() {
380 let toml = r#"
381 [config]
382 http_ca = "/etc/sandlock/ca.pem"
383 http_key = "/etc/sandlock/ca.key"
384 fs_storage = "/var/sandlock/redis-worker"
385 workdir = "/var/sandlock/redis-worker/work"
386
387 [determinism]
388 random_seed = 42
389 deterministic_dirs = true
390 no_randomize_memory = true
391
392 [program]
393 exec = "/usr/bin/redis-cli"
394 args = ["-h", "cache.internal", "-p", "6379"]
395 cwd = "/var/lib/redis"
396 uid = 1000
397 clean_env = true
398 no_coredump = true
399
400 [filesystem]
401 read = ["/usr", "/etc/redis"]
402 write = ["/var/lib/redis/state"]
403 deny = ["/proc/sys"]
404 chroot = "/var/lib/redis-rootfs"
405 mount = ["/data:/srv/redis-data"]
406 on_exit = "commit"
407 on_error = "abort"
408
409 [network]
410 bind = [8080]
411 allow = ["tcp://cache.internal:6379"]
412 port_remap = true
413
414 [http]
415 ports = [80, 443]
416 allow = ["GET api.internal/v1/*"]
417 deny = ["* */admin/*"]
418
419 [syscalls]
420 extra_allow = ["sysv_ipc"]
421 extra_deny = ["ptrace", "mount"]
422
423 [limits]
424 memory = "512M"
425 processes = 32
426 cpu = 80
427 "#;
428
429 let (policy, spec) = parse_profile(toml).unwrap();
430 assert_eq!(spec.exec.as_deref(), Some(std::path::Path::new("/usr/bin/redis-cli")));
431 assert_eq!(spec.args.len(), 4);
432 assert!(policy.allows_sysv_ipc());
433 assert_eq!(policy.extra_deny_syscalls.len(), 2);
434 assert_eq!(policy.fs_readable.len(), 2);
435 assert!(policy.net_allow.len() >= 2);
439 assert_eq!(policy.http_allow.len(), 1);
440 assert_eq!(policy.fs_mount.len(), 1);
441 }
442
443 #[test]
444 fn parse_profile_unknown_section_field_is_error() {
445 let toml = r#"
446 [program]
447 exec = "/bin/true"
448 bogus = 1
449 "#;
450 let err = parse_profile(toml).unwrap_err();
451 let msg = format!("{err}");
452 assert!(msg.contains("unknown field"), "got: {msg}");
453 }
454
455 #[test]
456 fn parse_profile_old_flat_format_is_error() {
457 let toml = r#"
459 fs_readable = ["/usr"]
460 "#;
461 let err = parse_profile(toml).unwrap_err();
462 let msg = format!("{err}");
463 assert!(msg.contains("unknown field"), "got: {msg}");
464 }
465
466 #[test]
467 fn parse_profile_time_start_sets_policy_field() {
468 let toml = r#"
469 [program]
470 exec = "/bin/true"
471 [determinism]
472 time_start = "2026-01-01T00:00:00Z"
473 "#;
474 let (policy, _spec) = parse_profile(toml).unwrap();
475 assert!(policy.time_start.is_some());
476 }
477
478 #[test]
479 fn parse_profile_invalid_time_start_is_error() {
480 let toml = r#"
481 [program]
482 exec = "/bin/true"
483 [determinism]
484 time_start = "not-a-time"
485 "#;
486 let err = parse_profile(toml).unwrap_err();
487 let msg = format!("{err}");
488 assert!(msg.contains("time_start"), "got: {msg}");
489 }
490
491 #[test]
492 fn isolation_key_is_rejected() {
493 let toml = r#"
494 [program]
495 exec = "/bin/true"
496 [filesystem]
497 isolation = "none"
498 "#;
499 let err = parse_profile(toml).unwrap_err();
500 let msg = format!("{err}");
501 assert!(msg.contains("unknown field"), "got: {msg}");
502 }
503}