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 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    /// RFC3339 timestamp string. Maps to `Sandbox::time_start`.
47    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    /// 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/// One `[network].allow_bind` entry: a bare integer port (`8080`) or a
81/// quoted string holding a comma list and/or `lo-hi` range (`"9000-9005"`).
82/// The untagged form lets a TOML array mix the two, e.g.
83/// `allow_bind = [8080, "9000-9005"]`.
84#[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// Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not
117// `max_memory`) — the section name `[limits]` makes the prefix redundant.
118// `parse_input` maps each of these to the corresponding `Sandbox::max_*` field.
119#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
120#[serde(deny_unknown_fields, default)]
121pub struct LimitsSection {
122    /// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
123    /// not yet supported). Maps to `Sandbox::max_memory`.
124    pub memory: Option<String>,
125    pub processes: Option<u32>,
126    pub open_files: Option<u32>,
127    /// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`.
128    pub cpu: Option<u8>,
129    /// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
130    /// not yet supported). Maps to `Sandbox::max_disk`.
131    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
137/// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair.
138///
139/// Forwards each schema section's fields to the corresponding `SandboxBuilder`
140/// method calls. The two private helpers (`parse_branch_action`,
141/// `parse_mount_spec`) handle string-to-typed-value conversions for fields
142/// that lack `FromStr` impls on their target types.
143pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
144    let mut b = Sandbox::builder();
145
146    // [config]
147    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    // [determinism]
155    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    // [program] — process knobs go to Sandbox; exec/args go to ProgramSpec.
163    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    // [filesystem]
171    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    // [network]
183    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    // [http]
200    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    // [syscalls]
205    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    // [limits]
213    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
231/// Parses an `[filesystem].on_exit` / `on_error` string into a `BranchAction`.
232fn 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
245/// Parses a `"VIRTUAL:HOST"` mount spec string into a `(virtual, host)` pair.
246fn 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
259/// Parses an RFC3339 timestamp string into `SystemTime`.
260fn 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
270/// Default profile directory.
271pub 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
285/// Parse a TOML profile string into a Sandbox + ProgramSpec.
286pub 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
294/// Load a profile by name.
295pub 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
304/// List available profile names.
305pub 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        // With no profile dir, list_profiles() should return an empty vec.
330        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        // 1 user rule (tcp://cache.internal:6379) + at least 1 http-port-derived
482        // rule that the builder auto-merges (api.internal on http.ports). The
483        // merge is the contract being verified here.
484        assert!(policy.net_allow.len() >= 2);
485        // allow_bind mixes a bare int port and a quoted range string.
486        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        // Old format used top-level "fs_readable = [...]"; we no longer accept it.
506        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        // Mixed int + range string, same as allow_bind.
552        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}