Skip to main content

sandlock_core/
profile.rs

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/// Program identity supplied by a profile alongside the policy.
9/// Not a `Sandbox` field — passed separately to the sandbox runner.
10#[derive(Debug, Clone, Default, PartialEq)]
11pub struct ProgramSpec {
12    pub exec: Option<PathBuf>,
13    pub args: Vec<String>,
14}
15
16/// Top-level profile input. Each section maps to one schema section.
17#[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// Field names follow the schema vocabulary and match `Sandbox`'s field names 1:1.
31#[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    /// RFC3339 timestamp string. Maps to `Sandbox::time_start`.
45    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    /// One of `"none"`, `"overlayfs"`, `"branchfs"`. Maps to `Sandbox::fs_isolation`.
70    pub isolation: Option<String>,
71    pub chroot: Option<PathBuf>,
72    /// Each entry has the form `"VIRTUAL:HOST"`, matching `--fs-mount` syntax.
73    pub mount: Vec<String>,
74    /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_exit`.
75    pub on_exit: Option<String>,
76    /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_error`.
77    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// Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not
104// `max_memory`) — the section name `[limits]` makes the prefix redundant.
105// `parse_input` maps each of these to the corresponding `Sandbox::max_*` field.
106#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
107#[serde(deny_unknown_fields, default)]
108pub struct LimitsSection {
109    /// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
110    /// not yet supported). Maps to `Sandbox::max_memory`.
111    pub memory: Option<String>,
112    pub processes: Option<u32>,
113    pub open_files: Option<u32>,
114    /// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`.
115    pub cpu: Option<u8>,
116    /// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
117    /// not yet supported). Maps to `Sandbox::max_disk`.
118    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
124/// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair.
125///
126/// Forwards each schema section's fields to the corresponding `SandboxBuilder`
127/// method calls. The three private helpers (`parse_fs_isolation`,
128/// `parse_branch_action`, `parse_mount_spec`) handle string-to-typed-value
129/// conversions for fields that lack `FromStr` impls on their target types.
130pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
131    let mut b = Sandbox::builder();
132
133    // [config]
134    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    // [determinism]
140    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    // [program] — process knobs go to Sandbox; exec/args go to ProgramSpec.
148    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    // [filesystem]
156    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    // [network]
171    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    // [http]
176    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    // [syscalls]
181    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    // [limits]
189    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
207/// Parses the `[filesystem].isolation` schema string into a `FsIsolation`.
208fn 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
221/// Parses an `[filesystem].on_exit` / `on_error` string into a `BranchAction`.
222fn 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
235/// Parses a `"VIRTUAL:HOST"` mount spec string into a `(virtual, host)` pair.
236fn 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
249/// Parses an RFC3339 timestamp string into `SystemTime`.
250fn 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
260/// Default profile directory.
261pub 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
275/// Parse a TOML profile string into a Sandbox + ProgramSpec.
276pub 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
284/// Load a profile by name.
285pub 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
294/// List available profile names.
295pub 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        // With no profile dir, list_profiles() should return an empty vec.
320        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        // 1 user rule (tcp://cache.internal:6379) + at least 1 http-port-derived
456        // rule that the builder auto-merges (api.internal on http.ports). The
457        // merge is the contract being verified here.
458        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        // Old format used top-level "fs_readable = [...]"; we no longer accept it.
478        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}