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    pub chroot: Option<PathBuf>,
70    /// Each entry has the form `"VIRTUAL:HOST"`, matching `--fs-mount` syntax.
71    pub mount: Vec<String>,
72    /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_exit`.
73    pub on_exit: Option<String>,
74    /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_error`.
75    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// Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not
102// `max_memory`) — the section name `[limits]` makes the prefix redundant.
103// `parse_input` maps each of these to the corresponding `Sandbox::max_*` field.
104#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
105#[serde(deny_unknown_fields, default)]
106pub struct LimitsSection {
107    /// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
108    /// not yet supported). Maps to `Sandbox::max_memory`.
109    pub memory: Option<String>,
110    pub processes: Option<u32>,
111    pub open_files: Option<u32>,
112    /// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`.
113    pub cpu: Option<u8>,
114    /// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
115    /// not yet supported). Maps to `Sandbox::max_disk`.
116    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
122/// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair.
123///
124/// Forwards each schema section's fields to the corresponding `SandboxBuilder`
125/// method calls. The two private helpers (`parse_branch_action`,
126/// `parse_mount_spec`) handle string-to-typed-value conversions for fields
127/// that lack `FromStr` impls on their target types.
128pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
129    let mut b = Sandbox::builder();
130
131    // [config]
132    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    // [determinism]
138    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    // [program] — process knobs go to Sandbox; exec/args go to ProgramSpec.
146    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    // [filesystem]
154    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    // [network]
166    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    // [http]
171    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    // [syscalls]
176    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    // [limits]
184    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
202/// Parses an `[filesystem].on_exit` / `on_error` string into a `BranchAction`.
203fn 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
216/// Parses a `"VIRTUAL:HOST"` mount spec string into a `(virtual, host)` pair.
217fn 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
230/// Parses an RFC3339 timestamp string into `SystemTime`.
231fn 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
241/// Default profile directory.
242pub 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
256/// Parse a TOML profile string into a Sandbox + ProgramSpec.
257pub 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
265/// Load a profile by name.
266pub 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
275/// List available profile names.
276pub 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        // With no profile dir, list_profiles() should return an empty vec.
301        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        // 1 user rule (tcp://cache.internal:6379) + at least 1 http-port-derived
436        // rule that the builder auto-merges (api.internal on http.ports). The
437        // merge is the contract being verified here.
438        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        // Old format used top-level "fs_readable = [...]"; we no longer accept it.
458        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}