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 gid: Option<u32>,
61    pub clean_env: bool,
62    pub no_coredump: bool,
63    pub no_huge_pages: bool,
64}
65
66#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
67#[serde(deny_unknown_fields, default)]
68pub struct FilesystemSection {
69    pub read: Vec<PathBuf>,
70    pub write: Vec<PathBuf>,
71    pub deny: Vec<PathBuf>,
72    pub chroot: Option<PathBuf>,
73    /// Each entry has the form `"VIRTUAL:HOST"`, matching `--fs-mount` syntax.
74    pub mount: Vec<String>,
75    /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_exit`.
76    pub on_exit: Option<String>,
77    /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_error`.
78    pub on_error: Option<String>,
79}
80
81/// One `[network].allow_bind` entry: a bare integer port (`8080`) or a
82/// quoted string holding a comma list and/or `lo-hi` range (`"9000-9005"`).
83/// The untagged form lets a TOML array mix the two, e.g.
84/// `allow_bind = [8080, "9000-9005"]`.
85#[derive(Debug, Clone, Deserialize, PartialEq)]
86#[serde(untagged)]
87pub enum PortSpec {
88    Port(u16),
89    Spec(String),
90}
91
92#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
93#[serde(deny_unknown_fields, default)]
94pub struct NetworkSection {
95    pub allow_bind: Vec<PortSpec>,
96    pub deny_bind: Vec<PortSpec>,
97    pub allow: Vec<String>,
98    pub deny: Vec<String>,
99    pub port_remap: bool,
100}
101
102#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
103#[serde(deny_unknown_fields, default)]
104pub struct HttpSection {
105    pub ports: Vec<u16>,
106    pub allow: Vec<String>,
107    pub deny: Vec<String>,
108}
109
110#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
111#[serde(deny_unknown_fields, default)]
112pub struct SyscallsSection {
113    pub extra_allow: Vec<String>,
114    pub extra_deny: Vec<String>,
115}
116
117// Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not
118// `max_memory`) — the section name `[limits]` makes the prefix redundant.
119// `parse_input` maps each of these to the corresponding `Sandbox::max_*` field.
120#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
121#[serde(deny_unknown_fields, default)]
122pub struct LimitsSection {
123    /// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
124    /// not yet supported). Maps to `Sandbox::max_memory`.
125    pub memory: Option<String>,
126    pub processes: Option<u32>,
127    pub open_files: Option<u32>,
128    /// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`.
129    pub cpu: Option<u8>,
130    /// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB`
131    /// not yet supported). Maps to `Sandbox::max_disk`.
132    pub disk: Option<String>,
133    pub gpu_devices: Option<Vec<u32>>,
134    pub cpu_cores: Option<Vec<u32>>,
135    pub num_cpus: Option<u32>,
136}
137
138/// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair.
139///
140/// Forwards each schema section's fields to the corresponding `SandboxBuilder`
141/// method calls. The two private helpers (`parse_branch_action`,
142/// `parse_mount_spec`) handle string-to-typed-value conversions for fields
143/// that lack `FromStr` impls on their target types.
144pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> {
145    let mut b = Sandbox::builder();
146
147    // [config]
148    if let Some(p) = input.config.http_ca       { b = b.http_ca(p); }
149    if let Some(p) = input.config.http_key      { b = b.http_key(p); }
150    for p in input.config.http_inject_ca       { b = b.http_inject_ca(p); }
151    if let Some(p) = input.config.http_ca_out  { b = b.http_ca_out(p); }
152    if let Some(p) = input.config.fs_storage    { b = b.fs_storage(p); }
153    if let Some(p) = input.config.workdir       { b = b.workdir(p); }
154
155    // [determinism]
156    if let Some(s) = input.determinism.random_seed { b = b.random_seed(s); }
157    if let Some(s) = input.determinism.time_start.as_deref() {
158        b = b.time_start(parse_time_start(s)?);
159    }
160    if input.determinism.deterministic_dirs        { b = b.deterministic_dirs(true); }
161    if input.determinism.no_randomize_memory       { b = b.no_randomize_memory(true); }
162
163    // [program] — process knobs go to Sandbox; exec/args go to ProgramSpec.
164    for (k, v) in input.program.env.iter() { b = b.env_var(k, v); }
165    if let Some(c) = input.program.cwd             { b = b.cwd(c); }
166    match (input.program.uid, input.program.gid) {
167        (Some(u), Some(g)) => b = b.user(u, g),
168        (None, None) => {}
169        _ => return Err(SandlockError::Sandbox(crate::error::SandboxError::Invalid(
170            "program.uid and program.gid must both be set".into(),
171        ))),
172    }
173    if input.program.clean_env                     { b = b.clean_env(true); }
174    if input.program.no_coredump                   { b = b.no_coredump(true); }
175    if input.program.no_huge_pages                 { b = b.no_huge_pages(true); }
176
177    // [filesystem]
178    for p in input.filesystem.read.iter()  { b = b.fs_read(p); }
179    for p in input.filesystem.write.iter() { b = b.fs_write(p); }
180    for p in input.filesystem.deny.iter()  { b = b.fs_deny(p); }
181    if let Some(c) = input.filesystem.chroot         { b = b.chroot(c); }
182    for spec in input.filesystem.mount.iter() {
183        let (virt, host, read_only) = parse_mount_spec(spec)?;
184        b = if read_only { b.fs_mount_ro(virt, host) } else { b.fs_mount(virt, host) };
185    }
186    if let Some(s) = input.filesystem.on_exit.as_deref()  { b = b.on_exit(parse_branch_action(s)?); }
187    if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); }
188
189    // [network]
190    for entry in input.network.allow_bind.iter() {
191        b = match entry {
192            PortSpec::Port(p) => b.net_allow_bind_port(*p),
193            PortSpec::Spec(s) => b.net_allow_bind(s),
194        };
195    }
196    for entry in input.network.deny_bind.iter() {
197        b = match entry {
198            PortSpec::Port(p) => b.net_deny_bind_port(*p),
199            PortSpec::Spec(s) => b.net_deny_bind(s),
200        };
201    }
202    for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); }
203    for r in input.network.deny.iter()  { b = b.net_deny(r.as_str()); }
204    if input.network.port_remap         { b = b.port_remap(true); }
205
206    // [http]
207    for p in input.http.ports.iter() { b = b.http_port(*p); }
208    for r in input.http.allow.iter() { b = b.http_allow(r); }
209    for r in input.http.deny.iter()  { b = b.http_deny(r); }
210
211    // [syscalls]
212    if !input.syscalls.extra_allow.is_empty() {
213        b = b.extra_allow_syscalls(input.syscalls.extra_allow);
214    }
215    if !input.syscalls.extra_deny.is_empty() {
216        b = b.extra_deny_syscalls(input.syscalls.extra_deny);
217    }
218
219    // [limits]
220    if let Some(s) = input.limits.memory.as_deref()    {
221        b = b.max_memory(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
222    }
223    if let Some(n) = input.limits.processes            { b = b.max_processes(n); }
224    if let Some(n) = input.limits.open_files           { b = b.max_open_files(n); }
225    if let Some(p) = input.limits.cpu                  { b = b.max_cpu(p); }
226    if let Some(s) = input.limits.disk.as_deref()      {
227        b = b.max_disk(ByteSize::parse(s).map_err(SandlockError::Sandbox)?);
228    }
229    if let Some(g) = input.limits.gpu_devices  { b = b.gpu_devices(g); }
230    if let Some(c) = input.limits.cpu_cores    { b = b.cpu_cores(c); }
231    if let Some(n) = input.limits.num_cpus             { b = b.num_cpus(n); }
232
233    let policy = b.build()?;
234    let spec = ProgramSpec { exec: input.program.exec, args: input.program.args };
235    Ok((policy, spec))
236}
237
238/// Parses an `[filesystem].on_exit` / `on_error` string into a `BranchAction`.
239fn parse_branch_action(s: &str) -> Result<crate::sandbox::BranchAction, SandlockError> {
240    use crate::error::SandboxError;
241    use crate::sandbox::BranchAction;
242    Ok(match s {
243        "commit" => BranchAction::Commit,
244        "abort"  => BranchAction::Abort,
245        "keep"   => BranchAction::Keep,
246        other    => return Err(SandlockError::Sandbox(SandboxError::Invalid(
247            format!("invalid branch action {other:?}; expected \"commit\" | \"abort\" | \"keep\""),
248        ))),
249    })
250}
251
252/// Parses a `"VIRTUAL:HOST"` mount spec string into a `(virtual, host)` pair.
253/// Parse a `VIRTUAL:HOST` mount spec, with an optional trailing `:ro` (or the
254/// default `:rw`) selecting a read-only mount. Returns
255/// `(virtual_path, host_path, read_only)`.
256pub fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf, bool), SandlockError> {
257    use crate::error::SandboxError;
258    let (body, read_only) = if let Some(b) = s.strip_suffix(":ro") {
259        (b, true)
260    } else if let Some(b) = s.strip_suffix(":rw") {
261        (b, false)
262    } else {
263        (s, false)
264    };
265    let (virt, host) = body.split_once(':').ok_or_else(|| SandlockError::Sandbox(SandboxError::Invalid(
266        format!("invalid mount spec {s:?}; expected \"VIRTUAL:HOST[:ro]\""),
267    )))?;
268    if virt.is_empty() || host.is_empty() {
269        return Err(SandlockError::Sandbox(SandboxError::Invalid(
270            format!("invalid mount spec {s:?}; both VIRTUAL and HOST must be non-empty"),
271        )));
272    }
273    Ok((PathBuf::from(virt), PathBuf::from(host), read_only))
274}
275
276/// Parses an RFC3339 timestamp string into `SystemTime`.
277fn parse_time_start(s: &str) -> Result<SystemTime, SandlockError> {
278    use crate::error::SandboxError;
279    let ts: jiff::Timestamp = s.parse().map_err(|e| {
280        SandlockError::Sandbox(SandboxError::Invalid(
281            format!("invalid [determinism].time_start {s:?}: {e}"),
282        ))
283    })?;
284    Ok(ts.into())
285}
286
287/// Default profile directory.
288pub fn profile_dir() -> PathBuf {
289    dirs_or_fallback().join("profiles")
290}
291
292fn dirs_or_fallback() -> PathBuf {
293    std::env::var("XDG_CONFIG_HOME")
294        .map(PathBuf::from)
295        .unwrap_or_else(|_| {
296            let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
297            PathBuf::from(home).join(".config")
298        })
299        .join("sandlock")
300}
301
302/// Parse a TOML profile string into a Sandbox + ProgramSpec.
303pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
304    let input: ProfileInput = toml::from_str(content)
305        .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
306            format!("TOML parse error: {e}"),
307        )))?;
308    parse_input(input)
309}
310
311/// Load a profile by name.
312pub fn load_profile(name: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> {
313    let path = profile_dir().join(format!("{}.toml", name));
314    let content = std::fs::read_to_string(&path)
315        .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(
316            format!("profile '{}': {}", name, e),
317        )))?;
318    parse_profile(&content)
319}
320
321/// List available profile names.
322pub fn list_profiles() -> Result<Vec<String>, SandlockError> {
323    let dir = profile_dir();
324    if !dir.exists() { return Ok(Vec::new()); }
325    let mut names = Vec::new();
326    for entry in std::fs::read_dir(&dir)
327        .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(format!("read dir: {}", e))))? {
328        if let Ok(entry) = entry {
329            if let Some(name) = entry.path().file_stem() {
330                if entry.path().extension().map_or(false, |e| e == "toml") {
331                    names.push(name.to_string_lossy().into_owned());
332                }
333            }
334        }
335    }
336    names.sort();
337    Ok(names)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn list_profiles_empty_dir() {
346        // With no profile dir, list_profiles() should return an empty vec.
347        std::env::set_var("XDG_CONFIG_HOME", "/tmp/sandlock-test-nonexistent");
348        let profiles = list_profiles().unwrap();
349        assert!(profiles.is_empty());
350        std::env::remove_var("XDG_CONFIG_HOME");
351    }
352
353    #[test]
354    fn profile_input_deserializes_minimal() {
355        let toml = r#"
356            [program]
357            exec = "/bin/true"
358        "#;
359        let parsed: ProfileInput = toml::from_str(toml).unwrap();
360        assert_eq!(parsed.program.exec, Some("/bin/true".into()));
361        assert!(parsed.program.args.is_empty());
362        assert_eq!(parsed.config, ConfigSection::default());
363        assert_eq!(parsed.filesystem, FilesystemSection::default());
364    }
365
366    #[test]
367    fn config_section_maps_to_policy_http_fields() {
368        let toml = r#"
369            [config]
370            http_ca  = "/tmp/ca.pem"
371            http_key = "/tmp/ca.key"
372            [program]
373            exec = "/bin/true"
374        "#;
375        let input: ProfileInput = toml::from_str(toml).unwrap();
376        let (policy, _spec) = parse_input(input).unwrap();
377        assert_eq!(policy.http_ca.as_deref(), Some(std::path::Path::new("/tmp/ca.pem")));
378        assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key")));
379    }
380
381    #[test]
382    fn parses_http_inject_ca_and_ca_out() {
383        let toml = r#"
384            [config]
385            http_inject_ca = ["/etc/ssl/certs/ca-certificates.crt"]
386            http_ca_out = "/tmp/ca.pem"
387            [http]
388            allow = ["GET example.com/*"]
389            [program]
390            exec = "/bin/true"
391        "#;
392        let input: ProfileInput = toml::from_str(toml).unwrap();
393        let (policy, _prog) = parse_input(input).unwrap();
394        assert_eq!(policy.http_inject_ca.len(), 1);
395        assert_eq!(policy.http_ca_out.as_deref(), Some(std::path::Path::new("/tmp/ca.pem")));
396    }
397
398    #[test]
399    fn syscalls_extra_allow_sysv_ipc_sets_vec() {
400        let toml = r#"
401            [program]
402            exec = "/bin/true"
403            [syscalls]
404            extra_allow = ["sysv_ipc"]
405            extra_deny  = ["ptrace"]
406        "#;
407        let input: ProfileInput = toml::from_str(toml).unwrap();
408        let (policy, _spec) = parse_input(input).unwrap();
409        assert!(policy.allows_sysv_ipc());
410        assert_eq!(policy.extra_deny_syscalls, vec!["ptrace".to_string()]);
411    }
412
413    #[test]
414    fn parse_mount_spec_ro_suffix() {
415        let (v, h, ro) = parse_mount_spec("/work:/host").unwrap();
416        assert_eq!((v.to_str().unwrap(), h.to_str().unwrap(), ro), ("/work", "/host", false));
417        let (_, _, ro) = parse_mount_spec("/work:/host:rw").unwrap();
418        assert!(!ro);
419        let (v, h, ro) = parse_mount_spec("/work:/host:ro").unwrap();
420        assert_eq!((v.to_str().unwrap(), h.to_str().unwrap(), ro), ("/work", "/host", true));
421        // a host path containing colons still parses; only a trailing :ro/:rw is an option
422        let (_, h, ro) = parse_mount_spec("/v:/a:b:ro").unwrap();
423        assert_eq!((h.to_str().unwrap(), ro), ("/a:b", true));
424        assert!(parse_mount_spec("nocolon").is_err());
425    }
426
427    #[test]
428    fn parse_mount_spec_rejects_missing_colon() {
429        let toml = r#"
430            [program]
431            exec = "/bin/true"
432            [filesystem]
433            mount = ["nocolon"]
434        "#;
435        let input: ProfileInput = toml::from_str(toml).unwrap();
436        let err = parse_input(input).unwrap_err();
437        let msg = format!("{err}");
438        assert!(msg.contains("VIRTUAL:HOST"), "got: {msg}");
439    }
440
441    #[test]
442    fn parse_mount_spec_rejects_empty_half() {
443        let toml = r#"
444            [program]
445            exec = "/bin/true"
446            [filesystem]
447            mount = [":/host"]
448        "#;
449        let input: ProfileInput = toml::from_str(toml).unwrap();
450        let err = parse_input(input).unwrap_err();
451        let msg = format!("{err}");
452        assert!(msg.contains("non-empty"), "got: {msg}");
453    }
454
455    #[test]
456    fn parse_profile_full_example() {
457        let toml = r#"
458            [config]
459            http_ca    = "/etc/sandlock/ca.pem"
460            http_key   = "/etc/sandlock/ca.key"
461            fs_storage = "/var/sandlock/redis-worker"
462            workdir    = "/var/sandlock/redis-worker/work"
463
464            [determinism]
465            random_seed         = 42
466            deterministic_dirs  = true
467            no_randomize_memory = true
468
469            [program]
470            exec      = "/usr/bin/redis-cli"
471            args      = ["-h", "cache.internal", "-p", "6379"]
472            cwd       = "/var/lib/redis"
473            uid       = 1000
474            gid       = 1000
475            clean_env = true
476            no_coredump = true
477
478            [filesystem]
479            read      = ["/usr", "/etc/redis"]
480            write     = ["/var/lib/redis/state"]
481            deny      = ["/proc/sys"]
482            chroot    = "/var/lib/redis-rootfs"
483            mount     = ["/data:/srv/redis-data"]
484            on_exit   = "commit"
485            on_error  = "abort"
486
487            [network]
488            allow_bind = [8080, "9000-9002"]
489            allow      = ["tcp://cache.internal:6379"]
490            port_remap = true
491
492            [http]
493            ports = [80, 443]
494            allow = ["GET api.internal/v1/*"]
495            deny  = ["* */admin/*"]
496
497            [syscalls]
498            extra_allow = ["sysv_ipc"]
499            extra_deny  = ["ptrace", "mount"]
500
501            [limits]
502            memory    = "512M"
503            processes = 32
504            cpu       = 80
505        "#;
506
507        let (policy, spec) = parse_profile(toml).unwrap();
508        assert_eq!(spec.exec.as_deref(), Some(std::path::Path::new("/usr/bin/redis-cli")));
509        assert_eq!(spec.args.len(), 4);
510        assert!(policy.allows_sysv_ipc());
511        assert_eq!(policy.extra_deny_syscalls.len(), 2);
512        assert_eq!(policy.fs_readable.len(), 2);
513        // 1 user rule (tcp://cache.internal:6379) + at least 1 http-port-derived
514        // rule that the builder auto-merges (api.internal on http.ports). The
515        // merge is the contract being verified here.
516        assert!(policy.net_allow.len() >= 2);
517        // allow_bind mixes a bare int port and a quoted range string.
518        assert_eq!(
519            policy.net_allow_bind,
520            crate::sandbox::BindPorts::Ports(vec![8080, 9000, 9001, 9002])
521        );
522        assert_eq!(policy.http_allow.len(), 1);
523        assert_eq!(policy.fs_mount.len(), 1);
524    }
525
526    #[test]
527    fn parse_profile_unknown_section_field_is_error() {
528        let toml = r#"
529            [program]
530            exec = "/bin/true"
531            bogus = 1
532        "#;
533        let err = parse_profile(toml).unwrap_err();
534        let msg = format!("{err}");
535        assert!(msg.contains("unknown field"), "got: {msg}");
536    }
537
538    #[test]
539    fn parse_profile_old_flat_format_is_error() {
540        // Old format used top-level "fs_readable = [...]"; we no longer accept it.
541        let toml = r#"
542            fs_readable = ["/usr"]
543        "#;
544        let err = parse_profile(toml).unwrap_err();
545        let msg = format!("{err}");
546        assert!(msg.contains("unknown field"), "got: {msg}");
547    }
548
549    #[test]
550    fn parse_profile_time_start_sets_policy_field() {
551        let toml = r#"
552            [program]
553            exec = "/bin/true"
554            [determinism]
555            time_start = "2026-01-01T00:00:00Z"
556        "#;
557        let (policy, _spec) = parse_profile(toml).unwrap();
558        assert!(policy.time_start.is_some());
559    }
560
561    #[test]
562    fn parse_profile_invalid_time_start_is_error() {
563        let toml = r#"
564            [program]
565            exec = "/bin/true"
566            [determinism]
567            time_start = "not-a-time"
568        "#;
569        let err = parse_profile(toml).unwrap_err();
570        let msg = format!("{err}");
571        assert!(msg.contains("time_start"), "got: {msg}");
572    }
573
574    #[test]
575    fn profile_network_deny_parses() {
576        let toml = r#"
577            [network]
578            deny = ["10.0.0.0/8", "192.168.0.0/16"]
579        "#;
580        let (policy, _spec) = parse_profile(toml).unwrap();
581        assert!(policy.net_deny.len() > 1);
582    }
583
584    #[test]
585    fn profile_network_deny_bind_parses() {
586        // Mixed int + range string, same as allow_bind.
587        let toml = r#"
588            [network]
589            deny_bind = [8080, "9000-9002"]
590        "#;
591        let (policy, _spec) = parse_profile(toml).unwrap();
592        assert_eq!(policy.net_deny_bind, vec![8080, 9000, 9001, 9002]);
593        assert!(policy.net_allow_bind.is_default());
594    }
595
596    #[test]
597    fn isolation_key_is_rejected() {
598        let toml = r#"
599            [program]
600            exec = "/bin/true"
601            [filesystem]
602            isolation = "none"
603        "#;
604        let err = parse_profile(toml).unwrap_err();
605        let msg = format!("{err}");
606        assert!(msg.contains("unknown field"), "got: {msg}");
607    }
608}