diff --git a/README.md b/README.md index 99a224a3..a470c6b7 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ sandlock run --no-supervisor -r /proc -r /usr -r /lib -r /lib64 -r /bin -r /etc ### Python API ```python -from sandlock import Sandbox, confine +from sandlock import Mount, Sandbox, confine sandbox = Sandbox( fs_writable=["/tmp/sandbox"], @@ -272,7 +272,7 @@ result = agent.run(["python3", "agent.py"]) # Chroot with per-sandbox mount (Docker-style -v, no root needed) chrooted = Sandbox( chroot="/opt/rootfs", - fs_mount={"/work": "/tmp/sandbox-1/work"}, # maps /work inside chroot + fs_mount=[Mount("/work", "/tmp/sandbox-1/work")], # maps /work inside chroot fs_readable=["/usr", "/bin", "/lib", "/etc"], cwd="/work", ) diff --git a/crates/sandlock-core/src/http.rs b/crates/sandlock-core/src/http.rs index 31928770..4b984c0e 100644 --- a/crates/sandlock-core/src/http.rs +++ b/crates/sandlock-core/src/http.rs @@ -179,6 +179,12 @@ pub fn http_acl_check( /// allowed to reach the original destination on the intercepted ports. Concrete /// HTTP rule hosts tighten the IP allowlist to those hosts; wildcard hosts or /// explicit HTTP ports with no rules allow any IP on the HTTP ports. +/// +/// Derived entries a caller already carries are not added twice. A policy can +/// be taken apart and rebuilt (`sandlock run --profile-file` rebuilds a builder +/// from the parsed profile, then applies flag overrides on top), and the +/// rebuilt net allowlist arrives here already holding the entries this +/// function added on the first build. pub(crate) fn extend_net_allow_for_http( net_allow: &mut Vec, http_allow: &[HttpRule], @@ -189,6 +195,12 @@ pub(crate) fn extend_net_allow_for_http( return; } + fn push_unique(net_allow: &mut Vec, rule: NetAllow) { + if !net_allow.contains(&rule) { + net_allow.push(rule); + } + } + let mut wildcard_seen = false; let mut concrete_hosts: Vec = Vec::new(); for rule in http_allow.iter().chain(http_deny.iter()) { @@ -203,7 +215,7 @@ pub(crate) fn extend_net_allow_for_http( } if wildcard_seen || (http_allow.is_empty() && http_deny.is_empty()) { - net_allow.push(NetAllow { + push_unique(net_allow, NetAllow { protocol: Protocol::Tcp, target: NetTarget::AnyIp, ports: http_ports.to_vec(), @@ -212,7 +224,7 @@ pub(crate) fn extend_net_allow_for_http( } for host in concrete_hosts { - net_allow.push(NetAllow { + push_unique(net_allow, NetAllow { protocol: Protocol::Tcp, target: NetTarget::Host(host), ports: http_ports.to_vec(), @@ -481,6 +493,29 @@ mod tests { assert_eq!(net_allow[1].ports, vec![80, 443]); } + #[test] + fn extend_net_allow_for_http_is_idempotent() { + // A policy that is taken apart and rebuilt feeds the already derived + // entries back in as plain net-allow specs (that is what + // `sandlock run --profile-file` does before applying flag overrides), + // so a second pass must not grow the allowlist. + let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; + let mut net_allow = Vec::new(); + + extend_net_allow_for_http(&mut net_allow, &allow, &[], &[80]); + let first = net_allow.clone(); + extend_net_allow_for_http(&mut net_allow, &allow, &[], &[80]); + + assert_eq!(net_allow, first); + + // Same for the any-IP entry, which comes from a different branch. + let mut wide = Vec::new(); + extend_net_allow_for_http(&mut wide, &[], &[], &[8080]); + let first_wide = wide.clone(); + extend_net_allow_for_http(&mut wide, &[], &[], &[8080]); + assert_eq!(wide, first_wide); + } + #[test] fn extend_net_allow_for_http_adds_any_ip_for_wildcard_or_bare_port() { let mut net_allow = Vec::new(); diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 875d3202..9e98086e 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; use std::collections::HashMap; use std::time::SystemTime; +pub mod canonical; + /// Program identity supplied by a profile alongside the policy. /// Not a `Sandbox` field — passed separately to the sandbox runner. #[derive(Debug, Clone, Default, PartialEq)] @@ -337,13 +339,21 @@ pub fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf, bool), SandlockErr /// Parses an RFC3339 timestamp string into `SystemTime`. fn parse_time_start(s: &str) -> Result { + Ok(parse_timestamp(s)?.into()) +} + +/// Parses an RFC3339 timestamp string, keeping the `jiff::Timestamp`. +/// +/// `SystemTime` cannot represent a pre-epoch instant as a plain second count, +/// so the canonical form resolves the string through this instead and does its +/// own epoch split. +fn parse_timestamp(s: &str) -> Result { use crate::error::SandboxError; - let ts: jiff::Timestamp = s.parse().map_err(|e| { + s.parse().map_err(|e| { SandlockError::Sandbox(SandboxError::Invalid( format!("invalid [determinism].time_start {s:?}: {e}"), )) - })?; - Ok(ts.into()) + }) } // ============================================================ @@ -578,13 +588,20 @@ fn dirs_or_fallback() -> PathBuf { .join("sandlock") } -/// Parse a TOML profile string into a Sandbox + ProgramSpec. -pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> { - let input: ProfileInput = toml::from_str(content) +/// Deserialize a TOML profile string into the raw schema. +/// +/// Shared by `parse_profile` and `canonical::parse` so both report a syntax or +/// unknown-key problem with the same wording. +fn deserialize_profile(content: &str) -> Result { + toml::from_str(content) .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid( format!("TOML parse error: {e}"), - )))?; - parse_input(input) + ))) +} + +/// Parse a TOML profile string into a Sandbox + ProgramSpec. +pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> { + parse_input(deserialize_profile(content)?) } /// Load a profile by name. diff --git a/crates/sandlock-core/src/profile/canonical.rs b/crates/sandlock-core/src/profile/canonical.rs new file mode 100644 index 00000000..c0ebd93f --- /dev/null +++ b/crates/sandlock-core/src/profile/canonical.rs @@ -0,0 +1,1162 @@ +//! Canonical profile form: the TOML schema with every string micro-grammar +//! already resolved. +//! +//! [`ProfileInput`] is the raw schema: it mirrors the TOML file, so a mount is +//! still the string `"/data:/srv:ro"`, a size is still `"512M"` and a start +//! time is still an RFC 3339 stamp. Every consumer that wants the actual values +//! has to re-implement those grammars, and each re-implementation drifts. +//! +//! [`CanonicalProfile`] is the same section layout with the leaves resolved: +//! mounts are `{virt, host, ro}` objects, sizes are integer bytes, `time_start` +//! is epoch seconds, bind ports are expanded integer lists, net and HTTP rules +//! are structured records. A binding that consumes it only has to do structural +//! field mapping, and because both this type and [`ProfileInput`] reject unknown +//! keys, a schema change fails loudly at load time instead of being silently +//! mis-parsed. +//! +//! What this is not: it is not the *effective* policy. The builder derives extra +//! state at `build()` time (HTTP rules append host entries to the net allowlist, +//! `http.ports` materializes to `[80]`, `max_processes` defaults to 64). Those +//! derivations are deliberately absent here, because a consumer that maps this +//! form back into a builder would otherwise apply them a second time. Use +//! [`super::sandbox_to_json`] when the effective policy is what you want. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::error::{SandboxError, SandlockError}; +use crate::http::HttpRule; +use crate::network::{NetRule, NetTarget, Protocol}; +use crate::sandbox::{BindPorts, BranchAction, ByteSize}; + +use super::{PortSpec, ProfileInput}; + +// ============================================================ +// Canonical types +// ============================================================ + +/// A profile with every micro-grammar resolved. Section layout matches +/// [`ProfileInput`] one to one; only the leaf types differ. +/// +/// Unlike [`ProfileInput`], which omits defaulted fields to keep a serialized +/// profile minimal, every field is always emitted. A consumer maps the shape +/// unconditionally, and a key that goes missing is a schema break rather than +/// an ambiguous "unset or absent". +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalProfile { + pub config: CanonicalConfig, + pub determinism: CanonicalDeterminism, + pub program: CanonicalProgram, + pub filesystem: CanonicalFilesystem, + pub network: CanonicalNetwork, + pub http: CanonicalHttp, + pub syscalls: CanonicalSyscalls, + pub limits: CanonicalLimits, +} + +/// `[config]`: paths only, no grammar to resolve. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalConfig { + pub http_ca: Option, + pub http_key: Option, + pub http_inject_ca: Vec, + pub http_ca_out: Option, + pub fs_storage: Option, + pub workdir: Option, +} + +/// `[determinism]`, with `time_start` resolved from RFC 3339 to epoch time. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalDeterminism { + pub random_seed: Option, + pub time_start: Option, + pub deterministic_dirs: bool, + pub no_randomize_memory: bool, +} + +/// An epoch timestamp split into whole seconds and a non-negative +/// sub-second remainder. +/// +/// `seconds` is signed because the profile grammar accepts pre-1970 stamps +/// (`"1969-01-01T00:00:00Z"` parses today). `nanoseconds` is carried +/// separately rather than truncated because the grammar also accepts +/// fractional seconds (`"...T00:00:00.5Z"`); dropping them here would make a +/// profile mean one thing through the CLI and another through a binding, +/// which is the exact class of drift this form exists to remove. +/// +/// Normalization: `nanoseconds` is always in `[0, 1_000_000_000)`, so +/// `seconds` floors rather than truncating towards zero. Half a second before +/// the epoch is `{seconds: -1, nanoseconds: 500000000}`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CanonicalTimestamp { + pub seconds: i64, + pub nanoseconds: u32, +} + +/// `[program]`: process knobs plus the program identity (`exec`/`args`), +/// which the effective-policy serializer drops but a profile consumer needs. +/// +/// `env` is a sorted map: a hash map would give a different key order on every +/// run, and a canonical form has to be byte-stable. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalProgram { + pub exec: Option, + pub args: Vec, + pub env: BTreeMap, + pub cwd: Option, + pub uid: Option, + pub gid: Option, + pub clean_env: bool, + pub no_coredump: bool, + pub no_huge_pages: bool, +} + +/// `[filesystem]`, with mount specs resolved and branch actions made explicit. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalFilesystem { + pub read: Vec, + pub write: Vec, + pub deny: Vec, + pub chroot: Option, + pub mount: Vec, + /// Always present. The profile grammar lets both branch actions default, + /// and a consumer that supplies its own default for an absent key is free + /// to pick a different one, which changes what happens to a COW branch + /// without any error being raised. Resolving the default here means the + /// contract lives in one place. + pub on_exit: CanonicalBranchAction, + pub on_error: CanonicalBranchAction, +} + +/// One resolved `VIRTUAL:HOST[:ro|:rw]` mount spec. +/// +/// `ro` is the effective read-only setting for `virt`, not the flag written on +/// this particular spec. The core keys read-only mounts by virtual path +/// (`Sandbox::fs_mount_ro` is a list of virtual paths), so two specs that share +/// a virtual path share one verdict: if any of them says `:ro`, writes through +/// that virtual path are denied for all of them. This form reports what the +/// sandbox will do rather than what the text said, which is also what +/// [`super::sandbox_to_profile`] prints when it re-emits the same policy as +/// specs. +/// +/// Not modelled: read-only is enforced by virtual-path prefix, so a mount +/// nested under a read-only one is also write-denied at run time while its `ro` +/// here stays false. Distinguishing the two would need read-only to be keyed by +/// `(virt, host)` in the core, which this form cannot do on its own. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CanonicalMount { + pub virt: PathBuf, + pub host: PathBuf, + pub ro: bool, +} + +/// `[filesystem].on_exit` / `on_error`, resolved from the three string literals. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CanonicalBranchAction { + #[default] + Commit, + Abort, + Keep, +} + +impl From for CanonicalBranchAction { + fn from(a: BranchAction) -> Self { + match a { + BranchAction::Commit => CanonicalBranchAction::Commit, + BranchAction::Abort => CanonicalBranchAction::Abort, + BranchAction::Keep => CanonicalBranchAction::Keep, + } + } +} + +/// `[network]`, with bind ports expanded and rules structured. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalNetwork { + pub allow_bind: CanonicalBindPorts, + /// `any` is always false here: the wildcard is an allow-only token and the + /// grammar rejects it for deny. The shape is shared with `allow_bind` so a + /// consumer needs one mapper, not two. + pub deny_bind: CanonicalBindPorts, + pub allow: Vec, + pub deny: Vec, + pub port_remap: bool, +} + +/// A resolved bind-port list: either the `*` wildcard or an expanded, +/// sorted, deduplicated set of ports. Ranges (`"9000-9002"`) and comma +/// lists are already flattened. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalBindPorts { + /// The `*` wildcard: any port may be bound. `ports` is empty when set. + pub any: bool, + pub ports: Vec, +} + +/// One resolved `--net-allow` / `--net-deny` rule. +/// +/// A scheme-less profile entry names two protocols, so it resolves to two +/// rules here; the array length is not the profile array length. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CanonicalNetRule { + pub protocol: Protocol, + pub target: CanonicalNetTarget, + /// Empty when `all_ports` is set, and always empty for ICMP. + pub ports: Vec, + pub all_ports: bool, + /// The single-protocol spec string this rule round-trips through, rendered + /// by the core formatter. + /// + /// The builder ABI takes net rules as spec strings, so a consumer that + /// feeds a profile back into a builder needs one. Emitting it here keeps + /// the grammar (including the IPv6 bracket rule) on this side: the + /// consumer forwards an opaque string, it never composes one. + pub spec: String, +} + +/// What a net rule targets at the IP layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum CanonicalNetTarget { + /// Any destination IP (`*`, or a bare `:port`). + Any, + /// A hostname, resolved at sandbox start. Allow-only: the deny grammar + /// rejects hostnames, so a deny rule never carries this variant. + Host { host: String }, + /// A literal IP or a CIDR range. A bare IP arrives as a host route + /// (`prefix_len` 32 or 128). + Cidr { address: String, prefix_len: u8 }, +} + +/// `[http]`, with rules structured. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalHttp { + /// Ports exactly as written in the profile. The builder substitutes + /// `[80]` (or `[80, 443]` with a CA) when this is empty; that derivation + /// belongs to the effective policy, not to the profile. + pub ports: Vec, + pub allow: Vec, + pub deny: Vec, +} + +/// One resolved `"METHOD host[/path]"` rule: the method is already +/// upper-cased and the path already normalized (percent-decoded, `//` +/// collapsed, `.`/`..` resolved, trailing `*` preserved). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CanonicalHttpRule { + pub method: String, + pub host: String, + pub path: String, + /// The rule re-rendered as a spec string, for the same reason as + /// [`CanonicalNetRule::spec`]: the builder ABI takes HTTP rules as strings. + pub spec: String, +} + +/// `[syscalls]`. +/// +/// Names are not expanded: `extra_allow` accepts group names only, so +/// substituting a group's members would produce a list the builder rejects. +/// `extra_deny` accepts both a group name and a bare syscall name, and its +/// validity is architecture dependent (the name table is per-target), which +/// is why this form resolves nothing here. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalSyscalls { + pub extra_allow: Vec, + pub extra_deny: Vec, +} + +/// `[limits]`, with byte sizes resolved to integer bytes. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct CanonicalLimits { + pub memory: Option, + pub disk: Option, + pub processes: Option, + pub open_files: Option, + pub cpu: Option, + pub gpu_devices: Option>, + pub cpu_cores: Option>, + pub num_cpus: Option, +} + +// ============================================================ +// Resolution +// ============================================================ + +/// Parse a TOML profile into its canonical form. +/// +/// The profile goes through exactly the same pipeline a CLI user hits +/// (`super::parse_input`, including the builder's cross-section checks), so a +/// profile that fails to load here fails with the identical message the CLI +/// prints; the resolved form is then emitted from the profile input rather than +/// from the built policy, so none of the builder's derived state leaks in. +pub fn parse(content: &str) -> Result { + let input = super::deserialize_profile(content)?; + // Validation only: the policy is discarded. Resolving from `input` below + // cannot fail once this call has succeeded, because both walk the same + // grammar functions over the same strings. + let _validated = super::parse_input(input.clone())?; + resolve(&input) +} + +/// Parse a TOML profile into canonical JSON. +pub fn parse_to_json(content: &str) -> Result { + let canonical = parse(content)?; + serde_json::to_string_pretty(&canonical).map_err(|e| { + SandlockError::Sandbox(SandboxError::Invalid(format!("JSON serialize error: {e}"))) + }) +} + +/// Resolve every micro-grammar in a deserialized profile. +/// +/// Private, and deliberately so: it resolves grammars but runs none of the +/// cross-section checks (a uid without a gid, `net.allow` together with +/// `net.deny`, an HTTP CA without its key). [`parse`] is the entry point +/// because it runs `super::parse_input` first. +fn resolve(input: &ProfileInput) -> Result { + let mut mount = Vec::with_capacity(input.filesystem.mount.len()); + for spec in &input.filesystem.mount { + let (virt, host, ro) = super::parse_mount_spec(spec)?; + mount.push(CanonicalMount { virt, host, ro }); + } + // Read-only is keyed by virtual path in the core, so specs that share one + // share its verdict. Emit the effective flag rather than the written one. + let read_only: Vec = mount + .iter() + .filter(|m| m.ro) + .map(|m| m.virt.clone()) + .collect(); + for m in mount.iter_mut() { + m.ro = read_only.contains(&m.virt); + } + + let time_start = match input.determinism.time_start.as_deref() { + Some(s) => Some(CanonicalTimestamp::from(super::parse_timestamp(s)?)), + None => None, + }; + + let on_exit = match input.filesystem.on_exit.as_deref() { + Some(s) => super::parse_branch_action(s)?, + None => BranchAction::default(), + }; + let on_error = match input.filesystem.on_error.as_deref() { + Some(s) => super::parse_branch_action(s)?, + None => BranchAction::default(), + }; + + Ok(CanonicalProfile { + config: CanonicalConfig { + http_ca: input.config.http_ca.clone(), + http_key: input.config.http_key.clone(), + http_inject_ca: input.config.http_inject_ca.clone(), + http_ca_out: input.config.http_ca_out.clone(), + fs_storage: input.config.fs_storage.clone(), + workdir: input.config.workdir.clone(), + }, + determinism: CanonicalDeterminism { + random_seed: input.determinism.random_seed, + time_start, + deterministic_dirs: input.determinism.deterministic_dirs, + no_randomize_memory: input.determinism.no_randomize_memory, + }, + program: CanonicalProgram { + exec: input.program.exec.clone(), + args: input.program.args.clone(), + env: input + .program + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + cwd: input.program.cwd.clone(), + uid: input.program.uid, + gid: input.program.gid, + clean_env: input.program.clean_env, + no_coredump: input.program.no_coredump, + no_huge_pages: input.program.no_huge_pages, + }, + filesystem: CanonicalFilesystem { + read: input.filesystem.read.clone(), + write: input.filesystem.write.clone(), + deny: input.filesystem.deny.clone(), + chroot: input.filesystem.chroot.clone(), + mount, + on_exit: on_exit.into(), + on_error: on_error.into(), + }, + network: CanonicalNetwork { + allow_bind: resolve_allow_bind(&input.network.allow_bind)?, + deny_bind: resolve_deny_bind(&input.network.deny_bind)?, + allow: resolve_net_rules(&input.network.allow, NetRule::parse_allow)?, + deny: resolve_net_rules(&input.network.deny, NetRule::parse_deny)?, + port_remap: input.network.port_remap, + }, + http: CanonicalHttp { + ports: input.http.ports.clone(), + allow: resolve_http_rules(&input.http.allow)?, + deny: resolve_http_rules(&input.http.deny)?, + }, + syscalls: CanonicalSyscalls { + extra_allow: input.syscalls.extra_allow.clone(), + extra_deny: input.syscalls.extra_deny.clone(), + }, + limits: CanonicalLimits { + memory: resolve_byte_size(input.limits.memory.as_deref())?, + disk: resolve_byte_size(input.limits.disk.as_deref())?, + processes: input.limits.processes, + open_files: input.limits.open_files, + cpu: input.limits.cpu, + gpu_devices: input.limits.gpu_devices.clone(), + cpu_cores: input.limits.cpu_cores.clone(), + num_cpus: input.limits.num_cpus, + }, + }) +} + +impl From for CanonicalTimestamp { + fn from(ts: jiff::Timestamp) -> Self { + // jiff signs the sub-second part to match the seconds part, so a + // pre-epoch stamp arrives as e.g. (0, -500_000_000). Carry the borrow + // so the emitted remainder is always non-negative. + let mut seconds = ts.as_second(); + let mut nanoseconds = i64::from(ts.subsec_nanosecond()); + if nanoseconds < 0 { + seconds -= 1; + nanoseconds += 1_000_000_000; + } + CanonicalTimestamp { + seconds, + nanoseconds: nanoseconds as u32, + } + } +} + +/// `PortSpec` is what the TOML array holds: a bare integer or a string +/// holding a comma list and/or a range. The builder stringifies the integer +/// form and runs one grammar over both, so do the same here. +fn port_specs_to_strings(specs: &[PortSpec]) -> Vec { + specs + .iter() + .map(|s| match s { + PortSpec::Port(p) => p.to_string(), + PortSpec::Spec(s) => s.clone(), + }) + .collect() +} + +fn resolve_allow_bind(specs: &[PortSpec]) -> Result { + let strings = port_specs_to_strings(specs); + let ports = crate::sandbox::parse_allow_bind_ports(&strings, "--net-allow-bind") + .map_err(SandlockError::Sandbox)?; + Ok(match ports { + BindPorts::All => CanonicalBindPorts { + any: true, + ports: Vec::new(), + }, + BindPorts::Ports(ports) => CanonicalBindPorts { any: false, ports }, + }) +} + +fn resolve_deny_bind(specs: &[PortSpec]) -> Result { + let strings = port_specs_to_strings(specs); + let ports = crate::sandbox::parse_bind_ports(&strings, "--net-deny-bind") + .map_err(SandlockError::Sandbox)?; + Ok(CanonicalBindPorts { any: false, ports }) +} + +fn resolve_net_rules( + specs: &[String], + parse_one: fn(&str) -> Result, SandboxError>, +) -> Result, SandlockError> { + let mut out = Vec::new(); + for spec in specs { + for rule in parse_one(spec).map_err(SandlockError::Sandbox)? { + let spec = super::format_net_rule(&rule); + let target = match rule.target { + NetTarget::AnyIp => CanonicalNetTarget::Any, + NetTarget::Host(host) => CanonicalNetTarget::Host { host }, + NetTarget::Cidr(cidr) => CanonicalNetTarget::Cidr { + address: cidr.addr.to_string(), + prefix_len: cidr.prefix_len, + }, + }; + out.push(CanonicalNetRule { + protocol: rule.protocol, + target, + ports: rule.ports, + all_ports: rule.all_ports, + spec, + }); + } + } + Ok(out) +} + +fn resolve_http_rules(specs: &[String]) -> Result, SandlockError> { + let mut out = Vec::with_capacity(specs.len()); + for spec in specs { + let rule = HttpRule::parse(spec).map_err(SandlockError::Sandbox)?; + out.push(CanonicalHttpRule { + spec: super::format_http_rule(&rule), + method: rule.method, + host: rule.host, + path: rule.path, + }); + } + Ok(out) +} + +fn resolve_byte_size(s: Option<&str>) -> Result, SandlockError> { + match s { + Some(s) => Ok(Some( + ByteSize::parse(s).map_err(SandlockError::Sandbox)?.0, + )), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn json(toml: &str) -> serde_json::Value { + let s = parse_to_json(toml).unwrap_or_else(|e| panic!("parse failed: {e}")); + serde_json::from_str(&s).unwrap() + } + + fn err(toml: &str) -> String { + format!("{}", parse_to_json(toml).unwrap_err()) + } + + // ---- mounts ---- + + #[test] + fn mounts_resolve_to_structured_objects() { + let v = json( + r#" + [filesystem] + mount = ["/data:/srv/data", "/work:/srv/work:ro", "/tmpdir:/srv/tmp:rw"] + "#, + ); + assert_eq!( + v["filesystem"]["mount"], + serde_json::json!([ + {"virt": "/data", "host": "/srv/data", "ro": false}, + {"virt": "/work", "host": "/srv/work", "ro": true}, + {"virt": "/tmpdir", "host": "/srv/tmp", "ro": false}, + ]) + ); + } + + #[test] + fn mount_host_path_may_contain_colons() { + // Only a trailing :ro/:rw is an option; the split takes the first colon. + let v = json( + r#" + [filesystem] + mount = ["/v:/a:b:ro"] + "#, + ); + assert_eq!( + v["filesystem"]["mount"][0], + serde_json::json!({"virt": "/v", "host": "/a:b", "ro": true}) + ); + } + + #[test] + fn duplicate_virtual_mounts_report_one_effective_read_only_flag() { + // Read-only is keyed by virtual path in the core, so `:ro` on one spec + // denies writes through `/w` for both. Reporting the written flag here + // would describe a policy no layer applies. + const TOML: &str = r#" + [filesystem] + mount = ["/w:/h1", "/w:/h2:ro"] + [program] + exec = "/bin/true" + "#; + assert_eq!( + json(TOML)["filesystem"]["mount"], + serde_json::json!([ + {"virt": "/w", "host": "/h1", "ro": true}, + {"virt": "/w", "host": "/h2", "ro": true}, + ]) + ); + + // What the built policy actually enforces, from the same text. + let (sandbox, _) = super::super::parse_profile(TOML).unwrap(); + assert_eq!(sandbox.fs_mount.len(), 2); + for (virt, _) in &sandbox.fs_mount { + assert!( + sandbox.fs_mount_ro.iter().any(|d| d == virt), + "{virt:?} is write-denied by the built policy" + ); + } + // And what the core prints when it re-emits that policy as specs. + let back = super::super::sandbox_to_profile(&sandbox, &[]); + assert_eq!(back.filesystem.mount, vec!["/w:/h1:ro", "/w:/h2:ro"]); + } + + #[test] + fn invalid_mount_specs_are_errors() { + assert!(err("[filesystem]\nmount = [\"nocolon\"]").contains("VIRTUAL:HOST")); + assert!(err("[filesystem]\nmount = [\":/host\"]").contains("non-empty")); + assert!(err("[filesystem]\nmount = [\"/virt:\"]").contains("non-empty")); + } + + // ---- byte sizes ---- + + #[test] + fn sizes_resolve_to_integer_bytes() { + let v = json( + r#" + [limits] + memory = "512M" + disk = "1G" + "#, + ); + assert_eq!(v["limits"]["memory"], serde_json::json!(536870912u64)); + assert_eq!(v["limits"]["disk"], serde_json::json!(1073741824u64)); + } + + #[test] + fn size_without_suffix_is_bytes_and_zero_is_kept() { + let v = json("[limits]\nmemory = \"512\"\ndisk = \"0\""); + assert_eq!(v["limits"]["memory"], serde_json::json!(512)); + assert_eq!(v["limits"]["disk"], serde_json::json!(0)); + } + + #[test] + fn absent_sizes_are_null_not_zero() { + let v = json("[limits]\ncpu = 50"); + assert!(v["limits"]["memory"].is_null()); + assert!(v["limits"]["disk"].is_null()); + assert_eq!(v["limits"]["cpu"], serde_json::json!(50)); + } + + #[test] + fn fractional_and_terabyte_sizes_are_rejected() { + // The core grammar takes integers with a K/M/G suffix. These two forms + // are the ones a lenient re-implementation tends to accept. + assert!(err("[limits]\nmemory = \"1.5G\"").contains("invalid byte size: 1.5G")); + assert!(err("[limits]\nmemory = \"1T\"").contains("unknown byte size suffix: T")); + } + + #[test] + fn overflowing_size_is_an_error_not_a_silent_zero() { + let msg = err("[limits]\nmemory = \"17179869184G\""); + assert!(msg.contains("out of range"), "got: {msg}"); + } + + // ---- time_start ---- + + #[test] + fn time_start_resolves_to_epoch_seconds() { + let v = json("[determinism]\ntime_start = \"2026-01-01T00:00:00Z\""); + assert_eq!( + v["determinism"]["time_start"], + serde_json::json!({"seconds": 1767225600i64, "nanoseconds": 0}) + ); + } + + #[test] + fn time_start_honours_the_offset() { + let v = json("[determinism]\ntime_start = \"2026-01-01T00:00:00+03:00\""); + assert_eq!( + v["determinism"]["time_start"]["seconds"], + serde_json::json!(1767225600i64 - 3 * 3600) + ); + } + + #[test] + fn time_start_keeps_sub_second_precision() { + let v = json("[determinism]\ntime_start = \"2026-01-01T00:00:00.25Z\""); + assert_eq!( + v["determinism"]["time_start"], + serde_json::json!({"seconds": 1767225600i64, "nanoseconds": 250000000u32}) + ); + } + + #[test] + fn pre_epoch_time_start_stays_signed_with_a_non_negative_remainder() { + let v = json("[determinism]\ntime_start = \"1969-12-31T23:59:59.5Z\""); + assert_eq!( + v["determinism"]["time_start"], + serde_json::json!({"seconds": -1i64, "nanoseconds": 500000000u32}) + ); + } + + #[test] + fn naive_time_start_is_rejected() { + // The grammar requires an offset; a consumer that treats a naive stamp + // as UTC would disagree with the CLI on what the profile means. + let msg = err("[determinism]\ntime_start = \"2026-01-01T00:00:00\""); + assert!(msg.contains("time_start"), "got: {msg}"); + assert!(msg.contains("offset"), "got: {msg}"); + } + + #[test] + fn bare_unix_seconds_in_time_start_are_rejected() { + assert!(err("[determinism]\ntime_start = \"1767225600\"").contains("time_start")); + } + + // ---- branch actions ---- + + #[test] + fn branch_actions_resolve_and_default_explicitly() { + let v = json("[filesystem]\nread = [\"/usr\"]"); + assert_eq!(v["filesystem"]["on_exit"], serde_json::json!("commit")); + assert_eq!(v["filesystem"]["on_error"], serde_json::json!("commit")); + + let v = json("[filesystem]\non_exit = \"keep\"\non_error = \"abort\""); + assert_eq!(v["filesystem"]["on_exit"], serde_json::json!("keep")); + assert_eq!(v["filesystem"]["on_error"], serde_json::json!("abort")); + } + + #[test] + fn branch_action_is_case_sensitive() { + let msg = err("[filesystem]\non_exit = \"COMMIT\""); + assert!(msg.contains("invalid branch action"), "got: {msg}"); + } + + // ---- bind ports ---- + + #[test] + fn bind_ports_expand_sort_and_deduplicate() { + let v = json("[network]\nallow_bind = [9001, \"9000-9002\", \"8080,8080\"]"); + assert_eq!( + v["network"]["allow_bind"], + serde_json::json!({"any": false, "ports": [8080, 9000, 9001, 9002]}) + ); + } + + #[test] + fn bind_port_wildcard_becomes_any() { + let v = json("[network]\nallow_bind = [\"*\"]"); + assert_eq!( + v["network"]["allow_bind"], + serde_json::json!({"any": true, "ports": []}) + ); + } + + #[test] + fn bind_port_zero_is_accepted() { + // Unlike net rules, where port 0 is rejected. + let v = json("[network]\nallow_bind = [0]"); + assert_eq!( + v["network"]["allow_bind"], + serde_json::json!({"any": false, "ports": [0]}) + ); + } + + #[test] + fn deny_bind_resolves_and_never_reports_any() { + let v = json("[network]\ndeny_bind = [8080, \"9000-9001\"]"); + assert_eq!( + v["network"]["deny_bind"], + serde_json::json!({"any": false, "ports": [8080, 9000, 9001]}) + ); + } + + #[test] + fn bind_port_grammar_errors_surface() { + assert!(err("[network]\nallow_bind = [\"90-80\"]").contains("reversed port range")); + assert!(err("[network]\nallow_bind = [\"8080,\"]").contains("empty port")); + assert!(err("[network]\ndeny_bind = [\"*\"]").contains("only supported for")); + assert!( + err("[network]\nallow_bind = [\"*\", \"8080\"]").contains("cannot be combined") + ); + } + + // ---- net rules ---- + + #[test] + fn scheme_less_net_rule_expands_to_tcp_and_udp() { + let v = json("[network]\nallow = [\"example.com:443\"]"); + assert_eq!( + v["network"]["allow"], + serde_json::json!([ + { + "protocol": "tcp", + "target": {"kind": "host", "host": "example.com"}, + "ports": [443], + "all_ports": false, + "spec": "tcp://example.com:443", + }, + { + "protocol": "udp", + "target": {"kind": "host", "host": "example.com"}, + "ports": [443], + "all_ports": false, + "spec": "udp://example.com:443", + }, + ]) + ); + } + + #[test] + fn net_rule_cidr_and_wildcard_targets_resolve() { + let v = json("[network]\nallow = [\"tcp://10.0.0.0/8:80,443\", \"udp://*\"]"); + assert_eq!( + v["network"]["allow"][0], + serde_json::json!({ + "protocol": "tcp", + "target": {"kind": "cidr", "address": "10.0.0.0", "prefix_len": 8}, + "ports": [80, 443], + "all_ports": false, + "spec": "tcp://10.0.0.0/8:80,443", + }) + ); + assert_eq!( + v["network"]["allow"][1], + serde_json::json!({ + "protocol": "udp", + "target": {"kind": "any"}, + "ports": [], + "all_ports": true, + "spec": "udp://*", + }) + ); + } + + #[test] + fn bare_ip_net_rule_becomes_a_host_route() { + let v = json("[network]\ndeny = [\"tcp://192.168.1.1:22\"]"); + assert_eq!( + v["network"]["deny"][0]["target"], + serde_json::json!({"kind": "cidr", "address": "192.168.1.1", "prefix_len": 32}) + ); + } + + #[test] + fn ipv6_net_rule_spec_keeps_the_bracket_form() { + let v = json("[network]\nallow = [\"tcp://[fc00::/7]:443\"]"); + let rule = &v["network"]["allow"][0]; + assert_eq!( + rule["target"], + serde_json::json!({"kind": "cidr", "address": "fc00::", "prefix_len": 7}) + ); + // The spec has to round-trip: an unbracketed addr:port is itself a + // valid IPv6 literal. + assert_eq!(rule["spec"], serde_json::json!("tcp://[fc00::/7]:443")); + } + + #[test] + fn icmp_net_rule_carries_no_ports() { + let v = json("[network]\nallow = [\"icmp://*\"]"); + assert_eq!( + v["network"]["allow"], + serde_json::json!([{ + "protocol": "icmp", + "target": {"kind": "any"}, + "ports": [], + "all_ports": true, + "spec": "icmp://*", + }]) + ); + } + + #[test] + fn net_rule_grammar_errors_surface() { + assert!(err("[network]\nallow = [\"example.com:0\"]").contains("port 0 is not valid")); + assert!(err("[network]\nallow = [\"ftp://example.com\"]").contains("unknown scheme")); + assert!(err("[network]\nallow = [\"icmp://example.com:1\"]").contains("takes no port")); + // Hostnames are allow-only. + assert!(err("[network]\ndeny = [\"example.com\"]").contains("hostnames are not allowed")); + } + + #[test] + fn net_allow_and_net_deny_are_mutually_exclusive() { + let msg = err("[network]\nallow = [\"1.2.3.4\"]\ndeny = [\"5.6.7.8\"]"); + assert!(msg.contains("mutually exclusive"), "got: {msg}"); + } + + // ---- http rules ---- + + #[test] + fn http_rules_resolve_with_uppercased_method_and_normalized_path() { + let v = json("[http]\nallow = [\"get Example.COM/v1//a/../b/\"]"); + assert_eq!( + v["http"]["allow"][0], + serde_json::json!({ + "method": "GET", + "host": "Example.COM", + "path": "/v1/b", + "spec": "GET Example.COM/v1/b", + }) + ); + } + + #[test] + fn http_rule_without_a_path_gets_the_wildcard_path() { + let v = json("[http]\ndeny = [\"* admin.internal\"]"); + assert_eq!( + v["http"]["deny"][0], + serde_json::json!({ + "method": "*", + "host": "admin.internal", + "path": "/*", + "spec": "* admin.internal/*", + }) + ); + } + + #[test] + fn http_rules_do_not_leak_into_the_net_allowlist() { + // The builder appends a net rule per HTTP host at build time. That is + // effective-policy state; a consumer that mapped it back into a builder + // would apply it twice. + let v = json("[http]\nallow = [\"GET api.example.com/v1/*\"]"); + assert_eq!(v["network"]["allow"], serde_json::json!([])); + // Same for the port default: the builder substitutes [80], the profile + // said nothing. + assert_eq!(v["http"]["ports"], serde_json::json!([])); + } + + #[test] + fn http_rule_grammar_errors_surface() { + assert!(err("[http]\nallow = [\"GET\"]").contains("invalid http rule")); + } + + // ---- unknown keys / invalid TOML ---- + + #[test] + fn unknown_section_is_an_error() { + let msg = err("[bogus]\nx = 1"); + assert!(msg.contains("unknown field"), "got: {msg}"); + assert!(msg.contains("bogus"), "got: {msg}"); + } + + #[test] + fn unknown_field_in_a_known_section_is_an_error() { + let msg = err("[program]\nexec = \"/bin/true\"\nbogus = 1"); + assert!(msg.contains("unknown field"), "got: {msg}"); + assert!(msg.contains("bogus"), "got: {msg}"); + } + + #[test] + fn old_flat_format_is_an_error() { + assert!(err("fs_readable = [\"/usr\"]").contains("unknown field")); + } + + #[test] + fn invalid_toml_is_an_error() { + let msg = err("[program"); + assert!(msg.contains("TOML parse error"), "got: {msg}"); + } + + #[test] + fn wrong_scalar_type_is_an_error() { + let msg = err("[limits]\ncpu = 300"); + assert!(msg.contains("TOML parse error"), "got: {msg}"); + assert!(msg.contains("expected u8"), "got: {msg}"); + } + + #[test] + fn time_start_must_be_a_string_not_an_integer() { + let msg = err("[determinism]\ntime_start = 1767225600"); + assert!(msg.contains("invalid type: integer"), "got: {msg}"); + } + + // ---- cross-section validation ---- + + #[test] + fn cross_section_checks_run_at_parse_time() { + // These live in the builder, not in the schema. Running them here is + // the point: a broken profile fails when it is loaded. + assert!(err("[limits]\ncpu = 0").contains("max_cpu must be 1-100")); + assert!(err("[limits]\nopen_files = 0").contains("greater than 0")); + assert!(err("[program]\nuid = 1000").contains("must both be set")); + assert!( + err("[syscalls]\nextra_allow = [\"read\"]").contains("unknown syscall group name") + ); + } + + // ---- whole-profile shape ---- + + #[test] + fn every_section_is_always_present() { + let v = json(""); + for section in [ + "config", + "determinism", + "program", + "filesystem", + "network", + "http", + "syscalls", + "limits", + ] { + assert!(v.get(section).is_some(), "missing section {section}"); + } + } + + #[test] + fn program_identity_survives() { + // The effective-policy serializer drops exec/args; a profile consumer + // cannot run anything without them. + let v = json( + r#" + [program] + exec = "/usr/bin/redis-cli" + args = ["-h", "cache.internal"] + "#, + ); + assert_eq!( + v["program"]["exec"], + serde_json::json!("/usr/bin/redis-cli") + ); + assert_eq!( + v["program"]["args"], + serde_json::json!(["-h", "cache.internal"]) + ); + } + + #[test] + fn env_is_emitted_in_a_stable_order() { + let toml = r#" + [program] + env = { zulu = "1", alpha = "2", mike = "3" } + "#; + let first = parse_to_json(toml).unwrap(); + for _ in 0..8 { + assert_eq!(parse_to_json(toml).unwrap(), first); + } + let v: serde_json::Value = serde_json::from_str(&first).unwrap(); + let keys: Vec<&str> = v["program"]["env"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(keys, ["alpha", "mike", "zulu"]); + } + + #[test] + fn canonical_json_round_trips_through_the_canonical_type() { + // The consumer side deserializes this shape; it has to survive the + // trip, and an unknown key has to be rejected there too. + let toml = r#" + [program] + exec = "/bin/true" + [filesystem] + mount = ["/data:/srv:ro"] + [network] + allow = ["tcp://example.com:443"] + allow_bind = ["*"] + [http] + deny = ["POST admin.example.com/x"] + [limits] + memory = "512M" + [determinism] + time_start = "2026-01-01T00:00:00Z" + "#; + let parsed = parse(toml).unwrap(); + let text = parse_to_json(toml).unwrap(); + let back: CanonicalProfile = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed, back); + + let with_extra = text.replacen('{', "{\"bogus\": 1,", 1); + let e = serde_json::from_str::(&with_extra).unwrap_err(); + assert!(format!("{e}").contains("unknown field"), "got: {e}"); + } + + #[test] + fn full_profile_resolves_every_grammar_at_once() { + let v = json( + r#" + [config] + http_ca = "/etc/sandlock/ca.pem" + http_key = "/etc/sandlock/ca.key" + + [determinism] + random_seed = 42 + time_start = "2026-01-01T00:00:00Z" + deterministic_dirs = true + + [program] + exec = "/usr/bin/redis-cli" + args = ["-h", "cache.internal"] + uid = 1000 + gid = 1000 + clean_env = true + + [filesystem] + read = ["/usr"] + mount = ["/data:/srv/redis:ro"] + on_exit = "keep" + + [network] + allow_bind = [8080, "9000-9001"] + allow = ["tcp://cache.internal:6379"] + port_remap = true + + [http] + ports = [80, 443] + allow = ["GET api.internal/v1/*"] + + [syscalls] + extra_allow = ["sysv_ipc"] + extra_deny = ["ptrace"] + + [limits] + memory = "512M" + disk = "1G" + cpu = 80 + "#, + ); + + assert_eq!(v["determinism"]["time_start"]["seconds"], 1767225600i64); + assert_eq!( + v["filesystem"]["mount"][0], + serde_json::json!({"virt": "/data", "host": "/srv/redis", "ro": true}) + ); + assert_eq!(v["filesystem"]["on_exit"], "keep"); + assert_eq!(v["filesystem"]["on_error"], "commit"); + assert_eq!( + v["network"]["allow_bind"], + serde_json::json!({"any": false, "ports": [8080, 9000, 9001]}) + ); + assert_eq!(v["network"]["allow"][0]["ports"], serde_json::json!([6379])); + assert_eq!(v["http"]["allow"][0]["path"], "/v1/*"); + assert_eq!(v["limits"]["memory"], 536870912u64); + assert_eq!(v["limits"]["disk"], 1073741824u64); + assert_eq!(v["syscalls"]["extra_allow"], serde_json::json!(["sysv_ipc"])); + } + + #[test] + fn parse_error_text_matches_what_the_cli_prints() { + // Same profile, same pipeline: the message a consumer surfaces is the + // message a CLI user sees. + for toml in [ + "[filesystem]\nmount = [\"nocolon\"]", + "[limits]\nmemory = \"1.5G\"", + "[determinism]\ntime_start = \"nope\"", + "[network]\nallow = [\"example.com:0\"]", + "[program]\nbogus = 1", + // The last two only fail inside the builder, so they also pin the + // fact that this path runs the builder's checks at all. + "[limits]\ncpu = 0", + "[program]\nuid = 1000", + ] { + let canonical = format!("{}", parse_to_json(toml).unwrap_err()); + let cli = format!("{}", super::super::parse_profile(toml).unwrap_err()); + assert_eq!(canonical, cli, "profile: {toml}"); + } + } +} + diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 3b4f8109..23ac4123 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -2958,7 +2958,7 @@ fn validate_allow_deny_disjoint( /// Parse `--net-allow-bind` specs. Accepts the `*` wildcard (any port), /// which cannot be combined with port lists; repeating the bare wildcard /// is idempotent. -fn parse_allow_bind_ports(specs: &[String], label: &str) -> Result { +pub(crate) fn parse_allow_bind_ports(specs: &[String], label: &str) -> Result { let mut parts = specs.iter().flat_map(|s| s.split(',')).map(str::trim); if !parts.clone().any(|part| part == "*") { return Ok(BindPorts::Ports(parse_bind_ports(specs, label)?)); @@ -2975,7 +2975,7 @@ fn parse_allow_bind_ports(specs: &[String], label: &str) -> Result Result, SandboxError> { +pub(crate) fn parse_bind_ports(specs: &[String], label: &str) -> Result, SandboxError> { let mut ports: std::collections::BTreeSet = std::collections::BTreeSet::new(); for spec in specs { for part in spec.split(',') { diff --git a/crates/sandlock-core/tests/profile_canonical_adversarial.rs b/crates/sandlock-core/tests/profile_canonical_adversarial.rs new file mode 100644 index 00000000..186e4829 --- /dev/null +++ b/crates/sandlock-core/tests/profile_canonical_adversarial.rs @@ -0,0 +1,444 @@ +//! Malformed and hostile input for the canonical profile parser. +//! +//! The unit tests next to the parser cover what a well-formed profile resolves +//! to. These cover what happens when it is not well formed, because that is the +//! half a binding depends on: the canonical form exists so that a profile means +//! the same thing through the CLI and through an SDK, and a profile that is +//! *rejected* by one and *accepted* by the other is the same divergence as one +//! that resolves differently. So the centrepiece here is a battery that runs +//! every hostile profile through both entry points and demands byte-identical +//! diagnoses. + +use sandlock_core::profile::{canonical, parse_profile}; + +fn ok(toml: &str) -> serde_json::Value { + let json = canonical::parse_to_json(toml).unwrap_or_else(|e| panic!("{toml:?} failed: {e}")); + serde_json::from_str(&json).expect("emitted JSON must parse") +} + +fn err(toml: &str) -> String { + match canonical::parse_to_json(toml) { + Ok(json) => panic!("{toml:?} was accepted, giving: {json}"), + Err(e) => e.to_string(), + } +} + +/// Every hostile profile in this file, so the parity check below cannot drift +/// away from the cases the other tests actually exercise. +fn hostile_profiles() -> Vec { + let mut cases: Vec = Vec::new(); + cases.extend(EMPTY_VALUES.iter().map(|(toml, _)| toml.to_string())); + cases.extend(OUT_OF_RANGE.iter().map(|(toml, _)| toml.to_string())); + cases.extend(WRONG_TYPES.iter().map(|(toml, _)| toml.to_string())); + cases.extend( + [ + // nothing to parse + "", + " \n\t\n", + "# just a comment\n", + // structure + "[bogus]\nx = 1\n", + "[limits]\nbogus = 1\n", + "memory = \"1G\"\n", + "[limits]\nmemory = \"1G\"\nmemory = \"2G\"\n", + "[limits]\nmemory = \"1G\"\n[limits]\ncpu = 1\n", + "[program.env]\nA = \"1\"\nA = \"2\"\n", + "[program", + // NUL smuggled in through the TOML escape + "[limits]\nmemory = \"1\\u0000G\"\n", + "[syscalls]\nextra_deny = [\"re\\u0000ad\"]\n", + "[filesystem]\nmount = [\"\\u0000\"]\n", + "[program]\nexec = \"a\\u0000b\"\n", + "[filesystem]\nchroot = \"/a\\u0000b\"\n", + // grammar shapes + "[filesystem]\nmount = [\"nocolon\"]\n", + "[filesystem]\nmount = [\"/v:/h:bogus\"]\n", + "[filesystem]\non_exit = \"Commit\"\n", + "[filesystem]\non_exit = \"bogus\"\n", + "[determinism]\ntime_start = \"nope\"\n", + "[determinism]\ntime_start = \"2026-01-01T00:00:00\"\n", + "[determinism]\ntime_start = \"1700000000\"\n", + "[determinism]\ntime_start = \"999999-01-01T00:00:00Z\"\n", + "[determinism]\ntime_start = \"2026-01-01T00:00:00+99:00\"\n", + "[limits]\nmemory = \"1.5G\"\n", + "[limits]\nmemory = \"1T\"\n", + "[network]\nallow = [\"ftp://example.com\"]\n", + "[network]\nallow = [\"icmp://example.com:80\"]\n", + "[network]\nallow = [\"tcp://10.0.0.0/33\"]\n", + "[network]\nallow = [\"tcp://[::1]/129\"]\n", + "[network]\nallow_bind = [\"9-1\"]\n", + "[network]\nallow_bind = [\"-\"]\n", + "[network]\nallow_bind = [\"*\", \"80\"]\n", + "[network]\ndeny_bind = [\"*\"]\n", + "[http]\nallow = [\"GET\"]\n", + // cross-section checks, which only fire inside the builder + "[network]\nallow = [\"tcp://a\"]\ndeny = [\"tcp://b\"]\n", + "[syscalls]\nextra_allow = [\"sysv_ipc\"]\nextra_deny = [\"sysv_ipc\"]\n", + "[syscalls]\nextra_allow = [\"read\"]\n", + "[syscalls]\nextra_deny = [\"no_such_syscall\"]\n", + "[program]\nuid = 1000\n", + "[program]\ngid = 1000\n", + "[limits]\ncpu = 0\n", + "[limits]\nopen_files = 0\n", + ] + .iter() + .map(|s| s.to_string()), + ); + cases +} + +/// The reason this form exists. `canonical::parse` runs the CLI's own pipeline +/// instead of a second validator, so a profile that is rejected must be +/// rejected identically, down to the wording: an SDK user filing a bug quotes a +/// message a CLI user can reproduce. +/// +/// Comparing the full string, not a prefix, is deliberate. Any re-implemented +/// check would almost certainly still say "invalid byte size" while differing +/// in the value it quotes or the layer it names. +#[test] +fn every_rejection_is_word_for_word_what_the_cli_prints() { + for toml in hostile_profiles() { + let canonical = canonical::parse(&toml).err().map(|e| e.to_string()); + let cli = parse_profile(&toml).err().map(|e| e.to_string()); + assert_eq!(canonical, cli, "profile: {toml:?}"); + } +} + +// --------------------------------------------------------------- +// Nothing to parse +// --------------------------------------------------------------- + +/// An empty profile is a profile that constrains nothing, not a syntax error. +/// It is also the shape a caller gets from an empty file or an empty string, so +/// it has to produce the full section skeleton rather than a partial document +/// that a consumer's field mapping would trip over. +#[test] +fn a_profile_with_nothing_in_it_still_yields_every_section() { + for toml in ["", " \n\t\n", "# just a comment\n", "\u{feff}[limits]\n"] { + let v = ok(toml); + for section in [ + "config", + "determinism", + "program", + "filesystem", + "network", + "http", + "syscalls", + "limits", + ] { + assert!(v[section].is_object(), "{toml:?} lost [{section}]"); + } + assert_eq!(v["limits"]["memory"], serde_json::Value::Null); + assert_eq!(v["filesystem"]["on_exit"], "commit"); + } +} + +/// A file with no recognized section is far more likely to be the wrong file, +/// or a schema that moved, than an empty policy. Accepting it as "no +/// constraints" would hand a caller a wide-open sandbox from a typo. +#[test] +fn a_file_with_no_known_section_is_rejected_not_read_as_empty() { + let msg = err("[bogus]\nx = 1\n"); + assert!(msg.contains("unknown field `bogus`"), "{msg}"); + // The message has to list the alternatives, or the caller cannot tell a + // typo from a version skew. + assert!(msg.contains("`limits`"), "{msg}"); + + // The old flat schema, where keys sat at the top level, fails the same way. + assert!(err("memory = \"1G\"\n").contains("unknown field `memory`")); +} + +/// Both sides of the wire reject unknown keys, which is what makes a schema +/// change a load-time failure instead of a setting that quietly stops applying. +#[test] +fn an_unknown_key_inside_a_known_section_is_rejected() { + let msg = err("[limits]\nbogus = 1\n"); + assert!(msg.contains("unknown field `bogus`"), "{msg}"); + assert!(msg.contains("`memory`"), "{msg}"); +} + +/// TOML forbids these outright; the point of pinning it is that a profile +/// written twice never silently resolves to "the last one wins", which would +/// make a merge conflict resolve itself into a policy nobody chose. +#[test] +fn a_key_written_twice_is_rejected() { + assert!(err("[limits]\nmemory = \"1G\"\nmemory = \"2G\"\n").contains("duplicate key `memory`")); + assert!(err("[limits]\nmemory = \"1G\"\n[limits]\ncpu = 1\n").contains("duplicate key")); + assert!(err("[program.env]\nA = \"1\"\nA = \"2\"\n").contains("duplicate key `A`")); +} + +// --------------------------------------------------------------- +// Right name, wrong type +// --------------------------------------------------------------- + +/// A field of the right name and the wrong type, in both directions: a string +/// where a number belongs and a number where a string belongs. A parser that +/// coerced would turn `cpu = "1"` into a working profile in one binding and a +/// failure in another, which is the drift this form removes. +const WRONG_TYPES: &[(&str, &str)] = &[ + ("[limits]\ncpu = \"1\"\n", "expected u8"), + ("[limits]\nmemory = 1024\n", "expected a string"), + ("[limits]\nmemory = [\"1G\"]\n", "expected a string"), + ( + "[determinism]\ntime_start = 1700000000\n", + "expected a string", + ), + ("[determinism]\nrandom_seed = \"5\"\n", "expected u64"), + ("[program]\nexec = 5\n", "invalid type: integer"), + ("[program]\nclean_env = \"true\"\n", "expected a boolean"), + ("[program]\nargs = [1, 2]\n", "invalid type: integer"), + ("[program]\nenv = \"a=b\"\n", "invalid type: string"), + ("[filesystem]\nmount = \"/a:/b\"\n", "invalid type: string"), + ("[filesystem]\nread = \"/a\"\n", "invalid type: string"), + ("[network]\nport_remap = 1\n", "invalid type: integer"), +]; + +#[test] +fn a_field_of_the_wrong_type_is_rejected_rather_than_coerced() { + for (toml, needle) in WRONG_TYPES { + let msg = err(toml); + assert!( + msg.contains(needle), + "{toml:?}: expected {needle:?}, got {msg}" + ); + } +} + +// --------------------------------------------------------------- +// Empty values, one per micro-grammar +// --------------------------------------------------------------- + +/// The empty string is the value a caller gets from an unset template variable +/// or an unfilled placeholder, so every grammar meets it eventually. Each one +/// has to name itself in the diagnosis; "invalid value" alone leaves the user +/// hunting through a profile for which of eight sections went wrong. +const EMPTY_VALUES: &[(&str, &str)] = &[ + ("[filesystem]\nmount = [\"\"]\n", "invalid mount spec \"\""), + ("[limits]\nmemory = \"\"\n", "empty byte size string"), + ("[limits]\ndisk = \"\"\n", "empty byte size string"), + ( + "[determinism]\ntime_start = \"\"\n", + "[determinism].time_start \"\"", + ), + ( + "[filesystem]\non_exit = \"\"\n", + "invalid branch action \"\"", + ), + ( + "[filesystem]\non_error = \"\"\n", + "invalid branch action \"\"", + ), + ("[network]\nallow = [\"\"]\n", "--net-allow: empty rule"), + ("[network]\ndeny = [\"\"]\n", "--net-deny: empty rule"), + ( + "[network]\nallow_bind = [\"\"]\n", + "--net-allow-bind: empty port", + ), + ( + "[network]\ndeny_bind = [\"\"]\n", + "--net-deny-bind: empty port", + ), + ("[http]\nallow = [\"\"]\n", "invalid http rule"), + ("[http]\ndeny = [\"\"]\n", "invalid http rule"), + ( + "[syscalls]\nextra_allow = [\"\"]\n", + "unknown syscall group name", + ), +]; + +#[test] +fn an_empty_value_is_rejected_by_the_grammar_that_owns_it() { + for (toml, needle) in EMPTY_VALUES { + let msg = err(toml); + assert!( + msg.contains(needle), + "{toml:?}: expected {needle:?}, got {msg}" + ); + } +} + +/// Not every empty string is a grammar violation: a path is just a path, and +/// core accepts an empty one today. Pinning it keeps the previous test honest +/// about which list a field belongs to, and makes a future decision to reject +/// these show up as a deliberate change rather than an accident. +#[test] +fn an_empty_path_is_carried_through_rather_than_rejected() { + assert_eq!( + ok("[filesystem]\nchroot = \"\"\n")["filesystem"]["chroot"], + "" + ); + assert_eq!( + ok("[filesystem]\nread = [\"\"]\n")["filesystem"]["read"][0], + "" + ); + assert_eq!(ok("[program]\nexec = \"\"\n")["program"]["exec"], ""); +} + +// --------------------------------------------------------------- +// Numbers at and past the edges +// --------------------------------------------------------------- + +/// Sizes and ports both have a signed spelling a user can write and an +/// unsigned type they land in, which is where a silent wrap lives. Each of +/// these has to be a diagnosis, never a number. +const OUT_OF_RANGE: &[(&str, &str)] = &[ + // sizes: negative, non-numeric-large, and overflow through the suffix + ("[limits]\nmemory = \"-1\"\n", "invalid byte size: -1"), + ("[limits]\nmemory = \"-1G\"\n", "invalid byte size: -1G"), + ( + "[limits]\nmemory = \"18446744073709551616\"\n", + "invalid byte size", + ), + ( + "[limits]\nmemory = \"17179869184G\"\n", + "byte size out of range", + ), + ("[limits]\ndisk = \"-1M\"\n", "invalid byte size: -1M"), + // integers the schema types reject before any grammar runs + ("[limits]\nprocesses = -1\n", "invalid value"), + ("[limits]\nprocesses = 4294967296\n", "invalid value"), + ("[limits]\ncpu = 256\n", "invalid value"), + ("[program]\nuid = -1\n", "invalid value"), + ("[program]\nuid = 4294967296\n", "invalid value"), + ("[determinism]\nrandom_seed = -1\n", "invalid value"), + ("[limits]\ngpu_devices = [-1]\n", "invalid value"), + // ports written as integers, checked by the u16 schema type + ("[network]\nallow_bind = [65536]\n", "did not match"), + ("[network]\nallow_bind = [-1]\n", "did not match"), + ("[http]\nports = [65536]\n", "invalid value"), + ("[http]\nports = [-1]\n", "invalid value"), + // the same ports written as strings, checked by the port grammar + ( + "[network]\nallow_bind = [\"65536\"]\n", + "--net-allow-bind: invalid port `65536`", + ), + ( + "[network]\nallow_bind = [\"-1\"]\n", + "--net-allow-bind: invalid port range `-1`", + ), + ( + "[network]\nallow_bind = [\"1-65536\"]\n", + "--net-allow-bind: invalid port range", + ), + ( + "[network]\nallow = [\"tcp://example.com:65536\"]\n", + "invalid port `65536`", + ), + ( + "[network]\nallow = [\"tcp://example.com:-1\"]\n", + "invalid port `-1`", + ), +]; + +#[test] +fn a_number_outside_its_range_is_a_diagnosis_not_a_wrapped_value() { + for (toml, needle) in OUT_OF_RANGE { + let msg = err(toml); + assert!( + msg.contains(needle), + "{toml:?}: expected {needle:?}, got {msg}" + ); + } +} + +/// The largest values that are still legal, so the range checks above are +/// pinned from below as well: a check that rejected everything would satisfy +/// them just as well as a correct one. +#[test] +fn the_largest_legal_values_are_still_accepted() { + assert_eq!( + ok("[limits]\nmemory = \"18446744073709551615\"\n")["limits"]["memory"], + u64::MAX + ); + assert_eq!(ok("[limits]\nmemory = \"0\"\n")["limits"]["memory"], 0); + assert_eq!(ok("[limits]\ncpu = 100\n")["limits"]["cpu"], 100); + let ports = ok("[network]\nallow_bind = [\"0-65535\"]\n"); + assert_eq!(ports["network"]["allow_bind"]["ports"][0], 0); + assert_eq!(ports["network"]["allow_bind"]["ports"][65535], 65535); + assert_eq!(ports["network"]["allow_bind"]["any"], false); +} + +// --------------------------------------------------------------- +// NUL and length +// --------------------------------------------------------------- + +/// A NUL is the one byte the transport cannot carry, and TOML hands it over +/// on request. The canonical document is JSON, where it is an ordinary escape, +/// so the value must arrive whole: a parser that stopped at the NUL would ship +/// a *shorter* path or host than the profile asked for, which for a net rule +/// means allowing a different destination than the file names. +#[test] +fn a_nul_inside_a_value_is_carried_whole_not_truncated() { + let v = ok("[network]\nallow = [\"tcp://ex\\u0000ample.com\"]\n"); + assert_eq!( + v["network"]["allow"][0]["target"]["host"], + "ex\u{0}ample.com" + ); + assert_eq!(v["network"]["allow"][0]["spec"], "tcp://ex\u{0}ample.com"); + + let v = ok("[program.env]\nA = \"x\\u0000y\"\n"); + assert_eq!(v["program"]["env"]["A"], "x\u{0}y"); + + // Percent-decoding gets there with no TOML escape involved at all. + let v = ok("[http]\nallow = [\"GET example.com/a%00b\"]\n"); + assert_eq!(v["http"]["allow"][0]["path"], "/a\u{0}b"); +} + +/// A value long enough to outgrow any fixed buffer a consumer might have, and +/// enough of them to outgrow a single allocation, have to come back byte for +/// byte. Length is not part of any grammar here, so a limit appearing would be +/// an artifact of the plumbing rather than a decision. +#[test] +fn very_long_values_survive_the_round_trip() { + let long = "/".to_string() + &"a".repeat(500_000); + let v = ok(&format!("[filesystem]\nread = [\"{long}\"]\n")); + assert_eq!(v["filesystem"]["read"][0], long); + + let many: Vec = (0..2_000) + .map(|i| format!("\"/p{i}/{}\"", "b".repeat(500))) + .collect(); + let v = ok(&format!("[filesystem]\nread = [{}]\n", many.join(", "))); + assert_eq!(v["filesystem"]["read"].as_array().unwrap().len(), 2_000); +} + +/// Nesting is the classic way to turn a recursive-descent parser into a stack +/// overflow, which across a C ABI aborts the caller's whole process rather +/// than returning an error it can handle. The depth limit belongs to the TOML +/// parser; this pins that we are behind one. +#[test] +fn deeply_nested_input_is_an_error_not_a_stack_overflow() { + let deep = format!( + "[filesystem]\nread = {}{}\n", + "[".repeat(100_000), + "]".repeat(100_000) + ); + assert!(err(&deep).contains("TOML parse error")); + + let tables = format!( + "[program]\nenv = {}\"v\"{}\n", + "{a=".repeat(5_000), + "}".repeat(5_000) + ); + assert!(err(&tables).contains("TOML parse error")); +} + +/// The document is handed to a C caller as one NUL-terminated string, so it +/// has to be valid UTF-8 whatever the profile contained, and it has to +/// deserialize back into the canonical type: `deny_unknown_fields` on that type +/// means a round trip also proves no stray key crept into the emitted JSON. +#[test] +fn the_emitted_document_always_round_trips() { + for toml in [ + "", + "[filesystem]\nread = [\"/a\\u0000b\"]\n", + "[program.env]\n\"\" = \"v\"\n", + "[filesystem]\nread = [\"/\\u00e9/\\u0001/\\u007f\"]\n", + "[network]\nallow = [\"tcp://\\u043f\\u0440\\u0438.\\u0440\\u0444:443\"]\n", + "[filesystem]\nmount = [\"/v:/a:b:ro\"]\n", + ] { + let json = canonical::parse_to_json(toml).unwrap_or_else(|e| panic!("{toml:?}: {e}")); + let back: canonical::CanonicalProfile = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("{toml:?} did not round trip: {e}")); + assert_eq!(back, canonical::parse(toml).unwrap(), "profile: {toml:?}"); + } +} diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 3ab0c067..078515e1 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -712,6 +712,34 @@ sandlock_sandbox_t *sandlock_sandbox_build(sandlock_builder_t *b, int *err, char */ void sandlock_sandbox_free(sandlock_sandbox_t *p); +/** + * Parse a TOML profile into canonical JSON. + * + * The returned document has the same section layout as the profile, but every + * string micro-grammar is already resolved: mounts are + * `{"virt", "host", "ro"}` objects, `[limits]` sizes are integer bytes, + * `[determinism].time_start` is `{"seconds", "nanoseconds"}` since the epoch, + * bind ports are expanded integer lists, and net/HTTP rules are structured + * records. A binding only has to map fields, so it never grows a second copy + * of a grammar that can drift from this one. + * + * The profile is validated exactly as `sandlock run --profile` validates it, + * including the cross-section checks that normally run at build time, so a + * bad profile fails here with the message a CLI user sees for the same file. + * + * On success, `*err` is 0 and a heap-allocated JSON string is returned; the + * caller must release it with [`sandlock_string_free`]. On failure, `*err` is + * -1, null is returned, and `*err_msg` (if non-null) is set to a + * heap-allocated C string describing the error, released the same way. Pass + * `null` for `err_msg` to discard it. A null return always means failure. + * + * # Safety + * `toml` must be a valid NUL-terminated C string. `err` and `err_msg` may + * both be null. When `err_msg` is non-null, it must point to writable storage + * for one `*mut c_char`. + */ +char *sandlock_profile_parse(const char *toml, int *err, char **err_msg); + /** * Confine the calling process with Landlock filesystem rules. * This is irreversible. Returns 0 on success, -1 on error. @@ -946,10 +974,16 @@ const uint8_t *sandlock_result_stderr_bytes(const sandlock_result_t *r, uintptr_ void sandlock_result_free(sandlock_result_t *r); /** - * Free a string returned by `sandlock_result_stdout` or `sandlock_result_stderr`. + * Free a string returned by this library. + * + * Every function in this header that hands back a `char *` (capture buffers, + * `sandlock_profile_parse`, checkpoint names, change paths, port mappings, and + * the `err_msg` out-parameters) allocates it the same way and releases it + * here. * * # Safety - * `s` must be null or a pointer from a `sandlock_result_std*` function. + * `s` must be null or a `char *` returned by one of those functions, and must + * not have been freed already. */ void sandlock_string_free(char *s); diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 4115d19e..866fc9fa 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -1021,6 +1021,99 @@ pub unsafe extern "C" fn sandlock_sandbox_free(p: *mut sandlock_sandbox_t) { } } +// ---------------------------------------------------------------- +// Profile parsing +// ---------------------------------------------------------------- + +/// Parse a TOML profile into canonical JSON. +/// +/// The returned document has the same section layout as the profile, but every +/// string micro-grammar is already resolved: mounts are +/// `{"virt", "host", "ro"}` objects, `[limits]` sizes are integer bytes, +/// `[determinism].time_start` is `{"seconds", "nanoseconds"}` since the epoch, +/// bind ports are expanded integer lists, and net/HTTP rules are structured +/// records. A binding only has to map fields, so it never grows a second copy +/// of a grammar that can drift from this one. +/// +/// The profile is validated exactly as `sandlock run --profile` validates it, +/// including the cross-section checks that normally run at build time, so a +/// bad profile fails here with the message a CLI user sees for the same file. +/// +/// On success, `*err` is 0 and a heap-allocated JSON string is returned; the +/// caller must release it with [`sandlock_string_free`]. On failure, `*err` is +/// -1, null is returned, and `*err_msg` (if non-null) is set to a +/// heap-allocated C string describing the error, released the same way. Pass +/// `null` for `err_msg` to discard it. A null return always means failure. +/// +/// # Safety +/// `toml` must be a valid NUL-terminated C string. `err` and `err_msg` may +/// both be null. When `err_msg` is non-null, it must point to writable storage +/// for one `*mut c_char`. +#[no_mangle] +pub unsafe extern "C" fn sandlock_profile_parse( + toml: *const c_char, + err: *mut c_int, + err_msg: *mut *mut c_char, +) -> *mut c_char { + if !err_msg.is_null() { + *err_msg = ptr::null_mut(); + } + let fail = |msg: Option| -> *mut c_char { + if !err.is_null() { + *err = -1; + } + if !err_msg.is_null() { + if let Some(msg) = msg { + // Unlike every other export, this one's messages quote text the + // parser decoded rather than text a caller handed in as a C + // string, so they really can contain a NUL: TOML accepts the + // `\u0000` escape, so `memory = "1\u0000G"` lands verbatim in + // "invalid byte size: 1\0G". A C string cannot carry that, and + // dropping the message would report the failure with no + // diagnosis at all, so escape it the way core's own + // Debug-formatted messages already render a NUL and keep the + // text. After the replacement CString::new cannot fail; the + // guard stays because unwrapping here would panic across the C + // boundary. + let msg = if msg.contains('\0') { + msg.replace('\0', "\\0") + } else { + msg + }; + if let Ok(c) = CString::new(msg) { + *err_msg = c.into_raw(); + } + } + } + ptr::null_mut() + }; + + if toml.is_null() { + // A null profile is a programmer error in the binding layer, not a + // profile problem, so there is no user-actionable message to report. + return fail(None); + } + let content = match CStr::from_ptr(toml).to_str() { + Ok(s) => s, + // Not a hard-coded literal: the message comes from the decode error + // itself. Silently substituting "" here would report an empty profile + // as valid. + Err(e) => return fail(Some(format!("{}", e))), + }; + match sandlock_core::profile::canonical::parse_to_json(content) { + Ok(json) => match CString::new(json) { + Ok(c) => { + if !err.is_null() { + *err = 0; + } + c.into_raw() + } + Err(_) => fail(None), + }, + Err(e) => fail(Some(format!("{}", e))), + } +} + // ---------------------------------------------------------------- // Confine current process // ---------------------------------------------------------------- @@ -1690,10 +1783,16 @@ pub unsafe extern "C" fn sandlock_result_free(r: *mut sandlock_result_t) { } } -/// Free a string returned by `sandlock_result_stdout` or `sandlock_result_stderr`. +/// Free a string returned by this library. +/// +/// Every function in this header that hands back a `char *` (capture buffers, +/// `sandlock_profile_parse`, checkpoint names, change paths, port mappings, and +/// the `err_msg` out-parameters) allocates it the same way and releases it +/// here. /// /// # Safety -/// `s` must be null or a pointer from a `sandlock_result_std*` function. +/// `s` must be null or a `char *` returned by one of those functions, and must +/// not have been freed already. #[no_mangle] pub unsafe extern "C" fn sandlock_string_free(s: *mut c_char) { if !s.is_null() { diff --git a/crates/sandlock-ffi/tests/c/handler_smoke.c b/crates/sandlock-ffi/tests/c/handler_smoke.c index cd1d9b29..bee30fa9 100644 --- a/crates/sandlock-ffi/tests/c/handler_smoke.c +++ b/crates/sandlock-ffi/tests/c/handler_smoke.c @@ -74,6 +74,48 @@ static int check_inject_bytes(void) { return 0; } +/* Exercise sandlock_profile_parse() through the cdylib: a valid profile + * yields JSON with the micro-grammars already resolved, and an unknown key + * yields err=-1 plus a message. Returns 0 on success, non-zero on failure. */ +static int check_profile_parse(void) { + int err = 7; + char *err_msg = NULL; + char *json = sandlock_profile_parse( + "[filesystem]\nmount = [\"/data:/srv:ro\"]\n" + "[limits]\nmemory = \"512M\"\n", + &err, &err_msg); + if (json == NULL || err != 0 || err_msg != NULL) { + fprintf(stderr, "profile_parse: valid profile failed (err=%d, msg=%s)\n", + err, err_msg ? err_msg : "(none)"); + sandlock_string_free(err_msg); + sandlock_string_free(json); + return 1; + } + /* Structured mounts and integer bytes, not "V:H:ro" and "512M". */ + if (strstr(json, "\"ro\"") == NULL || strstr(json, "536870912") == NULL) { + fprintf(stderr, "profile_parse: unresolved JSON: %s\n", json); + sandlock_string_free(json); + return 1; + } + sandlock_string_free(json); + + err = 7; + json = sandlock_profile_parse("[program]\nbogus = 1\n", &err, &err_msg); + if (json != NULL || err != -1 || err_msg == NULL) { + fprintf(stderr, "profile_parse: unknown key not reported (err=%d)\n", err); + sandlock_string_free(json); + sandlock_string_free(err_msg); + return 1; + } + if (strstr(err_msg, "unknown field") == NULL) { + fprintf(stderr, "profile_parse: unexpected message: %s\n", err_msg); + sandlock_string_free(err_msg); + return 1; + } + sandlock_string_free(err_msg); + return 0; +} + static int force_getpid_to_777( void *ud, const sandlock_notif_data_t *notif, @@ -160,6 +202,9 @@ int main(void) { if (check_policy_fn_user_data_drop() != 0) { return 1; } + if (check_profile_parse() != 0) { + return 1; + } /* Build a sandbox that exposes just enough of the host for the * system python3 interpreter to start. Mirrors the read mounts used by diff --git a/crates/sandlock-ffi/tests/profile_parse.rs b/crates/sandlock-ffi/tests/profile_parse.rs new file mode 100644 index 00000000..c394afd7 --- /dev/null +++ b/crates/sandlock-ffi/tests/profile_parse.rs @@ -0,0 +1,361 @@ +//! Integration tests for the `sandlock_profile_parse` C ABI export. +//! +//! These drive the FFI symbol directly and assert on the JSON body, not just +//! on "not null": the whole point of the export is that the caller receives +//! resolved values (structured mounts, integer bytes, epoch seconds) rather +//! than the profile's string micro-grammars. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; +use std::ptr; + +use sandlock_ffi::{sandlock_profile_parse, sandlock_string_free}; + +/// Call the export and take ownership of whatever it produced. +/// +/// Returns `(json, err, err_msg)` with both strings copied out and the +/// originals released, so a leak in the test itself cannot mask one in the +/// implementation. +fn call(toml: &str) -> (Option, c_int, Option) { + let c = CString::new(toml).unwrap(); + let mut err: c_int = 7; // poison: the export must write this + let mut err_msg: *mut c_char = ptr::null_mut(); + let raw = unsafe { sandlock_profile_parse(c.as_ptr(), &mut err, &mut err_msg) }; + + let json = if raw.is_null() { + None + } else { + let s = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned(); + unsafe { sandlock_string_free(raw) }; + Some(s) + }; + let msg = if err_msg.is_null() { + None + } else { + let s = unsafe { CStr::from_ptr(err_msg) } + .to_str() + .unwrap() + .to_owned(); + unsafe { sandlock_string_free(err_msg) }; + Some(s) + }; + (json, err, msg) +} + +fn parse_ok(toml: &str) -> serde_json::Value { + let (json, err, msg) = call(toml); + assert_eq!(err, 0, "expected success, err_msg: {msg:?}"); + assert!(msg.is_none(), "success must not set err_msg: {msg:?}"); + serde_json::from_str(&json.expect("success must return a string")).unwrap() +} + +fn parse_err(toml: &str) -> String { + let (json, err, msg) = call(toml); + assert_eq!(err, -1, "expected failure, got json: {json:?}"); + assert!(json.is_none(), "failure must return null"); + msg.expect("failure must set err_msg") +} + +#[test] +fn valid_profile_returns_resolved_json() { + let v = parse_ok( + r#" + [program] + exec = "/usr/bin/redis-cli" + args = ["-h", "cache.internal"] + + [determinism] + time_start = "2026-01-01T00:00:00Z" + + [filesystem] + mount = ["/data:/srv/data:ro"] + + [network] + allow_bind = [8080, "9000-9001"] + allow = ["tcp://cache.internal:6379"] + + [limits] + memory = "512M" + "#, + ); + + // Mounts come back structured, not as `V:H:ro` spec strings. + assert_eq!( + v["filesystem"]["mount"], + serde_json::json!([{"virt": "/data", "host": "/srv/data", "ro": true}]) + ); + // Sizes come back as integer bytes. + assert_eq!(v["limits"]["memory"], serde_json::json!(536870912u64)); + // time_start comes back as epoch time. + assert_eq!( + v["determinism"]["time_start"], + serde_json::json!({"seconds": 1767225600i64, "nanoseconds": 0}) + ); + // Bind port ranges come back expanded. + assert_eq!( + v["network"]["allow_bind"], + serde_json::json!({"any": false, "ports": [8080, 9000, 9001]}) + ); + // Net rules come back structured, with a spec string for the builder ABI. + assert_eq!( + v["network"]["allow"][0], + serde_json::json!({ + "protocol": "tcp", + "target": {"kind": "host", "host": "cache.internal"}, + "ports": [6379], + "all_ports": false, + "spec": "tcp://cache.internal:6379", + }) + ); + // Program identity survives. + assert_eq!( + v["program"]["exec"], + serde_json::json!("/usr/bin/redis-cli") + ); + assert_eq!( + v["program"]["args"], + serde_json::json!(["-h", "cache.internal"]) + ); +} + +#[test] +fn empty_profile_is_a_success_not_a_failure() { + let v = parse_ok(""); + assert_eq!(v["filesystem"]["mount"], serde_json::json!([])); + assert!(v["limits"]["memory"].is_null()); +} + +#[test] +fn unknown_key_is_reported_with_a_message() { + let msg = parse_err("[program]\nexec = \"/bin/true\"\nbogus = 1"); + assert!(msg.contains("unknown field"), "got: {msg}"); + assert!(msg.contains("bogus"), "got: {msg}"); +} + +#[test] +fn grammar_errors_carry_the_core_message() { + assert!(parse_err("[limits]\nmemory = \"1.5G\"").contains("invalid byte size: 1.5G")); + assert!(parse_err("[filesystem]\nmount = [\"nocolon\"]").contains("VIRTUAL:HOST")); + assert!(parse_err("[determinism]\ntime_start = \"nope\"").contains("time_start")); + assert!(parse_err("[network]\nallow = [\"example.com:0\"]").contains("port 0 is not valid")); +} + +#[test] +fn invalid_toml_is_reported() { + assert!(parse_err("[program").contains("TOML parse error")); +} + +#[test] +fn null_toml_sets_err_but_no_message() { + let mut err: c_int = 7; + let mut err_msg: *mut c_char = ptr::null_mut(); + let raw = unsafe { sandlock_profile_parse(ptr::null(), &mut err, &mut err_msg) }; + assert!(raw.is_null()); + assert_eq!(err, -1); + // A null profile is a binding-layer bug, not a profile problem: there is + // no user-actionable message, and inventing one in this layer would be + // wrong. + assert!(err_msg.is_null(), "err_msg must stay null"); +} + +#[test] +fn invalid_utf8_is_rejected_rather_than_read_as_empty() { + // A lossy decode would report a truncated (or empty) profile as valid. + let bytes = b"[program]\nexec = \"/bin/\xff\"\0"; + let mut err: c_int = 7; + let mut err_msg: *mut c_char = ptr::null_mut(); + let raw = + unsafe { sandlock_profile_parse(bytes.as_ptr() as *const c_char, &mut err, &mut err_msg) }; + assert!(raw.is_null()); + assert_eq!(err, -1); + assert!(!err_msg.is_null(), "decode failure must set err_msg"); + let msg = unsafe { CStr::from_ptr(err_msg) } + .to_string_lossy() + .into_owned(); + unsafe { sandlock_string_free(err_msg) }; + assert!(msg.contains("utf-8"), "got: {msg}"); +} + +#[test] +fn err_msg_is_cleared_before_each_call() { + // A caller that reuses the variable must not be handed back a stale + // pointer from the previous call, or it will double-free. + let mut err: c_int = 0; + let mut err_msg: *mut c_char = ptr::null_mut(); + + let bad = CString::new("[program]\nbogus = 1").unwrap(); + let raw = unsafe { sandlock_profile_parse(bad.as_ptr(), &mut err, &mut err_msg) }; + assert!(raw.is_null()); + assert!(!err_msg.is_null()); + unsafe { sandlock_string_free(err_msg) }; + // Deliberately left dangling, as a careless caller would. + + let good = CString::new("[program]\nexec = \"/bin/true\"").unwrap(); + let raw = unsafe { sandlock_profile_parse(good.as_ptr(), &mut err, &mut err_msg) }; + assert!(!raw.is_null()); + assert_eq!(err, 0); + assert!( + err_msg.is_null(), + "success must reset err_msg, not leave the previous pointer" + ); + unsafe { sandlock_string_free(raw) }; +} + +#[test] +fn null_out_params_are_allowed() { + let good = CString::new("[program]\nexec = \"/bin/true\"").unwrap(); + let raw = unsafe { sandlock_profile_parse(good.as_ptr(), ptr::null_mut(), ptr::null_mut()) }; + assert!(!raw.is_null(), "a null return always means failure"); + unsafe { sandlock_string_free(raw) }; + + let bad = CString::new("[program]\nbogus = 1").unwrap(); + let raw = unsafe { sandlock_profile_parse(bad.as_ptr(), ptr::null_mut(), ptr::null_mut()) }; + assert!(raw.is_null()); +} + +#[test] +fn string_free_is_a_no_op_on_null() { + unsafe { sandlock_string_free(ptr::null_mut()) }; +} + +/// A profile can smuggle a NUL into the *diagnostic*, because TOML decodes +/// `\u0000` and the message quotes the decoded value back. A C string cannot +/// carry one, and the caller must still be told what went wrong: reporting +/// `err = -1` with a null `err_msg` gives a Python or Go user a bare exception +/// with no text at all, and every one of these reaches the message through a +/// different formatter (`{}` on a byte-size error, a syscall name list, and +/// toml's own parse error). +#[test] +fn a_nul_in_the_diagnostic_is_escaped_not_dropped() { + for (toml, needle) in [ + ( + r#"[limits]"#.to_string() + "\nmemory = \"1\\u0000G\"", + "1\\0G", + ), + ( + r#"[syscalls]"#.to_string() + "\nextra_deny = [\"re\\u0000ad\"]", + "re\\0ad", + ), + ( + r#"[limits]"#.to_string() + "\n\"bo\\u0000gus\" = 1", + "bo\\0gus", + ), + ] { + let msg = parse_err(&toml); + assert!( + msg.contains(needle), + "diagnosis lost for {toml:?}; expected {needle:?} in {msg:?}" + ); + } +} + +/// The NUL only bothers the *message* path. When the profile is valid, the +/// value keeps its NUL and rides out in the JSON, where `\u0000` is an +/// ordinary escape: the C string stays intact and the consumer sees the whole +/// value rather than a prefix. `%00` in an HTTP rule gets there without any +/// TOML escape at all, since the path is percent-decoded. +#[test] +fn a_nul_inside_a_value_does_not_truncate_the_json() { + let v = parse_ok("[http]\nallow = [\"GET example.com/a%00b\"]"); + assert_eq!(v["http"]["allow"][0]["path"], "/a\u{0}b"); + assert_eq!(v["http"]["allow"][0]["spec"], "GET example.com/a\u{0}b"); + // Truncation at the NUL would have dropped every later section. + assert!(v["limits"].is_object(), "document was cut short: {v}"); +} + +/// Twelve combinations of (profile: null / valid / invalid) x (err: null / +/// non-null) x (err_msg: null / non-null). A binding written against the +/// header is allowed to discard either out-parameter, and none of those calls +/// may fault or leave a poisoned `err_msg` behind. +#[test] +fn every_out_param_combination_is_safe() { + const POISON: *mut c_char = usize::MAX as *mut c_char; + let valid = CString::new("[limits]\nmemory = \"1G\"").unwrap(); + let invalid = CString::new("[limits]\nbogus = 1").unwrap(); + + for (label, toml, want_json) in [ + ("null", ptr::null(), false), + ("valid", valid.as_ptr(), true), + ("invalid", invalid.as_ptr(), false), + ] { + for pass_err in [true, false] { + for pass_err_msg in [true, false] { + let mut err: c_int = 7; + let mut err_msg: *mut c_char = POISON; + let err_p = if pass_err { &mut err } else { ptr::null_mut() }; + let err_msg_p = if pass_err_msg { + &mut err_msg + } else { + ptr::null_mut() + }; + let raw = unsafe { sandlock_profile_parse(toml, err_p, err_msg_p) }; + + assert_eq!( + !raw.is_null(), + want_json, + "{label} (err={pass_err}, err_msg={pass_err_msg})" + ); + if pass_err { + assert_eq!(err, if want_json { 0 } else { -1 }, "{label}"); + } + if pass_err_msg { + assert_ne!(err_msg, POISON, "{label}: err_msg was never written"); + // Only a real profile problem carries a diagnosis: a null + // profile is a binding bug and success has nothing to say. + assert_eq!( + !err_msg.is_null(), + label == "invalid", + "{label}: unexpected err_msg" + ); + } + + if !raw.is_null() { + unsafe { sandlock_string_free(raw) }; + } + if pass_err_msg && !err_msg.is_null() { + unsafe { sandlock_string_free(err_msg) }; + } + } + } + } +} + +/// Both allocations are the caller's to free, and both must actually be +/// freeable. Running the success and the failure path many times over exercises +/// the release path enough that a double free or a use-after-free trips the +/// allocator here rather than in a user's process. +#[test] +fn repeated_calls_hand_back_releasable_allocations() { + let good = CString::new( + "[filesystem]\nmount = [\"/v:/h:ro\"]\n[limits]\nmemory = \"512M\"\n\ + [network]\nallow_bind = [\"8000-8100\"]", + ) + .unwrap(); + let bad = CString::new("[limits]\nmemory = \"1.5G\"").unwrap(); + + for _ in 0..2_000 { + for profile in [&good, &bad] { + let mut err: c_int = 7; + let mut err_msg: *mut c_char = ptr::null_mut(); + let raw = unsafe { sandlock_profile_parse(profile.as_ptr(), &mut err, &mut err_msg) }; + if !raw.is_null() { + assert!(!unsafe { CStr::from_ptr(raw) }.to_bytes().is_empty()); + unsafe { sandlock_string_free(raw) }; + } + if !err_msg.is_null() { + assert!(!unsafe { CStr::from_ptr(err_msg) }.to_bytes().is_empty()); + unsafe { sandlock_string_free(err_msg) }; + } + } + } +} + +/// Length is not a grammar: a path far longer than any buffer a caller is +/// likely to have sized must survive the round trip intact rather than being +/// clipped somewhere along it. +#[test] +fn a_very_long_value_survives_intact() { + let path = format!("/{}", "a".repeat(200_000)); + let v = parse_ok(&format!("[filesystem]\nread = [\"{path}\"]")); + assert_eq!(v["filesystem"]["read"][0], path); +} diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 5c05f860..75c15505 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -36,7 +36,7 @@ sandbox = Sandbox( # [filesystem] fs_readable=(), fs_writable=(), fs_denied=(), - chroot=None, fs_mount={}, + chroot=None, fs_mount=(), on_exit=BranchAction.COMMIT, on_error=BranchAction.ABORT, # [network] @@ -88,7 +88,6 @@ gid = 0 clean_env = true no_coredump = true no_huge_pages = true -no_supervisor = false [filesystem] read = ["/usr", "/lib"] @@ -239,7 +238,7 @@ Knobs that pin sources of non-determinism in the child process. | Python | TOML | Type | Default | Description | | ----------------------- | --------------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------ | | `random_seed` | `random_seed` | `int \| None` | `None` | Seed for deterministic `getrandom()`. Identical seeds yield identical byte streams. | -| `time_start` | `time_start` | `float \| str \| None`| `None` | Frozen start time as a Unix timestamp or RFC 3339 / ISO 8601 string. Time advances at real speed from the given epoch. | +| `time_start` | `time_start` | `float \| None` | `None` | Frozen start time as Unix epoch seconds. The TOML key takes an RFC 3339 stamp with an explicit offset (`"2026-01-01T00:00:00Z"`), which the core parser resolves to epoch seconds at load time. Time advances at real speed from the given epoch. | | `deterministic_dirs` | `deterministic_dirs` | `bool` | `False` | Sort `readdir()` entries lexicographically so that `ls`, `glob`, and `os.listdir` return a stable order. | | `no_randomize_memory` | `no_randomize_memory` | `bool` | `False` | Disable ASLR via `personality(ADDR_NO_RANDOMIZE)`. | @@ -259,7 +258,15 @@ fields on `Sandbox`. | `clean_env` | `clean_env` | `bool` | `False` | When `True`, start with a minimal environment (`PATH`, `HOME`, `USER`, `TERM`, `LANG`) instead of inheriting the parent's. | | `no_coredump` | `no_coredump` | `bool` | `False` | Apply `prctl(PR_SET_DUMPABLE, 0)`. Disables core dumps and restricts `/proc/` access from other processes. Breaks `gdb`, `strace`, and `perf`. | | `no_huge_pages` | `no_huge_pages` | `bool` | `False` | Disable transparent huge pages via `prctl(PR_SET_THP_DISABLE)`. | -| `no_supervisor` | `no_supervisor` | `bool` | `False` | Skip the seccomp user-notification supervisor. The sandbox runs with Landlock + a kernel-only deny filter, without IP allowlisting, resource limits, COW, chroot mediation, `/proc` virtualization, or custom handlers. Required when nesting inside another sandlock (the kernel only allows one `SECCOMP_FILTER_FLAG_NEW_LISTENER` per task). | + +`no_supervisor` is not in this table because it is not a profile key and +not a Python field: it is the `sandlock run --no-supervisor` flag (and the +Rust `Sandbox::no_supervisor` field). It skips the seccomp +user-notification supervisor, so the sandbox runs with Landlock plus a +kernel-only deny filter, without IP allowlisting, resource limits, COW, +chroot mediation, `/proc` virtualization, or custom handlers. It is +required when nesting inside another sandlock, because the kernel allows +only one `SECCOMP_FILTER_FLAG_NEW_LISTENER` per task. ## `[filesystem]` @@ -272,9 +279,9 @@ filesystem isolation. | `fs_writable` | `write` | `Sequence[str]` | `()` | Paths the sandbox may read and write. | | `fs_denied` | `deny` | `Sequence[str]` | `()` | Paths explicitly denied (neither read nor write), even if implied by a broader rule. | | `chroot` | `chroot` | `str \| None` | `None` | Path to `chroot` into before applying other confinement. | -| `fs_mount` | `mount` | `Mapping[str, str]` | `{}` | Map virtual paths inside the chroot to host directories. Python form: `{"/work": "/host/sandbox/work"}`. TOML form: list of `"VIRTUAL:HOST"` strings. A trailing `:ro` (or the default `:rw`) selects a read-only mount: the CLI honours it in `--fs-mount` and in profiles, and `sandlock inspect --toml` writes `:ro` back out. The Python SDK rejects such entries with `PolicyError`, since its mapping cannot express a read-only mount; load the profile with the CLI (`sandlock run --profile-file `), or use the C ABI's `sandlock_sandbox_builder_fs_mount_ro`. | +| `fs_mount` | `mount` | `Sequence[Mount]` | `()` | Map virtual paths inside the chroot to host directories. Python form: `[Mount("/work", "/host/sandbox/work"), Mount("/ref", "/host/ref", ro=True)]`. TOML form: list of `"VIRTUAL:HOST"` strings, where a trailing `:ro` (or the default `:rw`) selects a read-only mount. Loading such a profile resolves each spec to a `Mount`, so `ro` survives into the SDK; `sandlock inspect --toml` writes `:ro` back out. Read-only is keyed by the virtual path, so mounts that share one share a single verdict: if any of them asks for `:ro`, writes through that virtual path are denied for all of them, and that is the `ro` a loaded profile reports. | | `on_exit` | `on_exit` | `BranchAction` | `BranchAction.COMMIT` | Branch action on normal sandbox exit. | -| `on_error` | `on_error` | `BranchAction` | `BranchAction.ABORT` | Branch action on sandbox error or exception. | +| `on_error` | `on_error` | `BranchAction` | see note | Branch action on sandbox error or exception. A `Sandbox()` built in Python defaults to `BranchAction.ABORT`; a profile that omits `on_error` resolves to `commit`, which is what the CLI has always applied. Set the key explicitly to avoid depending on either default. | Landlock rules are kernel-evaluated and TOCTOU-immune. @@ -345,11 +352,11 @@ prefix redundant; the GPU and CPU placement fields keep their names. | Python | TOML | Type | Default | Description | | ---------------- | ------------- | ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `max_memory` | `memory` | `str \| int \| None` | `None` | Memory limit. Accepts strings such as `"512M"`, `"1G"`, or an integer byte count. | +| `max_memory` | `memory` | `int \| None` | `None` | Memory limit in bytes. The Python field takes a byte count; the size suffix grammar (`"512M"`, `"1G"`) belongs to the TOML key and is resolved by the core parser at load time. | | `max_processes` | `processes` | `int` | `64` | Maximum number of **concurrent** processes in the sandbox (peak, not lifetime; threads do not count). Also enables fork interception used by checkpoint freeze. | | `max_open_files` | `open_files` | `int \| None` | `None` | Maximum number of open file descriptors. Enforced via `RLIMIT_NOFILE` (kernel, survives `exec`), set in the child right before it execs. Both the soft and the hard limit are lowered, and descendants inherit the cap. Clamped to **both** limits sandlock itself inherited, so it is an upper bound, never a grant: a request above the inherited soft limit gives the guest the inherited limit, not more; raise the limit on sandlock itself (`prlimit`, systemd `LimitNOFILE=`) if a guest needs a bigger budget. Lowering the hard limit makes the cap one-way only for an *unprivileged* sandlock; a sandbox launched by root (or with `CAP_SYS_RESOURCE`) can raise it back, since sandlock does not drop capabilities; treat it as a resource budget, not as confinement. The limit must also cover process startup (stdio, the dynamic loader's per-library descriptors, and under `chroot` the injected exec fd); too low a value fails the exec and exits 127, reporting `EMFILE` on a plain exec but `EIO` under `chroot`. Past startup the errno likewise depends on who services the `open`: `EMFILE` from the kernel, `EACCES` when the supervisor mediates it (`chroot`, COW, procfs virtualisation). Measured floor for a trivial command: about 4, plain exec or `chroot`; programs linking more libraries need more. | | `max_cpu` | `cpu` | `int \| None` | `None` | CPU throttle as a percentage of one core (1 to 100). Applied to the entire process group via `SIGSTOP`/`SIGCONT` cycling. | -| `max_disk` | `disk` | `str \| None` | `None` | COW storage quota (e.g. `"1G"`). Returned as `ENOSPC` when the upper layer exceeds it. | +| `max_disk` | `disk` | `int \| None` | `None` | COW storage quota in bytes; the TOML key also accepts a suffixed size such as `"1G"`. Returned as `ENOSPC` when the upper layer exceeds it. | | `gpu_devices` | `gpu_devices` | `Sequence[int] \| None` | `None` | GPU device indices to expose. `None` denies GPU access entirely; `[]` exposes every GPU; a list exposes only those devices. Adds Landlock rules for `/dev/nvidia*` and `/dev/dri/*` and sets `CUDA_VISIBLE_DEVICES` / `ROCR_VISIBLE_DEVICES`. | | `cpu_cores` | `cpu_cores` | `Sequence[int] \| None` | `None` | CPU cores to pin the sandbox to via `sched_setaffinity` in the child. | | `num_cpus` | `num_cpus` | `int \| None` | `None` | Visible CPU count in `/proc/cpuinfo` (renumbered `0..N-1`). Also virtualizes `/proc/meminfo` when `max_memory` is set. | diff --git a/python/README.md b/python/README.md index 3e9d6988..1cc3e4d5 100644 --- a/python/README.md +++ b/python/README.md @@ -103,7 +103,7 @@ with Sandbox(fs_readable=["/usr", "/lib"]) as sb: | `fs_denied` | `list[str]` | `[]` | Paths explicitly denied | | `workdir` | `str \| None` | `None` | Working directory; enables COW protection | | `chroot` | `str \| None` | `None` | Path to chroot into before confinement | -| `fs_mount` | `dict[str, str]` | `{}` | Map virtual paths to host directories inside chroot | +| `fs_mount` | `list[Mount]` | `[]` | Host directories exposed at virtual paths inside chroot; `Mount(virt, host, ro=False)` | | `cwd` | `str \| None` | `None` | Child working directory | #### Network @@ -154,24 +154,29 @@ but without kernel bind mounts or root privileges. Each sandbox gets its own persistent workspace while sharing a read-only rootfs. ```python +from sandlock import Mount, Sandbox + sandbox = Sandbox( chroot="/opt/rootfs", - fs_mount={"/work": "/tmp/sandbox-1/work"}, + fs_mount=[Mount("/work", "/tmp/sandbox-1/work")], fs_readable=["/usr", "/bin", "/lib", "/etc"], cwd="/work", ) result = sandbox.run(["python3", "task.py"]) ``` +Pass `ro=True` for a read-only mount: `Mount("/data", "/srv/data", ro=True)` +exposes the host directory but refuses writes through it. + Combine with `workdir` + `max_disk` for quota-enforced writes: ```python sandbox = Sandbox( chroot="/opt/rootfs", - fs_mount={"/work": "/tmp/sandbox-1/work"}, + fs_mount=[Mount("/work", "/tmp/sandbox-1/work")], workdir="/tmp/sandbox-1/work", fs_storage="/tmp/sandbox-1/cow", - max_disk="100M", + max_disk=100 * 1024 ** 2, on_exit="commit", fs_readable=["/usr", "/bin", "/lib", "/etc"], ) @@ -181,7 +186,7 @@ sandbox = Sandbox( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `max_memory` | `str \| int \| None` | `None` | Memory limit, e.g. `"512M"` or int bytes | +| `max_memory` | `int \| None` | `None` | Memory limit in bytes, e.g. `512 * 1024 ** 2` | | `max_processes` | `int` | `64` | Peak concurrent process limit | | `max_open_files` | `int \| None` | `None` | Max file descriptors (RLIMIT_NOFILE) | | `max_cpu` | `int \| None` | `None` | CPU throttle as percentage of one core (1-100) | @@ -202,7 +207,7 @@ Sandlock always applies its default syscall blocklist. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `random_seed` | `int \| None` | `None` | Seed for deterministic getrandom() | -| `time_start` | `datetime \| float \| str \| None` | `None` | Start timestamp for time virtualization | +| `time_start` | `float \| None` | `None` | Start timestamp for time virtualization, as Unix epoch seconds | | `no_randomize_memory` | `bool` | `False` | Disable ASLR | | `no_huge_pages` | `bool` | `False` | Disable Transparent Huge Pages | | `deterministic_dirs` | `bool` | `False` | Sort directory entries lexicographically | @@ -233,7 +238,7 @@ Sandlock always applies its default syscall blocklist. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `fs_storage` | `str \| None` | `None` | Storage directory for the seccomp COW upper layer / deltas | -| `max_disk` | `str \| None` | `None` | Disk quota for COW storage (e.g. `"1G"`) | +| `max_disk` | `int \| None` | `None` | Disk quota for COW storage in bytes (e.g. `1024 ** 3`) | | `on_exit` | `BranchAction` | `COMMIT` | `COMMIT`, `ABORT`, or `KEEP` | | `on_error` | `BranchAction` | `ABORT` | `COMMIT`, `ABORT`, or `KEEP` | @@ -623,6 +628,13 @@ sandbox = load_profile("web-scraper") names = list_profiles() ``` +Profile text is parsed by the core parser, the same one the CLI runs, and +comes back with every micro-grammar resolved: `mount = ["/data:/srv:ro"]` +becomes `Mount("/data", "/srv", ro=True)`, `memory = "512M"` becomes +`536870912`, and `time_start = "2026-01-01T00:00:00Z"` becomes epoch +seconds. A profile therefore means the same thing here as it does to +`sandlock run --profile-file`, including the message it fails with. + ### Exceptions ``` @@ -712,7 +724,7 @@ permissions explicitly: | `fs_writable` | `["/tmp/agent"]` | Paths the tool can write to | | `net_allow` | `["api.example.com:443", "udp://1.1.1.1:53"]` | Outbound endpoints. Bare `host:port` is TCP; `udp://...` / `icmp://...` schemes opt UDP / ICMP echo in. | | `env` | `{"KEY": "val"}` | Environment variables to pass | -| `max_memory` | `"256M"` | Memory limit | +| `max_memory` | `256 * 1024 ** 2` | Memory limit in bytes | Any `Sandbox` field name is accepted as a capability key. diff --git a/python/examples/mcp_agent.py b/python/examples/mcp_agent.py index 8784b426..519a2d99 100644 --- a/python/examples/mcp_agent.py +++ b/python/examples/mcp_agent.py @@ -86,7 +86,7 @@ async def run_agent(user_prompt: str, workspace: str): mcp.add_tool( "run_python", run_python, description="Run Python code and return stdout. No filesystem or network access.", - capabilities={"max_memory": "128M"}, + capabilities={"max_memory": 128 * 1024 ** 2}, input_schema={ "type": "object", "properties": {"code": {"type": "string", "description": "Python code to execute"}}, diff --git a/python/pyproject.toml b/python/pyproject.toml index a665fec4..108cf11d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -8,7 +8,6 @@ dynamic = ["version"] readme = "README.md" description = "Lightweight process sandbox using Landlock, seccomp, and seccomp user notification" requires-python = ">=3.8" -dependencies = ["tomli>=1.0; python_version < '3.11'"] license = {text = "Apache-2.0"} authors = [ { name = "Cong Wang", email = "cwang@multikernel.io" }, diff --git a/python/src/sandlock/__init__.py b/python/src/sandlock/__init__.py index 80169fe3..5149fb4d 100644 --- a/python/src/sandlock/__init__.py +++ b/python/src/sandlock/__init__.py @@ -15,7 +15,7 @@ from .inputs import inputs from .handler import Handler, NotifAction, HandlerCtx, ExceptionPolicy from .sandbox import ( - Sandbox, BranchAction, parse_ports, Change, DryRunResult, StdioMode, Process, + Sandbox, BranchAction, Mount, parse_ports, Change, DryRunResult, StdioMode, Process, ) from ._profile import load_profile, list_profiles from .exceptions import ( @@ -50,6 +50,7 @@ "GatherPipeline", "inputs", "BranchAction", + "Mount", "parse_ports", "Change", "DryRunResult", diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index da6b919a..c1811096 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -1,116 +1,117 @@ # SPDX-License-Identifier: Apache-2.0 """TOML profile loading for Sandlock. -Profiles use the sectioned policy schema (the same one parsed by the -Rust CLI). Each section maps to a subset of ``Sandbox`` fields: - - [config] → http_ca, http_key, fs_storage, workdir - [determinism] → random_seed, time_start, deterministic_dirs, - no_randomize_memory - [program] → env, cwd, uid, clean_env, no_coredump, no_huge_pages - (``exec`` and ``args`` are runtime program identity - and are silently ignored — pass them to - ``sandbox.run(cmd)`` instead) - [filesystem] → fs_readable (read), fs_writable (write), - fs_denied (deny), chroot, - fs_mount (mount), on_exit, on_error - (mount entries are ``"VIRTUAL:HOST"`` only; a trailing - ``:ro``/``:rw`` is part of the CLI's grammar, not this - one, and is rejected; ``:ro`` because this mapping - cannot express a read-only mount at all) - [network] → net_allow_bind (allow_bind), net_deny_bind (deny_bind), net_allow (allow), net_deny (deny), port_remap - [http] → http_ports (ports), http_allow (allow), - http_deny (deny) - [syscalls] → extra_allow_syscalls (extra_allow), - extra_deny_syscalls (extra_deny) - [limits] → max_memory (memory), max_processes (processes), - max_open_files (open_files), max_cpu (cpu), - max_disk (disk), gpu_devices, cpu_cores, num_cpus +The profile text is handed to the core parser (``sandlock_profile_parse``), +which returns the profile in canonical form: every string micro-grammar is +already resolved (mounts are ``{virt, host, ro}`` objects, byte sizes are +integer bytes, ``time_start`` is epoch time, port specs are expanded integer +lists, net and HTTP rules are structured records with a rendered spec). This +module only maps those fields onto :class:`~sandlock.Sandbox`, so a profile +means exactly what it means to the CLI, down to the error message. + +Section to field mapping:: + + [config] -> http_ca, http_key, http_inject_ca, http_ca_out, + fs_storage, workdir + [determinism] -> random_seed, time_start, deterministic_dirs, + no_randomize_memory + [program] -> env, cwd, uid, gid, clean_env, no_coredump, + no_huge_pages (``exec`` and ``args`` are runtime program + identity and are ignored here; pass them to + ``sandbox.run(cmd)`` instead) + [filesystem] -> fs_readable (read), fs_writable (write), fs_denied + (deny), chroot, fs_mount (mount), on_exit, on_error + [network] -> net_allow_bind (allow_bind), net_deny_bind (deny_bind), + net_allow (allow), net_deny (deny), port_remap + [http] -> http_ports (ports), http_allow (allow), http_deny (deny) + [syscalls] -> extra_allow_syscalls (extra_allow), + extra_deny_syscalls (extra_deny) + [limits] -> max_memory (memory), max_disk (disk), max_processes + (processes), max_open_files (open_files), max_cpu (cpu), + gpu_devices, cpu_cores, num_cpus """ from __future__ import annotations -import sys - -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - +from collections.abc import Iterable from pathlib import Path from typing import Any +from ._sdk import profile_parse from .exceptions import PolicyError -from .sandbox import BranchAction, Sandbox +from .sandbox import BranchAction, Mount, Sandbox _PROFILES_DIR = Path("~/.config/sandlock/profiles").expanduser() -# Per-section schema. Each entry maps a TOML field name to -# (sandbox-attribute name, expected python type). A sandbox-attribute -# name of ``None`` means the field is recognised but silently ignored -# (used for [program].exec and [program].args, which are runtime -# program identity, not Sandbox config). -_SECTIONS: dict[str, dict[str, tuple[str | None, type]]] = { +# Canonical-form key -> Sandbox attribute, per section. ``None`` marks a key +# that is deliberately dropped (program identity, which is not policy). +# +# The key sets are exhaustive on purpose: the core emits every canonical field +# unconditionally, so a key that appears, disappears or is renamed on the other +# side is a schema break. Checking the whole set turns that into a load-time +# error here instead of a field that silently stops being applied. +_SECTIONS: dict[str, dict[str, str | None]] = { "config": { - "http_ca": ("http_ca", str), - "http_key": ("http_key", str), - "http_inject_ca": ("http_inject_ca", list), - "http_ca_out": ("http_ca_out", str), - "fs_storage": ("fs_storage", str), - "workdir": ("workdir", str), + "http_ca": "http_ca", + "http_key": "http_key", + "http_inject_ca": "http_inject_ca", + "http_ca_out": "http_ca_out", + "fs_storage": "fs_storage", + "workdir": "workdir", }, "determinism": { - "random_seed": ("random_seed", int), - "time_start": ("time_start", str), - "deterministic_dirs": ("deterministic_dirs", bool), - "no_randomize_memory": ("no_randomize_memory", bool), + "random_seed": "random_seed", + "time_start": "time_start", + "deterministic_dirs": "deterministic_dirs", + "no_randomize_memory": "no_randomize_memory", }, "program": { - "exec": (None, str), - "args": (None, list), - "env": ("env", dict), - "cwd": ("cwd", str), - "uid": ("uid", int), - "clean_env": ("clean_env", bool), - "no_coredump": ("no_coredump", bool), - "no_huge_pages": ("no_huge_pages", bool), + "exec": None, + "args": None, + "env": "env", + "cwd": "cwd", + "uid": "uid", + "gid": "gid", + "clean_env": "clean_env", + "no_coredump": "no_coredump", + "no_huge_pages": "no_huge_pages", }, "filesystem": { - "read": ("fs_readable", list), - "write": ("fs_writable", list), - "deny": ("fs_denied", list), - "chroot": ("chroot", str), - "mount": ("fs_mount", list), - "on_exit": ("on_exit", str), - "on_error": ("on_error", str), + "read": "fs_readable", + "write": "fs_writable", + "deny": "fs_denied", + "chroot": "chroot", + "mount": "fs_mount", + "on_exit": "on_exit", + "on_error": "on_error", }, "network": { - "allow_bind": ("net_allow_bind", list), - "deny_bind": ("net_deny_bind", list), - "allow": ("net_allow", list), - "deny": ("net_deny", list), - "port_remap": ("port_remap", bool), + "allow_bind": "net_allow_bind", + "deny_bind": "net_deny_bind", + "allow": "net_allow", + "deny": "net_deny", + "port_remap": "port_remap", }, "http": { - "ports": ("http_ports", list), - "allow": ("http_allow", list), - "deny": ("http_deny", list), + "ports": "http_ports", + "allow": "http_allow", + "deny": "http_deny", }, "syscalls": { - "extra_allow": ("extra_allow_syscalls", list), - "extra_deny": ("extra_deny_syscalls", list), + "extra_allow": "extra_allow_syscalls", + "extra_deny": "extra_deny_syscalls", }, "limits": { - "memory": ("max_memory", str), - "processes": ("max_processes", int), - "open_files": ("max_open_files", int), - "cpu": ("max_cpu", int), - "disk": ("max_disk", str), - "gpu_devices": ("gpu_devices", list), - "cpu_cores": ("cpu_cores", list), - "num_cpus": ("num_cpus", int), + "memory": "max_memory", + "disk": "max_disk", + "processes": "max_processes", + "open_files": "max_open_files", + "cpu": "max_cpu", + "gpu_devices": "gpu_devices", + "cpu_cores": "cpu_cores", + "num_cpus": "num_cpus", }, } @@ -133,7 +134,7 @@ def load_profile(name: str) -> Sandbox: """Load a named profile and return a Sandbox. Raises: - PolicyError: If the profile doesn't exist or has invalid fields. + PolicyError: If the profile doesn't exist or the core parser rejects it. """ path = _PROFILES_DIR / f"{name}.toml" if not path.is_file(): @@ -145,142 +146,119 @@ def load_profile_path(path: Path) -> Sandbox: """Load a profile from a file path and return a Sandbox. Raises: - PolicyError: If the file can't be parsed or has invalid fields. + PolicyError: If the file can't be read or the core parser rejects it. """ try: - with open(path, "rb") as f: - data = tomllib.load(f) - except tomllib.TOMLDecodeError as e: - raise PolicyError(f"invalid TOML in {path}: {e}") from e - - return policy_from_dict(data, source=str(path)) + text = Path(path).read_text(encoding="utf-8") + except OSError as e: + raise PolicyError(f"{path}: {e}") from e + except UnicodeDecodeError as e: + raise PolicyError(f"{path}: profile is not valid UTF-8: {e}") from e + try: + return policy_from_toml(text) + except PolicyError as e: + # The diagnosis stays the core parser's; only the file it came from + # is added, since the caller passed a path and not the text. + raise PolicyError(f"{path}: {e}") from e -def policy_from_dict(data: dict, source: str = "") -> Sandbox: - """Construct a Sandbox from a parsed sectioned-TOML dict. - Each top-level key must be a known schema section (``config``, - ``determinism``, ``program``, ``filesystem``, ``network``, ``http``, - ``syscalls``, ``limits``). Within each section, only the documented - fields are accepted. +def policy_from_toml(text: str) -> Sandbox: + """Construct a Sandbox from profile TOML text. Raises: - PolicyError: If unknown section / field names appear or types mismatch. + PolicyError: With the core parser's message, verbatim. """ + return _from_canonical(profile_parse(text)) + + +def _from_canonical(canonical: dict) -> Sandbox: + """Map the canonical profile form onto a Sandbox.""" + _check_keys(canonical, _SECTIONS, "profile") + + kwargs: dict[str, Any] = {} + for section, fields in _SECTIONS.items(): + data = canonical[section] + _check_keys(data, fields, f"[{section}]") + for key, attr in fields.items(): + if attr is None: + continue + value = _convert(attr, data[key]) + # A null leaf means "not set in the profile"; leaving it out keeps + # the Sandbox default, which is not always None (max_processes). + if value is not None: + kwargs[attr] = value + + return Sandbox(**kwargs) + + +def _check_keys(data: Any, expected: Iterable[str], where: str) -> None: + """Fail loudly when the canonical form is not the shape expected here.""" if not isinstance(data, dict): raise PolicyError( - f"{source}: expected a TOML table at the top level, " + f"{where}: expected an object in the canonical profile, " f"got {type(data).__name__}" ) + missing = sorted(set(expected) - set(data)) + unknown = sorted(set(data) - set(expected)) + if missing or unknown: + detail = [] + if missing: + detail.append(f"missing {', '.join(missing)}") + if unknown: + detail.append(f"unknown {', '.join(unknown)}") + raise PolicyError( + f"{where}: canonical profile does not match this SDK " + f"({'; '.join(detail)}); core and SDK are out of sync" + ) + + +def _convert(attr: str, value: Any) -> Any: + """Turn one canonical leaf into its Sandbox representation.""" + if value is None: + return None + if attr == "fs_mount": + return [_mount(entry) for entry in value] + if attr in ("on_exit", "on_error"): + return BranchAction(value) + if attr == "time_start": + return _timestamp(value) + if attr in ("net_allow_bind", "net_deny_bind"): + return _bind_ports(value) + if attr in ("net_allow", "net_deny", "http_allow", "http_deny"): + # Rules are structured, but every builder entry point takes a spec + # string, so the core-rendered `spec` is what gets forwarded. A + # scheme-less profile entry has already been split into one rule per + # protocol at this point. + return [_rule_spec(rule, attr) for rule in value] + return value - unknown_sections = set(data.keys()) - set(_SECTIONS.keys()) - if unknown_sections: + +def _rule_spec(rule: Any, attr: str) -> str: + if not isinstance(rule, dict) or "spec" not in rule: raise PolicyError( - f"{source}: unknown section(s): " - f"{', '.join(sorted(unknown_sections))}" + f"{attr}: canonical rule carries no 'spec' string; core and SDK " + f"are out of sync (got {rule!r})" ) + return rule["spec"] - kwargs: dict[str, Any] = {} - for section_name, section_data in data.items(): - if not isinstance(section_data, dict): - raise PolicyError( - f"{source}: [{section_name}] must be a TOML table, " - f"got {type(section_data).__name__}" - ) - schema = _SECTIONS[section_name] - unknown_fields = set(section_data.keys()) - set(schema.keys()) - if unknown_fields: - raise PolicyError( - f"{source}: unknown field(s) in [{section_name}]: " - f"{', '.join(sorted(unknown_fields))}" - ) - for toml_key, value in section_data.items(): - sandbox_key, expected_type = schema[toml_key] - if sandbox_key is None: - # [program].exec / [program].args — silently ignored. - continue - if not isinstance(value, expected_type): - raise PolicyError( - f"{source}: [{section_name}].{toml_key} expected " - f"{expected_type.__name__}, got {type(value).__name__}" - ) - value = _coerce(section_name, toml_key, sandbox_key, value, source) - kwargs[sandbox_key] = value +def _mount(entry: Any) -> Mount: + _check_keys(entry, ("virt", "host", "ro"), "mount entry") + return Mount(virt=entry["virt"], host=entry["host"], ro=entry["ro"]) - return Sandbox(**kwargs) +def _bind_ports(value: Any) -> list: + _check_keys(value, ("any", "ports"), "bind ports") + return ["*"] if value["any"] else list(value["ports"]) -def _coerce( - section: str, toml_key: str, sandbox_key: str, value: Any, source: str -) -> Any: - """Per-field value coercion (enums, mount-spec parsing, port lists).""" - if sandbox_key in ("on_exit", "on_error"): - try: - return BranchAction(value) - except ValueError: - raise PolicyError( - f"{source}: [{section}].{toml_key} must be " - f"'commit', 'abort', or 'keep', got {value!r}" - ) - if sandbox_key == "fs_mount": - # TOML form is ``["VIRTUAL:HOST", ...]``; - # Sandbox.fs_mount is dict[str, str]. - mount: dict[str, str] = {} - for spec in value: - if not isinstance(spec, str): - raise PolicyError( - f"{source}: [{section}].{toml_key} entries must be " - f"'VIRTUAL:HOST' strings, got {type(spec).__name__}" - ) - if ":" not in spec: - raise PolicyError( - f"{source}: [{section}].{toml_key} entry {spec!r} " - "must be 'VIRTUAL:HOST'" - ) - # The Rust CLI strips a trailing ':ro'/':rw' before splitting on - # the first colon; this parser does not, so the suffix would end - # up baked into the host path. Both forms are refused, but for - # different reasons, so the message must say which. - if spec.endswith(":ro"): - # Dropping ':ro' would be worse than refusing: Sandbox.fs_mount - # is a plain virtual -> host mapping with no read-only channel, - # so the target would be mounted read-write instead. - raise PolicyError( - f"{source}: [{section}].{toml_key} entry {spec!r} uses a " - "':ro' suffix, which the Python SDK cannot honour: its " - "mount mapping cannot express a read-only mount, and " - "dropping the suffix would silently mount the host path " - "read-write. Run this profile with the sandlock CLI " - "('sandlock run --profile-file ' or " - "'sandlock run -p '), which honours ':ro', or drop " - "the suffix and accept a read-write mount" - ) - if spec.endswith(":rw"): - # ':rw' is the CLI's explicit default and means exactly what - # this mapping already does, but the suffix is not part of the - # grammar this parser accepts, so it must not be swallowed. - raise PolicyError( - f"{source}: [{section}].{toml_key} entry {spec!r} uses a " - "':rw' suffix, which is the sandlock CLI's default and is " - "not part of this parser's 'VIRTUAL:HOST' grammar; remove " - "it: the mount is read-write already. To keep the suffix, " - "run the profile with the sandlock CLI " - "('sandlock run --profile-file ' or " - "'sandlock run -p ')" - ) - virt, host = spec.split(":", 1) - if not virt or not host: - raise PolicyError( - f"{source}: [{section}].{toml_key} entry {spec!r} " - "requires both VIRTUAL and HOST to be non-empty" - ) - mount[virt] = host - return mount - if sandbox_key == "net_allow_bind": - # Coerce TOML integers to strings for port specs (existing behaviour). - return [str(v) if isinstance(v, int) else v for v in value] - return value + +def _timestamp(value: Any) -> float | int: + _check_keys(value, ("seconds", "nanoseconds"), "time_start") + seconds, nanos = value["seconds"], value["nanoseconds"] + if nanos == 0: + return seconds + return seconds + nanos / 1_000_000_000 def merge_cli_overrides(policy: Sandbox, overrides: dict) -> Sandbox: diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 00736c27..b1130537 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -4,6 +4,7 @@ import ctypes import ctypes.util +import json import os import signal import sys @@ -80,6 +81,7 @@ def _builder_fn(name, *extra_args): _b_cwd = _builder_fn("sandlock_sandbox_builder_cwd", ctypes.c_char_p) _b_chroot = _builder_fn("sandlock_sandbox_builder_chroot", ctypes.c_char_p) _b_fs_mount = _builder_fn("sandlock_sandbox_builder_fs_mount", ctypes.c_char_p, ctypes.c_char_p) +_b_fs_mount_ro = _builder_fn("sandlock_sandbox_builder_fs_mount_ro", ctypes.c_char_p, ctypes.c_char_p) _b_on_exit = _builder_fn("sandlock_sandbox_builder_on_exit", ctypes.c_uint8) _b_on_error = _builder_fn("sandlock_sandbox_builder_on_error", ctypes.c_uint8) _b_max_memory = _builder_fn("sandlock_sandbox_builder_max_memory", ctypes.c_uint64) @@ -283,6 +285,54 @@ def confine(policy: "PolicyDataclass") -> None: _lib.sandlock_string_free.restype = None _lib.sandlock_string_free.argtypes = [ctypes.c_char_p] +# Profile parsing. The return type is c_void_p rather than c_char_p on +# purpose: ctypes converts a c_char_p result to `bytes` and drops the +# pointer, leaving nothing to hand back to sandlock_string_free. +_lib.sandlock_profile_parse.restype = ctypes.c_void_p +_lib.sandlock_profile_parse.argtypes = [ + ctypes.c_char_p, + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_char_p), +] + + +def profile_parse(toml_text: str) -> dict: + """Parse a TOML profile with the core parser, returning its canonical form. + + Every micro-grammar in the profile (mount specs, byte sizes, timestamps, + port specs, net/HTTP rules, branch actions) is resolved by the same code + path the CLI runs, so a profile either loads identically in both or fails + in both with the same message. + + Raises: + PolicyError: With the core parser's own message. + """ + from .exceptions import PolicyError + + encoded = toml_text.encode("utf-8") + if b"\0" in encoded: + # The C ABI takes a NUL-terminated string, so a NUL inside the profile + # would truncate it and parse a prefix as if it were the whole file. + raise PolicyError("profile contains a NUL byte") + + err = ctypes.c_int(0) + err_msg = ctypes.c_char_p() + ptr = _lib.sandlock_profile_parse(encoded, ctypes.byref(err), ctypes.byref(err_msg)) + if not ptr or err.value != 0: + # err_msg.value copies the bytes; the allocation itself still has to + # be released. When the FFI leaves it null (an internal binding bug, + # not a profile problem) there is no diagnosis to report, so raise + # without one rather than inventing a message. + msg = err_msg.value.decode("utf-8", "replace") if err_msg.value else None + if err_msg.value: + _lib.sandlock_string_free(err_msg) + raise PolicyError(msg) if msg else PolicyError() + try: + return json.loads(ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")) + finally: + _lib.sandlock_string_free(ctypes.cast(ptr, ctypes.c_char_p)) + + # Run _lib.sandlock_run.restype = _c_result_p _lib.sandlock_run.argtypes = [_c_policy_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p), ctypes.c_uint] @@ -740,6 +790,46 @@ def _encode(s: str) -> bytes: raise ValueError(f"NUL byte in string argument: {result!r}") return result + +def _bytes_limit(value, field: str) -> int: + """Validate a byte-count policy field for the ``uint64`` C ABI setter.""" + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError( + f"{field} must be an integer number of bytes, got {value!r}" + ) + if not 0 <= value <= 0xFFFF_FFFF_FFFF_FFFF: + raise ValueError(f"{field} out of range for a 64-bit byte count: {value}") + return value + + +def _epoch_seconds(value) -> int: + """Validate ``time_start`` for the ``uint64`` epoch-seconds C ABI setter. + + The setter carries whole, non-negative seconds. The profile grammar is + wider than that (it accepts pre-1970 stamps and fractional seconds), so + the values it cannot carry are refused here: passing them on would make + the same profile mean one thing through the CLI and another through this + SDK, and a negative value would additionally wrap to a date in the far + future instead of failing. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + f"time_start must be Unix epoch seconds, got {value!r}" + ) + if value < 0: + raise ValueError( + "time_start before the Unix epoch is not supported by the " + f"sandlock_sandbox_builder_time_start ABI: {value}" + ) + if value != int(value): + raise ValueError( + "time_start carries sub-second precision, which the " + "sandlock_sandbox_builder_time_start ABI cannot represent: " + f"{value}" + ) + return int(value) + + def _make_argv(cmd: Sequence[str]): """Create a (c_char_p array, argc) pair from a list of strings.""" argc = len(cmd) @@ -1027,7 +1117,8 @@ def __del__(self): "net_allow", "net_deny", "net_allow_bind", "net_deny_bind", "port_remap", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", - "uid", + "http_inject_ca", "http_ca_out", + "uid", "gid", "random_seed", "time_start", "clean_env", "env", "extra_deny_syscalls", "extra_allow_syscalls", "max_open_files", "no_randomize_memory", "no_huge_pages", "no_coredump", "deterministic_dirs", @@ -1042,8 +1133,6 @@ def __del__(self): @staticmethod def _build_from_policy(policy: PolicyDataclass): """Build a native builder from a Python Sandbox dataclass. Returns builder pointer.""" - from .sandbox import parse_memory_size - b = _lib.sandlock_sandbox_builder_new() for p in (policy.fs_readable or []): @@ -1068,8 +1157,9 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_cwd(b, _encode(str(policy.cwd))) if policy.chroot: b = _b_chroot(b, _encode(str(policy.chroot))) - for vp, hp in (policy.fs_mount or {}).items(): - b = _b_fs_mount(b, _encode(str(vp)), _encode(str(hp))) + for mount in (policy.fs_mount or []): + setter = _b_fs_mount_ro if mount.ro else _b_fs_mount + b = setter(b, _encode(str(mount.virt)), _encode(str(mount.host))) # COW branch actions (0=Commit, 1=Abort, 2=Keep) _action_map = {"commit": 0, "abort": 1, "keep": 2} @@ -1079,18 +1169,10 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_on_error(b, _action_map.get(on_error_val, 1)) if policy.max_memory is not None: - if isinstance(policy.max_memory, str): - mem_bytes = parse_memory_size(policy.max_memory) - else: - mem_bytes = int(policy.max_memory) - b = _b_max_memory(b, mem_bytes) + b = _b_max_memory(b, _bytes_limit(policy.max_memory, "max_memory")) if policy.max_disk is not None: - if isinstance(policy.max_disk, str): - disk_bytes = parse_memory_size(policy.max_disk) - else: - disk_bytes = int(policy.max_disk) - b = _b_max_disk(b, disk_bytes) + b = _b_max_disk(b, _bytes_limit(policy.max_disk, "max_disk")) if policy.max_processes != 64: b = _b_max_processes(b, policy.max_processes) @@ -1142,8 +1224,7 @@ def _build_from_policy(policy: PolicyDataclass): if policy.random_seed is not None: b = _b_random_seed(b, policy.random_seed) if policy.time_start is not None: - epoch_secs = int(policy.time_start.timestamp()) if hasattr(policy.time_start, 'timestamp') else int(policy.time_start) - b = _b_time_start(b, epoch_secs) + b = _b_time_start(b, _epoch_seconds(policy.time_start)) if policy.clean_env: b = _b_clean_env(b, True) for k, v in (policy.env or {}).items(): diff --git a/python/src/sandlock/mcp/_policy.py b/python/src/sandlock/mcp/_policy.py index 73a27927..a722f541 100644 --- a/python/src/sandlock/mcp/_policy.py +++ b/python/src/sandlock/mcp/_policy.py @@ -83,7 +83,7 @@ def policy_for_tool( - ``fs_writable: ["/tmp/workspace"]`` - ``net_allow: ["api.example.com:443"]`` - ``env: {"KEY": "value"}`` - - ``max_memory: "256M"`` + - ``max_memory: 268435456`` (bytes) Returns: A frozen :class:`Sandbox` instance. diff --git a/python/src/sandlock/mcp/server.py b/python/src/sandlock/mcp/server.py index 442c9c2d..4ef5c0f4 100644 --- a/python/src/sandlock/mcp/server.py +++ b/python/src/sandlock/mcp/server.py @@ -81,7 +81,7 @@ "Execute Python code and return stdout. " "No filesystem or network access." ), - "capabilities_extra": lambda ws: {"max_memory": "256M"}, + "capabilities_extra": lambda ws: {"max_memory": 256 * 1024 ** 2}, "input_schema": { "type": "object", "properties": { diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 37a69ac0..da141091 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -8,6 +8,7 @@ from __future__ import annotations +import collections.abc import inspect import itertools import os @@ -28,40 +29,6 @@ from ._sdk import ExitReason # DryRunResult.reason annotation (runtime import is circular) -# --- Memory size parsing (from branching/process/limits.py) --- - -_UNITS = { - "K": 1024, - "M": 1024 ** 2, - "G": 1024 ** 3, - "T": 1024 ** 4, -} - -_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([KMGT])?\s*$", re.IGNORECASE) - - -def parse_memory_size(s: str) -> int: - """Parse a human-friendly memory size string to bytes. - - Accepts plain integers (bytes) or suffixed values: ``'512M'``, ``'1G'``, - ``'100K'``. The suffix is case-insensitive. - - Returns: - Size in bytes (integer). - - Raises: - ValueError: If the string cannot be parsed. - """ - m = _SIZE_RE.match(s) - if m is None: - raise ValueError(f"invalid memory size: {s!r}") - value = float(m.group(1)) - suffix = m.group(2) - if suffix is not None: - value *= _UNITS[suffix.upper()] - return int(value) - - _PORT_RANGE_RE = re.compile(r"^(\d+)(?:-(\d+))?$") @@ -116,6 +83,25 @@ class StdioMode(IntEnum): """Connect the stream to ``/dev/null``.""" +@dataclass(frozen=True) +class Mount: + """One entry of :attr:`Sandbox.fs_mount`: a host directory exposed at a + virtual path inside the chroot. + + Field names mirror the canonical profile form emitted by the core parser, + so loading a profile is a field-for-field copy. + """ + + virt: str + """Path the sandbox sees (inside the chroot).""" + + host: str + """Host path the virtual path resolves to.""" + + ro: bool = False + """Mount read-only. Writes through the virtual path are refused.""" + + @dataclass(frozen=True) class Change: """A single filesystem change detected by dry-run.""" @@ -264,8 +250,10 @@ class Sandbox: private key. Useful for NODE_EXTRA_CA_CERTS and similar.""" # Resource limits - max_memory: str | int | None = None - """Memory limit. String like '512M' or int bytes.""" + max_memory: int | None = None + """Memory limit in bytes, e.g. ``512 * 1024 ** 2``. Size strings + (``'512M'``) belong to the profile grammar and are resolved by the core + parser; this field is the resolved value.""" max_processes: int = 64 """Maximum total forks allowed in the sandbox (lifetime count, @@ -310,11 +298,15 @@ class Sandbox: """Seed for deterministic randomness. When set, getrandom() returns deterministic bytes from a seeded PRNG. Same seed = same output.""" - time_start: float | str | None = None - """Start timestamp for time virtualization. When set, clock_gettime() - and gettimeofday() return shifted time starting from this epoch. - Accepts a Unix timestamp (float) or ISO 8601 string. - Time ticks at real speed from the given start point.""" + time_start: float | None = None + """Start timestamp for time virtualization, as Unix epoch seconds. + When set, clock_gettime() and gettimeofday() return shifted time + starting from this epoch. Time ticks at real speed from the given + start point. RFC 3339 stamps belong to the profile grammar and are + resolved by the core parser; this field is the resolved value. + For a :class:`datetime.datetime`, pass ``dt.timestamp()``: an aware + datetime converts unambiguously, and a naive one has to be given a + timezone first rather than silently assumed to be UTC.""" no_randomize_memory: bool = False """Disable Address Space Layout Randomization (ASLR) inside the sandbox. @@ -345,10 +337,13 @@ class Sandbox: chroot: str | None = None """Path to chroot into before applying other confinement.""" - fs_mount: Mapping[str, str] = field(default_factory=dict) - """Map virtual paths to host directories inside chroot. - Example: {"/work": "/host/sandbox/work"} makes /work inside the - chroot resolve to /host/sandbox/work on the host.""" + fs_mount: Sequence[Mount] = field(default_factory=list) + """Host directories exposed at virtual paths inside the chroot. + Example: ``[Mount("/work", "/host/sandbox/work")]`` makes /work inside + the chroot resolve to /host/sandbox/work on the host; pass ``ro=True`` + for a read-only mount. A list rather than a mapping because the same + virtual path may be listed twice with different read-only flags, which + is what the profile grammar (``"VIRTUAL:HOST[:ro]"``) allows.""" # Environment clean_env: bool = False @@ -389,8 +384,8 @@ class Sandbox: fs_storage: str | None = None """Separate storage directory for the seccomp COW upper layer / deltas.""" - max_disk: str | None = None - """Disk quota for COW storage (e.g. ``'1G'``). + max_disk: int | None = None + """Disk quota for COW storage, in bytes (e.g. ``1024 ** 3``). Enforced by the COW layer (returns ENOSPC).""" on_exit: BranchAction = BranchAction.COMMIT @@ -444,6 +439,46 @@ def __post_init__(self): raise ValueError("sandbox name must not contain '/'") if self.name in (".", ".."): raise ValueError("sandbox name must not be '.' or '..'") + # Fields whose representation is the *resolved* value, not the profile + # syntax it came from. Accepting the syntax here would mean a second + # parser for the same grammar, which is what made a profile mean one + # thing through the CLI and another through this SDK. + for attr in ("max_memory", "max_disk"): + value = getattr(self, attr) + if isinstance(value, str): + raise TypeError( + f"{attr} must be an integer number of bytes, got {value!r}; " + "size strings like '512M' are profile syntax, resolved by " + "the core parser when a profile is loaded" + ) + if isinstance(self.time_start, str): + raise TypeError( + "time_start must be Unix epoch seconds, got " + f"{self.time_start!r}; RFC 3339 stamps are profile syntax, " + "resolved by the core parser when a profile is loaded" + ) + if isinstance(self.fs_mount, dict): + raise TypeError( + "fs_mount is a sequence of Mount entries, not a mapping; " + "use [Mount('/virt', '/host')] or Mount(..., ro=True) for a " + "read-only mount" + ) + if not isinstance(self.fs_mount, collections.abc.Sequence) or isinstance( + self.fs_mount, (str, bytes) + ): + # Rejected rather than materialized: a one-shot iterable would be + # emptied by the check below and the sandbox would silently run + # with no mounts. + raise TypeError( + "fs_mount must be a list or tuple of Mount entries, got " + f"{type(self.fs_mount).__name__}" + ) + for entry in self.fs_mount: + if not isinstance(entry, Mount): + raise TypeError( + "fs_mount entries must be Mount, got " + f"{type(entry).__name__}: {entry!r}" + ) # Runtime state — not dataclass fields, not serialized self._native = None # _NativePolicy created lazily on first use self._handle = None # live sandbox handle during start()/run() @@ -480,29 +515,6 @@ def _ensure_native(self): # Config helper methods # ------------------------------------------------------------------ - def memory_bytes(self) -> int | None: - """Return max_memory as bytes, or None if unset.""" - if self.max_memory is None: - return None - if isinstance(self.max_memory, int): - return self.max_memory - return parse_memory_size(self.max_memory) - - def time_start_timestamp(self) -> float | None: - """Return time_start as a Unix timestamp float, or None if unset.""" - if self.time_start is None: - return None - if isinstance(self.time_start, (int, float)): - return float(self.time_start) - from datetime import datetime, timezone - s = self.time_start - if s.endswith("Z"): - s = s[:-1] + "+00:00" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.timestamp() - def cpu_pct(self) -> int | None: """Return max_cpu as a clamped percentage (1–100), or None.""" if self.max_cpu is None: diff --git a/python/tests/test_cli_parity.py b/python/tests/test_cli_parity.py new file mode 100644 index 00000000..818f81c9 --- /dev/null +++ b/python/tests/test_cli_parity.py @@ -0,0 +1,677 @@ +# SPDX-License-Identifier: Apache-2.0 +"""CLI/SDK parity on profiles. + +A profile has to mean the same thing whether `sandlock run --profile-file` +loads it or the Python SDK does. Both now go through the same core parser, so +what is left to check is that nothing is lost on the way from the canonical +form to a native sandbox, and that a bad profile is refused in the same words. + +The comparison point is the control plane. A running sandbox serves its +effective policy over `sandlock inspect`, serialized by the same core routine +whichever side created it, so a dropped read-only mount, a size resolved +differently, or a port range expanded differently shows up as a difference in +that document. Comparing the two documents compares the policy, not the text +of the profile. + +Profiles that cannot be run that far (a chroot with no interpreter inside it, +a memory limit of zero) are compared one step earlier: both implementations +have to accept them, and the values the SDK resolved are pinned here. Profiles +both sides reject are compared on the message. + +The corpus below is meant to cover every micro-grammar a profile can carry: +mount specs with and without a `:ro`/`:rw` suffix, byte sizes, RFC 3339 +timestamps, net rules, bind port specs, HTTP rules, branch actions, and +syscall group names. +""" + +from __future__ import annotations + +import dataclasses +import itertools +import json +import os +import re +import shutil +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +from sandlock._profile import policy_from_toml +from sandlock.exceptions import PolicyError +from sandlock.sandbox import BranchAction, Mount + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# The guest command only has to stay alive long enough to be inspected. +SLEEP = shutil.which("sleep") or "/bin/sleep" + +# Grants every runnable case needs, so that the guest can exec the interpreter +# it was given. Missing directories are dropped: a grant on a path that does +# not exist is an error, and the layout differs between distributions. +BASE_READ = [d for d in ("/usr", "/bin", "/lib", "/lib64", "/etc") if os.path.isdir(d)] + +_names = itertools.count() + + +def _unique_name(prefix: str) -> str: + return f"{prefix}-{os.getpid()}-{next(_names)}" + + +@pytest.fixture(scope="session") +def cli() -> str: + """Path to the `sandlock` binary, built if it is not there yet. + + `SANDLOCK_CLI` short-circuits the search for packaging and for running + these tests against an installed binary. + """ + env = os.environ.get("SANDLOCK_CLI") + if env: + return env + for profile in ("debug", "release"): + candidate = REPO_ROOT / "target" / profile / "sandlock" + if candidate.is_file(): + return str(candidate) + subprocess.run( + ["cargo", "build", "-p", "sandlock-cli"], + cwd=REPO_ROOT, + check=True, + # The SDK loads the shared library from /target, so the CLI has + # to land there too and not in a redirected target directory. + env={k: v for k, v in os.environ.items() if k != "CARGO_TARGET_DIR"}, + ) + return str(REPO_ROOT / "target" / "debug" / "sandlock") + + +# ============================================================ +# Corpus +# ============================================================ + + +@dataclass(frozen=True) +class Case: + """One profile, and how far the two implementations can be compared.""" + + name: str + toml: str + #: "run" compares the effective policy of a live sandbox; "load" only + #: checks that both implementations accept the profile, for policies whose + #: guest cannot reach the point of being inspected. + compare: str = "run" + #: Sandbox attributes the profile is expected to resolve to. This is what + #: pins the meaning of a micro-grammar ("512M" is 536870912 bytes) rather + #: than only pinning that both sides agree. + expect: dict = field(default_factory=dict) + + +CASES: list[Case] = [ + Case( + name="filesystem_lists_and_branch_actions", + toml=""" + [filesystem] + read = {base_read} + write = ["{tmp}/w"] + deny = ["/etc/shadow"] + on_exit = "keep" + on_error = "abort" + """, + expect={ + "fs_denied": ["/etc/shadow"], + "on_exit": BranchAction.KEEP, + "on_error": BranchAction.ABORT, + }, + ), + Case( + name="branch_action_commit", + toml=""" + [filesystem] + read = {base_read} + on_exit = "commit" + """, + expect={"on_exit": BranchAction.COMMIT, "on_error": BranchAction.COMMIT}, + ), + Case( + name="mount_specs_ro_rw_and_bare", + toml=""" + [filesystem] + read = {base_read} + mount = ["/ro:{tmp}:ro", "/bare:{tmp}", "/rw:{tmp}:rw"] + """, + expect={ + "fs_mount": [ + Mount(virt="/ro", host="{tmp}", ro=True), + Mount(virt="/bare", host="{tmp}", ro=False), + Mount(virt="/rw", host="{tmp}", ro=False), + ], + }, + ), + Case( + name="byte_sizes_suffixed", + toml=""" + [filesystem] + read = {base_read} + [limits] + memory = "512M" + disk = "1G" + """, + expect={"max_memory": 512 * 1024 * 1024, "max_disk": 1024 * 1024 * 1024}, + ), + Case( + # The spellings are what this case is about: a bare byte count and a + # `K` suffix. The values are large because the case compares two live + # sandboxes, and a ceiling that the guest cannot start under is a + # portability trap rather than a stricter test: 1MiB is enough for + # `sleep` on x86-64 and not on arm64, where the loader maps more. + name="byte_sizes_bare_and_kilo", + toml=""" + [filesystem] + read = {base_read} + [limits] + memory = "268435456" + disk = "1048576K" + """, + expect={"max_memory": 268435456, "max_disk": 1048576 * 1024}, + ), + Case( + name="byte_size_largest_supported", + toml=""" + [filesystem] + read = {base_read} + [limits] + disk = "16777215G" + """, + compare="load", + expect={"max_disk": 16777215 * 1024 * 1024 * 1024}, + ), + Case( + name="byte_size_zero", + # A zero memory limit is a valid policy that kills the guest as soon as + # it faults a page in, so it is only compared at load time. + toml=""" + [filesystem] + read = {base_read} + [limits] + memory = "0" + """, + compare="load", + expect={"max_memory": 0}, + ), + Case( + name="limits_scalars", + toml=""" + [filesystem] + read = {base_read} + [limits] + processes = 8 + open_files = 128 + cpu = 50 + num_cpus = 1 + cpu_cores = [0] + """, + expect={ + "max_processes": 8, + "max_open_files": 128, + "max_cpu": 50, + "num_cpus": 1, + "cpu_cores": [0], + }, + ), + Case( + name="time_start_utc", + toml=""" + [filesystem] + read = {base_read} + [determinism] + time_start = "2026-01-01T00:00:00Z" + """, + expect={"time_start": 1767225600}, + ), + Case( + name="time_start_offset", + # The same instant written with a non-zero offset. Both sides have to + # land on the same epoch second, not on the wall clock digits. + toml=""" + [filesystem] + read = {base_read} + [determinism] + time_start = "2025-12-31T21:00:00-03:00" + """, + expect={"time_start": 1767225600}, + ), + Case( + name="determinism_flags", + toml=""" + [filesystem] + read = {base_read} + [determinism] + random_seed = 7 + deterministic_dirs = true + no_randomize_memory = true + """, + expect={ + "random_seed": 7, + "deterministic_dirs": True, + "no_randomize_memory": True, + }, + ), + Case( + name="program_section", + toml=""" + [filesystem] + read = {base_read} + [program] + env = { FOO = "bar", BAZ = "qux" } + cwd = "/tmp" + uid = {uid} + gid = {gid} + clean_env = true + no_coredump = true + no_huge_pages = true + """, + expect={ + "env": {"FOO": "bar", "BAZ": "qux"}, + "cwd": "/tmp", + "clean_env": True, + "no_coredump": True, + "no_huge_pages": True, + }, + ), + Case( + name="program_exec_is_not_policy", + # exec/args identify a program, not a policy. The CLI takes the command + # from argv here, so the two sandboxes still have to agree. + toml=""" + [filesystem] + read = {base_read} + [program] + exec = "/bin/true" + args = ["--flag"] + """, + ), + Case( + name="net_rules_every_form", + toml=""" + [filesystem] + read = {base_read} + [network] + allow = [ + "127.0.0.1:8080", + "localhost:22,443", + "tcp://10.0.0.0/8:443", + "udp://192.168.1.1:53", + "udp://*:*", + "icmp://*", + ":53", + "[2606:4700::/32]:443", + ] + """, + expect={ + # A scheme-less rule covers TCP and UDP, so core hands back one + # rendered rule per protocol. + "net_allow": [ + "tcp://127.0.0.1:8080", + "udp://127.0.0.1:8080", + "tcp://localhost:22,443", + "udp://localhost:22,443", + "tcp://10.0.0.0/8:443", + "udp://192.168.1.1:53", + "udp://*", + "icmp://*", + "tcp://*:53", + "udp://*:53", + "tcp://[2606:4700::/32]:443", + "udp://[2606:4700::/32]:443", + ], + }, + ), + Case( + name="net_deny_and_bind_denylist", + toml=""" + [filesystem] + read = {base_read} + [network] + deny = ["10.0.0.0/8:443", "udp://1.2.3.4:53"] + deny_bind = [22, "8000-8002"] + """, + expect={ + "net_deny": ["tcp://10.0.0.0/8:443", "udp://10.0.0.0/8:443", "udp://1.2.3.4:53"], + "net_deny_bind": [22, 8000, 8001, 8002], + }, + ), + Case( + name="bind_port_specs", + toml=""" + [filesystem] + read = {base_read} + [network] + allow_bind = [8080, "9000-9002", "7000,7001"] + port_remap = true + """, + expect={ + # Ranges and lists are expanded, sorted and deduplicated by core. + "net_allow_bind": [7000, 7001, 8080, 9000, 9001, 9002], + "port_remap": True, + }, + ), + Case( + name="bind_any_port", + toml=""" + [filesystem] + read = {base_read} + [network] + allow_bind = ["*"] + """, + expect={"net_allow_bind": ["*"]}, + ), + Case( + name="http_rules", + toml=""" + [filesystem] + read = {base_read} + [http] + ports = [8080] + allow = ["get localhost/v1/*", "POST localhost/api"] + deny = ["* localhost/admin"] + """, + expect={ + # The method is uppercased and the path normalized by core. + "http_allow": ["GET localhost/v1/*", "POST localhost/api"], + "http_deny": ["* localhost/admin"], + "http_ports": [8080], + }, + ), + Case( + name="http_wildcard_host", + toml=""" + [filesystem] + read = {base_read} + [http] + ports = [8080] + allow = ["GET */public/*"] + """, + expect={"http_allow": ["GET */public/*"]}, + ), + Case( + name="syscall_groups_and_names", + toml=""" + [filesystem] + read = {base_read} + [syscalls] + extra_allow = ["sysv_ipc"] + extra_deny = ["ptrace", "keyctl"] + """, + expect={ + "extra_allow_syscalls": ["sysv_ipc"], + "extra_deny_syscalls": ["ptrace", "keyctl"], + }, + ), + Case( + name="config_workdir", + toml=""" + [filesystem] + read = {base_read} + [config] + workdir = "{tmp}" + """, + expect={"workdir": "{tmp}"}, + ), + Case( + name="chroot", + # A chroot with nothing in it cannot exec the guest command, so this + # one stops at load time. + toml=""" + [filesystem] + read = {base_read} + chroot = "{tmp}" + """, + compare="load", + expect={"chroot": "{tmp}"}, + ), +] + + +# A profile both implementations must refuse, with the same words. One entry +# per micro-grammar that can fail, plus the cross-section checks the builder +# runs after the whole profile is in. +REJECTS: list[tuple[str, str]] = [ + ("size_fractional", '[limits]\nmemory = "1.5G"\n'), + ("size_terabyte_suffix", '[limits]\nmemory = "1T"\n'), + ("size_out_of_range", '[limits]\nmemory = "17179869184G"\n'), + ("size_not_a_number", '[limits]\nmemory = "abc"\n'), + ("size_negative", '[limits]\nmemory = "-1"\n'), + ("disk_fractional", '[limits]\ndisk = "0.5G"\n'), + ("time_start_without_offset", '[determinism]\ntime_start = "2026-01-01T00:00:00"\n'), + ("time_start_not_a_timestamp", '[determinism]\ntime_start = "yesterday"\n'), + ("time_start_as_integer", "[determinism]\ntime_start = 1767225600\n"), + ("mount_without_separator", '[filesystem]\nmount = ["novirt"]\n'), + ("mount_empty_host", '[filesystem]\nmount = ["/v:"]\n'), + ("mount_empty_virtual", '[filesystem]\nmount = [":/h"]\n'), + ("mount_suffix_only", '[filesystem]\nmount = ["/v:ro"]\n'), + ("branch_action_on_exit", '[filesystem]\non_exit = "nope"\n'), + ("branch_action_on_error", '[filesystem]\non_error = "rollback"\n'), + ("net_port_out_of_range", '[network]\nallow = ["example.com:99999"]\n'), + ("net_unknown_scheme", '[network]\nallow = ["ftp://example.com:21"]\n'), + ("net_deny_hostname", '[network]\ndeny = ["example.com:443"]\n'), + ("net_allow_and_deny", '[network]\nallow = ["1.2.3.4:80"]\ndeny = ["5.6.7.8:80"]\n'), + ("bind_reversed_range", '[network]\nallow_bind = ["9000-8000"]\n'), + ("bind_not_a_port", '[network]\nallow_bind = ["http"]\n'), + ("bind_allow_and_deny", "[network]\nallow_bind = [80]\ndeny_bind = [81]\n"), + ("syscall_group_unknown", '[syscalls]\nextra_allow = ["not_a_group"]\n'), + ("syscall_name_unknown", '[syscalls]\nextra_deny = ["nosuchsyscall"]\n'), + ("uid_without_gid", "[program]\nuid = 1000\n"), + ("cpu_zero", "[limits]\ncpu = 0\n"), + ("cpu_above_hundred", "[limits]\ncpu = 101\n"), + ("open_files_zero", "[limits]\nopen_files = 0\n"), + ("http_rule_without_space", '[http]\nallow = ["GETexample.com"]\n'), + ("http_port_out_of_range", "[http]\nports = [70000]\n"), + ("unknown_key", '[limits]\nmemry = "1G"\n'), + ("unknown_section", "[nope]\nx = 1\n"), + ("malformed_toml", "[limits\n"), + ("wrong_value_type", '[limits]\nprocesses = "many"\n'), +] + + +# ============================================================ +# Harness +# ============================================================ + + +def _render(text: str, tmp_path: Path) -> str: + """Fill in the host-specific parts of a corpus profile.""" + return ( + text.replace("{base_read}", json.dumps(BASE_READ)) + .replace("{tmp}", str(tmp_path)) + .replace("{uid}", str(os.getuid())) + .replace("{gid}", str(os.getgid())) + ) + + +def _expected(value, tmp_path: Path): + """Fill in `{tmp}` inside an expected value.""" + if isinstance(value, str): + return value.replace("{tmp}", str(tmp_path)) + if isinstance(value, Mount): + return Mount( + virt=_expected(value.virt, tmp_path), + host=_expected(value.host, tmp_path), + ro=value.ro, + ) + if isinstance(value, list): + return [_expected(v, tmp_path) for v in value] + return value + + +def _without_run_local_paths(policy: dict) -> dict: + """Blank out the parts of a policy that name this run and not the profile. + + A workdir gives the sandbox a copy-on-write upper layer under a directory + named after a fresh UUID, which is then granted read access. The grant is + part of the effective policy but its path is per run, so comparing the two + documents literally would compare two UUIDs. + """ + text = json.dumps(policy) + text = re.sub(r"/sandlock-cow/[0-9a-f-]{36}/", "/sandlock-cow//", text) + return json.loads(text) + + +def _inspect(cli: str, name: str, timeout: float = 15.0) -> dict: + """Read a live sandbox's effective policy through the control plane.""" + deadline = time.monotonic() + timeout + last = "" + while time.monotonic() < deadline: + done = subprocess.run([cli, "inspect", name], capture_output=True, text=True) + if done.returncode == 0: + return json.loads(done.stdout) + last = done.stderr.strip() + time.sleep(0.05) + raise AssertionError(f"`sandlock inspect {name}` never answered: {last}") + + +def _stop(proc: subprocess.Popen) -> str: + """Shut a sandbox down and return what it reported. + + A signalled supervisor tears its guest down with it, so the polite signal + goes first; the guest would otherwise outlive the test as an orphan. + """ + proc.terminate() + try: + _, err = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: # pragma: no cover - defensive + proc.kill() + _, err = proc.communicate(timeout=10) + return err.strip() + + +def _cli_effective_policy(cli: str, profile: Path) -> dict: + """Run a profile through `sandlock run` and read back its policy.""" + name = _unique_name("parity-cli") + proc = subprocess.Popen( + [cli, "run", "--profile-file", str(profile), "--name", name, "--", SLEEP, "30"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + policy = _inspect(cli, name) + except BaseException as exc: + reported = _stop(proc) + if isinstance(exc, AssertionError): + raise AssertionError(f"{exc}\nsandlock run said: {reported}") from exc + raise + _stop(proc) + return policy + + +def _sdk_effective_policy(cli: str, text: str) -> dict: + """Load a profile through the SDK and read back the same document.""" + policy = dataclasses.replace(policy_from_toml(text), name=_unique_name("parity-sdk")) + policy.spawn([SLEEP, "30"]) + try: + return _inspect(cli, policy.name) + finally: + try: + policy.kill() + except Exception: # pragma: no cover - the guest may already be gone + pass + + +def _cli_load_error(cli: str, profile: Path) -> str | None: + """Return the CLI's profile diagnosis, or None if it accepted the profile. + + A rejected profile stops `sandlock run` before it forks anything, and the + report is the core error. Anything that goes wrong afterwards (an exec that + the policy denies, a host that does not resolve) is a different message, + which is what tells the two apart. + """ + done = subprocess.run( + [cli, "run", "--profile-file", str(profile), "--", "/bin/true"], + capture_output=True, + text=True, + ) + head = done.stderr.split("\n\nCaused by:")[0].strip() + if not head.startswith("Error: sandbox error:"): + return None + return head[len("Error: ") :] + + +def _write(tmp_path: Path, text: str) -> Path: + profile = tmp_path / "profile.toml" + profile.write_text(text, encoding="utf-8") + return profile + + +def _ids(cases): + return [c.name for c in cases] + + +RUN_CASES = [c for c in CASES if c.compare == "run"] +LOAD_CASES = [c for c in CASES if c.compare == "load"] +EXPECT_CASES = [c for c in CASES if c.expect] + + +# ============================================================ +# Tests +# ============================================================ + + +@pytest.mark.parametrize("case", RUN_CASES, ids=_ids(RUN_CASES)) +def test_effective_policy_is_the_same_from_both_sides(cli, tmp_path, case): + """The same profile has to produce the same live policy either way.""" + (tmp_path / "w").mkdir(exist_ok=True) + text = _render(case.toml, tmp_path) + from_cli = _cli_effective_policy(cli, _write(tmp_path, text)) + from_sdk = _sdk_effective_policy(cli, text) + assert _without_run_local_paths(from_cli) == _without_run_local_paths(from_sdk) + + +@pytest.mark.parametrize("case", LOAD_CASES, ids=_ids(LOAD_CASES)) +def test_both_accept_the_profile(cli, tmp_path, case): + """Policies whose guest cannot run are still accepted by both sides.""" + text = _render(case.toml, tmp_path) + assert _cli_load_error(cli, _write(tmp_path, text)) is None + policy_from_toml(text) # raises PolicyError if the SDK disagrees + + +@pytest.mark.parametrize("case", EXPECT_CASES, ids=_ids(EXPECT_CASES)) +def test_profile_resolves_to_expected_values(tmp_path, case): + """Pin what each micro-grammar means, not only that both sides agree.""" + policy = policy_from_toml(_render(case.toml, tmp_path)) + for attr, value in case.expect.items(): + assert getattr(policy, attr) == _expected(value, tmp_path), attr + + +@pytest.mark.parametrize("name,text", REJECTS, ids=[n for n, _ in REJECTS]) +def test_rejected_by_both_with_the_same_message(cli, tmp_path, name, text): + """A refused profile is refused on both sides, in the same words.""" + from_cli = _cli_load_error(cli, _write(tmp_path, text)) + assert from_cli is not None, "the CLI accepted a profile the SDK rejects" + + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(text) + + # The core message is passed through unchanged on both sides; the CLI's + # error formatting is what strips the trailing newline of a TOML report. + assert str(excinfo.value).rstrip("\n") == from_cli + + +def test_time_start_the_c_abi_cannot_carry_is_refused_loudly(cli, tmp_path): + """Two timestamps the CLI accepts and the SDK cannot apply. + + `sandlock_sandbox_builder_time_start` takes a `uint64` of seconds, so a + sub-second or pre-epoch stamp cannot be handed to it, while core keeps a + full timestamp and the CLI runs both. That is a real parity gap, reported + upstream. What is pinned here is that the SDK says so instead of wrapping + a negative value through the unsigned setter (a pre-epoch stamp used to + land in year 584942417355), and that the profile itself still loads, so + the gap stays visible as an ABI limit rather than a parse difference. + """ + for text in ( + '[determinism]\ntime_start = "2026-01-01T00:00:00.5Z"\n', + '[determinism]\ntime_start = "1960-01-01T00:00:00Z"\n', + ): + assert _cli_load_error(cli, _write(tmp_path, text)) is None + policy = policy_from_toml(text) + with pytest.raises(ValueError, match="sandlock_sandbox_builder_time_start"): + policy.create(["/bin/true"]) diff --git a/python/tests/test_fs_mount.py b/python/tests/test_fs_mount.py index c43ffdc2..ccc0bf37 100644 --- a/python/tests/test_fs_mount.py +++ b/python/tests/test_fs_mount.py @@ -10,7 +10,7 @@ import pytest -from sandlock import Sandbox +from sandlock import Mount, Sandbox _HELPER_BIN = Path(__file__).resolve().parent.parent.parent / "tests" / "rootfs-helper" @@ -54,7 +54,7 @@ def _mount_policy(rootfs, work_dir, cwd="/", extra_fs_readable=None): readable.extend(extra_fs_readable) return Sandbox( chroot=str(rootfs), - fs_mount={"/work": str(work_dir)}, + fs_mount=[Mount("/work", str(work_dir))], fs_readable=readable, clean_env=True, cwd=cwd, @@ -153,7 +153,7 @@ def test_fs_mount_setxattr(self, rootfs, tmp_path): # readable-only, so build a writable policy here. policy = Sandbox( chroot=str(rootfs), - fs_mount={"/work": str(work_dir)}, + fs_mount=[Mount("/work", str(work_dir))], fs_readable=list(_FS_READABLE), fs_writable=["/work"], clean_env=True, @@ -163,6 +163,47 @@ def test_fs_mount_setxattr(self, rootfs, tmp_path): assert result.success, f"failed: {result.stderr.decode(errors='replace')}" assert os.getxattr(target, "user.color") == b"blue" + def test_fs_mount_read_only_allows_reads(self, rootfs, tmp_path): + """A read-only mount still serves reads.""" + work_dir = tmp_path / "hostwork" + work_dir.mkdir() + (work_dir / "hello.txt").write_text("hello from host\n") + + policy = Sandbox( + chroot=str(rootfs), + fs_mount=[Mount("/work", str(work_dir), ro=True)], + fs_readable=list(_FS_READABLE), + fs_writable=["/work"], + clean_env=True, + env={"PATH": "/bin:/usr/bin"}, + ) + result = policy.run(["cat", "/work/hello.txt"]) + assert result.success, f"failed: {result.stderr}" + assert b"hello from host" in result.stdout + + def test_fs_mount_read_only_refuses_writes(self, rootfs, tmp_path): + """``Mount(..., ro=True)`` is honoured even when /work is writable. + + The old mount representation was a virtual-to-host mapping with no + room for the flag, so a profile's ':ro' suffix could not be applied + at all. Granting fs_writable here means only the mount's read-only + flag can be what refuses the write. + """ + work_dir = tmp_path / "hostwork" + work_dir.mkdir() + + policy = Sandbox( + chroot=str(rootfs), + fs_mount=[Mount("/work", str(work_dir), ro=True)], + fs_readable=list(_FS_READABLE), + fs_writable=["/work"], + clean_env=True, + env={"PATH": "/bin:/usr/bin"}, + ) + result = policy.run(["write", "/work/output.txt", "should be refused"]) + assert not result.success + assert not (work_dir / "output.txt").exists() + def test_fs_mount_cwd(self, rootfs, tmp_path): """Set cwd=/work, verify cat with relative path works.""" work_dir = tmp_path / "hostwork" @@ -241,7 +282,7 @@ def _cow_mount_policy(self, rootfs, work_dir, storage_dir, """Build a policy combining fs_mount with COW.""" kwargs = dict( chroot=str(rootfs), - fs_mount={"/work": str(work_dir)}, + fs_mount=[Mount("/work", str(work_dir))], workdir=str(work_dir), fs_storage=str(storage_dir), fs_writable=[str(work_dir)], @@ -302,7 +343,7 @@ def test_fs_mount_cow_quota(self, rootfs, tmp_path): (work_dir / "big.bin").write_bytes(b"\x00" * 8192) policy = self._cow_mount_policy(rootfs, work_dir, storage_dir, - on_exit="abort", max_disk="1K") + on_exit="abort", max_disk=1024) # The write applet opens the file with O_WRONLY|O_CREAT|O_TRUNC, # triggering a COW copy of the 8 KiB file against a 1 KiB quota. result = policy.run(["write", "/work/big.bin", "overwrite"]) diff --git a/python/tests/test_mcp.py b/python/tests/test_mcp.py index e0172dc0..28ca45ef 100644 --- a/python/tests/test_mcp.py +++ b/python/tests/test_mcp.py @@ -44,9 +44,9 @@ def test_net_allow(self): def test_max_memory(self): policy = policy_for_tool( workspace="/tmp/ws", - capabilities={"max_memory": "512M"}, + capabilities={"max_memory": 512 * 1024 ** 2}, ) - assert policy.max_memory == "512M" + assert policy.max_memory == 512 * 1024 ** 2 def test_multiple(self): policy = policy_for_tool( @@ -54,13 +54,13 @@ def test_multiple(self): capabilities={ "fs_writable": ["/data"], "net_allow": ["api.example.com:443", ":8080"], - "max_memory": "256M", + "max_memory": 256 * 1024 ** 2, }, ) assert policy.fs_writable == ["/data"] assert "api.example.com:443" in policy.net_allow assert ":8080" in policy.net_allow - assert policy.max_memory == "256M" + assert policy.max_memory == 256 * 1024 ** 2 def test_unknown_field_ignored(self): policy = policy_for_tool( @@ -84,9 +84,9 @@ def test_from_annotations(self): assert caps == {"net_allow": ["api.example.com:443"]} def test_from_meta(self): - tool = self._tool(meta={"sandlock:max_memory": "128M"}) + tool = self._tool(meta={"sandlock:max_memory": 128 * 1024 ** 2}) caps = capabilities_from_mcp_tool(tool) - assert caps == {"max_memory": "128M"} + assert caps == {"max_memory": 128 * 1024 ** 2} def test_standard_hints_ignored(self): tool = self._tool({"readOnlyHint": True, "openWorldHint": True}) diff --git a/python/tests/test_policy_fn.py b/python/tests/test_policy_fn.py index 6b53272b..c26378d8 100644 --- a/python/tests/test_policy_fn.py +++ b/python/tests/test_policy_fn.py @@ -210,7 +210,7 @@ def restrict_to_64mb(event, ctx): return 0 # Restricted: 128 MiB exceeds the tightened 64 MiB limit -> killed. - restricted = _policy(max_memory="256M", policy_fn=restrict_to_64mb).run( + restricted = _policy(max_memory=256 * 1024 * 1024, policy_fn=restrict_to_64mb).run( [sys.executable, "-c", alloc_128mb], timeout=15 ) assert b"STARTED" in restricted.stdout, restricted.stdout @@ -218,7 +218,7 @@ def restrict_to_64mb(event, ctx): assert not restricted.success, "128 MiB must exceed the 64 MiB dynamic limit" # Control: same 128 MiB under the un-restricted 256 MiB ceiling -> OK. - baseline = _policy(max_memory="256M").run( + baseline = _policy(max_memory=256 * 1024 * 1024).run( [sys.executable, "-c", alloc_128mb], timeout=15 ) assert b"ALLOC_OK" in baseline.stdout, baseline.stdout diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index 1994bb9b..96b0c71d 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -1,274 +1,479 @@ # SPDX-License-Identifier: Apache-2.0 -"""Tests for sandlock._profile (sectioned schema).""" +"""Tests for sandlock._profile. + +Profile text is parsed by the core parser and returned in canonical form; +this module only maps that form onto a Sandbox. The tests therefore fall +into two groups: field mapping, and parity with the core grammar (the SDK +must accept exactly what the CLI accepts, reject exactly what it rejects, +and say what the core says when it rejects). +""" from __future__ import annotations -import re import textwrap import pytest from sandlock._profile import ( + _from_canonical, list_profiles, load_profile_path, merge_cli_overrides, - policy_from_dict, + policy_from_toml, profiles_dir, ) +from sandlock._sdk import profile_parse from sandlock.exceptions import PolicyError -from sandlock.sandbox import BranchAction, Sandbox +from sandlock.sandbox import BranchAction, Mount, Sandbox -class TestPolicyFromDict: - def test_empty_dict(self): - p = policy_from_dict({}) - assert p == Sandbox() +class TestSectionMapping: + def test_empty_profile_is_defaults_with_core_branch_actions(self): + # A profile always carries both branch actions: core resolves the + # default so that an absent key cannot mean one thing to the CLI and + # another to a binding. Core's default is commit for both. + p = policy_from_toml("") + assert p == Sandbox(on_error=BranchAction.COMMIT) def test_filesystem_section(self): - p = policy_from_dict({ - "filesystem": { - "read": ["/usr", "/lib"], - "write": ["/tmp"], - "deny": ["/proc/sys"], - }, - }) + p = policy_from_toml(textwrap.dedent("""\ + [filesystem] + read = ["/usr", "/lib"] + write = ["/tmp"] + deny = ["/proc/sys"] + chroot = "/srv/root" + """)) assert p.fs_readable == ["/usr", "/lib"] assert p.fs_writable == ["/tmp"] assert p.fs_denied == ["/proc/sys"] + assert p.chroot == "/srv/root" def test_program_section(self): - p = policy_from_dict({ - "program": { - "env": {"FOO": "bar", "BAZ": "qux"}, - "uid": 0, - "clean_env": True, - "no_coredump": True, - }, - }) + p = policy_from_toml(textwrap.dedent("""\ + [program] + env = { FOO = "bar", BAZ = "qux" } + uid = 1000 + gid = 1000 + cwd = "/work" + clean_env = true + no_coredump = true + no_huge_pages = true + """)) assert p.env == {"FOO": "bar", "BAZ": "qux"} - assert p.uid == 0 + assert p.uid == 1000 + assert p.gid == 1000 + assert p.cwd == "/work" assert p.clean_env is True assert p.no_coredump is True + assert p.no_huge_pages is True - def test_program_exec_and_args_are_silently_ignored(self): - # exec/args are runtime program identity, not Sandbox config. - # Loading a profile with them should succeed but not place them - # anywhere on the resulting Sandbox. - p = policy_from_dict({ - "program": { - "exec": "/bin/true", - "args": ["--flag"], - "uid": 1000, - }, - }) - assert p.uid == 1000 - # No side-effect on Sandbox itself; we just need the load to succeed. - assert isinstance(p, Sandbox) + def test_program_exec_and_args_are_dropped(self): + # exec/args are runtime program identity, not Sandbox config. They + # must not block the load, and they must not land anywhere. + p = policy_from_toml(textwrap.dedent("""\ + [program] + exec = "/bin/true" + args = ["--flag"] + uid = 1000 + gid = 1000 + """)) + assert p == Sandbox(uid=1000, gid=1000, on_error=BranchAction.COMMIT) def test_limits_section(self): - p = policy_from_dict({ - "limits": { - "memory": "512M", - "processes": 10, - "open_files": 256, - "cpu": 80, - "disk": "256M", - "cpu_cores": [0, 1], - }, - }) - assert p.max_memory == "512M" + p = policy_from_toml(textwrap.dedent("""\ + [limits] + memory = "512M" + disk = "256M" + processes = 10 + open_files = 256 + cpu = 80 + cpu_cores = [0, 1] + num_cpus = 2 + gpu_devices = [0] + """)) + assert p.max_memory == 512 * 1024 ** 2 + assert p.max_disk == 256 * 1024 ** 2 assert p.max_processes == 10 assert p.max_open_files == 256 assert p.max_cpu == 80 - assert p.max_disk == "256M" assert list(p.cpu_cores) == [0, 1] + assert p.num_cpus == 2 + assert list(p.gpu_devices) == [0] + + def test_absent_limits_keep_sandbox_defaults(self): + # `processes` is null in the canonical form when unset, and the + # Sandbox default for it is 64, not None: a null must be skipped + # rather than assigned. + p = policy_from_toml("[limits]\ncpu = 50\n") + assert p.max_processes == 64 + assert p.max_memory is None + assert p.gpu_devices is None def test_network_section(self): - p = policy_from_dict({ - "network": { - "allow_bind": [8080], - "allow": ["api.example.com:443", ":8080"], - "port_remap": True, - }, - }) - assert p.net_allow_bind == ["8080"] # ints coerced to strings - assert list(p.net_allow) == ["api.example.com:443", ":8080"] + p = policy_from_toml(textwrap.dedent("""\ + [network] + allow_bind = [8080, "9000-9001"] + allow = ["tcp://api.example.com:443"] + port_remap = true + """)) + assert list(p.net_allow_bind) == [8080, 9000, 9001] + assert list(p.net_allow) == ["tcp://api.example.com:443"] assert p.port_remap is True - def test_network_allow_bind_wildcard(self): - p = policy_from_dict({ - "network": {"allow_bind": ["*"]}, - }) - assert p.net_allow_bind == ["*"] - def test_network_deny_section(self): - p = policy_from_dict({ - "network": {"deny": ["10.0.0.0/8", "169.254.169.254:80"]}, - }) - assert list(p.net_deny) == ["10.0.0.0/8", "169.254.169.254:80"] - - def test_network_deny_bind_section(self): - p = policy_from_dict({ - "network": {"deny_bind": [8080, "9000-9002"]}, - }) - assert list(p.net_deny_bind) == [8080, "9000-9002"] + p = policy_from_toml(textwrap.dedent("""\ + [network] + deny = ["tcp://10.0.0.0/8"] + deny_bind = [8080, "9000-9001"] + """)) + assert list(p.net_deny) == ["tcp://10.0.0.0/8"] + assert list(p.net_deny_bind) == [8080, 9000, 9001] def test_http_section(self): - p = policy_from_dict({ - "http": { - "ports": [80, 443], - "allow": ["GET api.internal/v1/*"], - "deny": ["* */admin/*"], - }, - }) + p = policy_from_toml(textwrap.dedent("""\ + [http] + ports = [80, 443] + allow = ["GET api.internal/v1/*"] + """)) assert list(p.http_ports) == [80, 443] assert list(p.http_allow) == ["GET api.internal/v1/*"] - assert list(p.http_deny) == ["* */admin/*"] def test_syscalls_section(self): - p = policy_from_dict({ - "syscalls": { - "extra_allow": ["sysv_ipc"], - "extra_deny": ["ptrace"], - }, - }) + p = policy_from_toml(textwrap.dedent("""\ + [syscalls] + extra_allow = ["sysv_ipc"] + extra_deny = ["ptrace"] + """)) assert list(p.extra_allow_syscalls) == ["sysv_ipc"] assert list(p.extra_deny_syscalls) == ["ptrace"] def test_config_section(self): - p = policy_from_dict({ - "config": { - "http_ca": "/etc/sandlock/ca.pem", - "http_key": "/etc/sandlock/ca.key", - "fs_storage": "/var/sandlock/store", - "workdir": "/var/sandlock/work", - }, - }) + p = policy_from_toml(textwrap.dedent("""\ + [config] + http_ca = "/etc/sandlock/ca.pem" + http_key = "/etc/sandlock/ca.key" + http_ca_out = "/tmp/ca-out.pem" + http_inject_ca = ["/etc/ssl/certs/ca-bundle.crt"] + fs_storage = "/var/sandlock/store" + workdir = "/var/sandlock/work" + + [http] + allow = ["GET api.internal/v1/*"] + """)) assert p.http_ca == "/etc/sandlock/ca.pem" assert p.http_key == "/etc/sandlock/ca.key" + assert p.http_ca_out == "/tmp/ca-out.pem" + assert list(p.http_inject_ca) == ["/etc/ssl/certs/ca-bundle.crt"] assert p.fs_storage == "/var/sandlock/store" assert p.workdir == "/var/sandlock/work" def test_determinism_section(self): - p = policy_from_dict({ - "determinism": { - "random_seed": 42, - "deterministic_dirs": True, - "no_randomize_memory": True, - }, - }) + p = policy_from_toml(textwrap.dedent("""\ + [determinism] + random_seed = 42 + deterministic_dirs = true + no_randomize_memory = true + """)) assert p.random_seed == 42 assert p.deterministic_dirs is True assert p.no_randomize_memory is True - def test_filesystem_isolation_key_rejected(self): - with pytest.raises(PolicyError, match=r"unknown field\(s\) in \[filesystem\]"): - policy_from_dict({"filesystem": {"isolation": "none"}}) - - def test_filesystem_branch_actions(self): - p = policy_from_dict({ - "filesystem": {"on_exit": "abort", "on_error": "keep"}, - }) + def test_branch_actions(self): + p = policy_from_toml('[filesystem]\non_exit = "abort"\non_error = "keep"\n') assert p.on_exit == BranchAction.ABORT assert p.on_error == BranchAction.KEEP - def test_filesystem_mount_strings_to_dict(self): - p = policy_from_dict({ - "filesystem": {"mount": ["/data:/srv/redis-data", "/cache:/srv/cache"]}, - }) - assert p.fs_mount == {"/data": "/srv/redis-data", "/cache": "/srv/cache"} - - def test_unknown_section_raises(self): - with pytest.raises(PolicyError, match="unknown section"): - policy_from_dict({"bogus": {}}) - - def test_unknown_field_in_section_raises(self): - with pytest.raises(PolicyError, match=r"unknown field\(s\) in \[filesystem\]"): - policy_from_dict({"filesystem": {"bogus": True}}) - - def test_section_must_be_table(self): - with pytest.raises(PolicyError, match=r"\[filesystem\] must be a TOML table"): - policy_from_dict({"filesystem": "not-a-table"}) - - def test_type_mismatch_raises(self): - with pytest.raises(PolicyError, match=r"\[program\]\.clean_env expected bool"): - policy_from_dict({"program": {"clean_env": "yes"}}) - - def test_invalid_branch_action_raises(self): - with pytest.raises(PolicyError, match=r"\[filesystem\]\.on_exit must be"): - policy_from_dict({"filesystem": {"on_exit": "invalid"}}) - - def test_mount_missing_colon_raises(self): - with pytest.raises(PolicyError, match=r"must be 'VIRTUAL:HOST'"): - policy_from_dict({"filesystem": {"mount": ["nocolon"]}}) - - def test_mount_empty_half_raises(self): - with pytest.raises(PolicyError, match=r"both VIRTUAL and HOST"): - policy_from_dict({"filesystem": {"mount": [":/host"]}}) - - def test_mount_ro_suffix_raises(self): - # The CLI accepts 'VIRTUAL:HOST:ro'; the SDK cannot express a - # read-only mount, so it must refuse instead of folding ':ro' into - # the host path. - with pytest.raises( - PolicyError, match=r"':ro' suffix, which the Python SDK cannot honour" - ): - policy_from_dict({"filesystem": {"mount": ["/work:/host:ro"]}}) - - def test_mount_rw_suffix_raises(self): - # ':rw' is refused for a different reason: it is outside this - # parser's grammar, not something the SDK cannot express. The - # message must not claim a read-only mount is involved. - with pytest.raises( - PolicyError, match=r"':rw' suffix, which is the sandlock CLI's default" - ): - policy_from_dict({"filesystem": {"mount": ["/work:/host:rw"]}}) - - def test_mount_rw_error_does_not_claim_a_read_only_mount(self): + def test_loaded_profile_is_still_a_plain_dataclass(self): + import dataclasses + + p = policy_from_toml('[limits]\nmemory = "512M"\n') + assert dataclasses.is_dataclass(p) + assert dataclasses.replace(p, max_cpu=50).max_cpu == 50 + assert dataclasses.asdict(p)["max_memory"] == 512 * 1024 ** 2 + + +class TestMounts: + def test_mount_maps_to_mount_entries(self): + p = policy_from_toml( + '[filesystem]\nmount = ["/data:/srv/data", "/cache:/srv/cache"]\n' + ) + assert list(p.fs_mount) == [ + Mount("/data", "/srv/data"), + Mount("/cache", "/srv/cache"), + ] + + def test_read_only_suffix_is_applied_not_refused(self): + # Before the core parser was adopted, the SDK could not express a + # read-only mount and refused the ':ro' suffix outright. + p = policy_from_toml('[filesystem]\nmount = ["/work:/host:ro"]\n') + assert list(p.fs_mount) == [Mount("/work", "/host", ro=True)] + + def test_read_write_suffix_is_accepted(self): + p = policy_from_toml('[filesystem]\nmount = ["/work:/host:rw"]\n') + assert list(p.fs_mount) == [Mount("/work", "/host", ro=False)] + + def test_host_path_may_contain_colons(self): + p = policy_from_toml( + '[filesystem]\nmount = ["/v:/a:b", "/v2:/host:root"]\n' + ) + assert list(p.fs_mount) == [ + Mount("/v", "/a:b"), + Mount("/v2", "/host:root"), + ] + + def test_same_virtual_path_twice_keeps_both_entries(self): + # A mapping keyed by virtual path would collapse these two and lose a + # host path; a sequence keeps both. The read-only flag does collapse, + # because the core keys it by virtual path: ':ro' on either spec denies + # writes through '/w' for both, and that is what the loaded policy says + # rather than the flag each spec was written with. + p = policy_from_toml('[filesystem]\nmount = ["/w:/h1", "/w:/h2:ro"]\n') + assert list(p.fs_mount) == [ + Mount("/w", "/h1", ro=True), + Mount("/w", "/h2", ro=True), + ] + + @pytest.mark.parametrize( + "spec,fragment", + [ + ("nocolon", 'expected "VIRTUAL:HOST[:ro]"'), + (":/host", "non-empty"), + ("/virt:", "non-empty"), + ], + ) + def test_invalid_mount_specs_report_the_core_message(self, spec, fragment): with pytest.raises(PolicyError) as excinfo: - policy_from_dict({"filesystem": {"mount": ["/work:/host:rw"]}}) - message = str(excinfo.value) - assert "read-only" not in message, message - assert "remove it" in message - - def test_mount_suffix_error_names_spec_and_remedy(self): + policy_from_toml(f'[filesystem]\nmount = ["{spec}"]\n') + assert fragment in str(excinfo.value) + + +class TestCoreParity: + """The SDK must not have a second opinion about the profile grammar.""" + + @pytest.mark.parametrize( + "size,expected", + [("512M", 512 * 1024 ** 2), ("1G", 1024 ** 3), ("512", 512), ("0", 0)], + ) + def test_sizes_resolve_the_way_core_resolves_them(self, size, expected): + p = policy_from_toml(f'[limits]\nmemory = "{size}"\n') + assert p.max_memory == expected + + @pytest.mark.parametrize( + "size,fragment", + [ + # The SDK's own size parser used to accept both of these, so a + # profile could load through the SDK and fail in the CLI. + ("1.5G", "invalid byte size: 1.5G"), + ("1T", "unknown byte size suffix: T"), + ("17179869184G", "out of range"), + ("512B", "unknown byte size suffix: B"), + ], + ) + def test_sizes_core_rejects_are_rejected_here(self, size, fragment): with pytest.raises(PolicyError) as excinfo: - policy_from_dict({"filesystem": {"mount": ["/work:/host:ro"]}}) - message = str(excinfo.value) - assert "'/work:/host:ro'" in message - # The profile is often one the CLI itself wrote, so the remedy is - # to run it with the CLI, not to retype it as a flag. - assert "sandlock run --profile-file " in message - assert "sandlock run -p " in message - - @pytest.mark.parametrize("spec", ["/work:/host:ro", "/work:/host:rw"]) - def test_mount_suffix_error_suggests_a_runnable_command(self, spec): - # Both flags live on the `run` subcommand (RunArgs in - # crates/sandlock-cli/src/main.rs), not on the top-level parser: - # `sandlock --profile-file p.toml` exits 2 with "unexpected - # argument". A loud rejection that routes the user to a command - # which cannot run is not a remedy, so the suggestion must always - # carry the subcommand. + policy_from_toml(f'[limits]\nmemory = "{size}"\n') + assert fragment in str(excinfo.value) + + def test_disk_size_uses_the_same_grammar(self): + assert policy_from_toml('[limits]\ndisk = "1G"\n').max_disk == 1024 ** 3 + with pytest.raises(PolicyError): + policy_from_toml('[limits]\ndisk = "1.5G"\n') + + def test_rfc3339_time_start_resolves_to_epoch_seconds(self): + # The SDK used to call int() on the raw string here, so an RFC 3339 + # stamp (the only form the CLI accepts) raised ValueError. + p = policy_from_toml('[determinism]\ntime_start = "2026-01-01T00:00:00Z"\n') + assert p.time_start == 1767225600 + + def test_time_start_honours_the_offset(self): + p = policy_from_toml( + '[determinism]\ntime_start = "2026-01-01T00:00:00+03:00"\n' + ) + assert p.time_start == 1767225600 - 3 * 3600 + + def test_time_start_keeps_sub_second_precision(self): + p = policy_from_toml( + '[determinism]\ntime_start = "2026-01-01T00:00:00.25Z"\n' + ) + assert p.time_start == 1767225600.25 + + def test_pre_epoch_time_start_stays_negative(self): + p = policy_from_toml( + '[determinism]\ntime_start = "1969-12-31T23:59:59.5Z"\n' + ) + assert p.time_start == -0.5 + + def test_naive_time_start_is_rejected(self): + # A binding that assumed UTC here would disagree with the CLI about + # what the profile means. with pytest.raises(PolicyError) as excinfo: - policy_from_dict({"filesystem": {"mount": [spec]}}) - message = str(excinfo.value) - quoted = re.findall(r"'(sandlock[^']*)'", message) - assert quoted, f"no quoted sandlock invocation in {message!r}" - for invocation in quoted: - assert invocation.startswith("sandlock run "), message + policy_from_toml('[determinism]\ntime_start = "2026-01-01T00:00:00"\n') + assert "offset" in str(excinfo.value) + + def test_bare_unix_seconds_in_time_start_are_rejected(self): + with pytest.raises(PolicyError): + policy_from_toml('[determinism]\ntime_start = "1767225600"\n') + + def test_scheme_less_net_rule_expands_to_both_protocols(self): + # Core turns one profile entry into one rule per protocol; the SDK + # forwards what core produced instead of the original string. + p = policy_from_toml('[network]\nallow = ["example.com:443"]\n') + assert list(p.net_allow) == [ + "tcp://example.com:443", + "udp://example.com:443", + ] + + def test_ipv6_net_rule_keeps_the_bracket_form(self): + p = policy_from_toml('[network]\nallow = ["tcp://[fc00::/7]:443"]\n') + assert list(p.net_allow) == ["tcp://[fc00::/7]:443"] + + def test_http_rule_is_normalized_by_core(self): + p = policy_from_toml('[http]\nallow = ["get Example.COM/v1//a/../b/"]\n') + assert list(p.http_allow) == ["GET Example.COM/v1/b"] + + def test_bind_port_ranges_are_expanded_sorted_and_deduplicated(self): + p = policy_from_toml( + '[network]\nallow_bind = [9001, "9000-9002", "8080,8080"]\n' + ) + assert list(p.net_allow_bind) == [8080, 9000, 9001, 9002] + + def test_bind_port_wildcard_survives(self): + p = policy_from_toml('[network]\nallow_bind = ["*"]\n') + assert list(p.net_allow_bind) == ["*"] + + @pytest.mark.parametrize( + "profile,fragment", + [ + ('[network]\nallow_bind = ["90-80"]\n', "reversed port range"), + ('[network]\ndeny_bind = ["*"]\n', "only supported for"), + ('[network]\nallow = ["example.com:0"]\n', "port 0 is not valid"), + ('[network]\ndeny = ["example.com"]\n', "hostnames are not allowed"), + ('[filesystem]\non_exit = "COMMIT"\n', "invalid branch action"), + ('[syscalls]\nextra_allow = ["read"]\n', "unknown syscall group name"), + ], + ) + def test_other_grammars_report_the_core_message(self, profile, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(profile) + assert fragment in str(excinfo.value) + + @pytest.mark.parametrize( + "profile,fragment", + [ + # Cross-section checks live in the builder, not in the schema. + # They still have to fire when a profile is loaded. + ("[limits]\ncpu = 0\n", "max_cpu must be 1-100"), + ("[limits]\nopen_files = 0\n", "greater than 0"), + ("[program]\nuid = 1000\n", "must both be set"), + ( + '[network]\nallow = ["1.2.3.4"]\ndeny = ["5.6.7.8"]\n', + "mutually exclusive", + ), + ], + ) + def test_cross_section_checks_run_at_load_time(self, profile, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(profile) + assert fragment in str(excinfo.value) + + @pytest.mark.parametrize( + "profile,message", + [ + ( + '[limits]\nmemory = "1.5G"\n', + "sandbox error: invalid sandbox: invalid byte size: 1.5G", + ), + ( + '[filesystem]\nmount = ["nocolon"]\n', + "sandbox error: invalid sandbox: invalid mount spec " + '"nocolon"; expected "VIRTUAL:HOST[:ro]"', + ), + ( + "[limits]\ncpu = 0\n", + "sandbox error: max_cpu must be 1-100, got 0", + ), + ], + ) + def test_the_message_is_the_core_message_and_nothing_else( + self, profile, message + ): + # Whole-string equality on purpose: an SDK-side prefix, suffix or + # reword is exactly what this pins down. The core message is what a + # CLI user sees for the same profile. + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(profile) + assert str(excinfo.value) == message + + @pytest.mark.parametrize( + "profile", + [ + "[bogus]\nx = 1\n", + "[program]\nbogus = 1\n", + 'fs_readable = ["/usr"]\n', + "[limits]\ncpu = 300\n", + "[determinism]\ntime_start = 1767225600\n", + "[filesystem]\nmount = 1\n", + ], + ) + def test_schema_errors_reach_the_caller_unchanged(self, profile): + # The mapping layer must not swallow, reclassify or re-wrap what the + # export raised on its way out. + with pytest.raises(PolicyError) as from_core: + profile_parse(profile) + with pytest.raises(PolicyError) as from_sdk: + policy_from_toml(profile) + assert str(from_sdk.value) == str(from_core.value) + + def test_invalid_toml_is_reported_by_the_core_parser(self): + # Wording specific to core's TOML reader: a Python-side reader would + # phrase this differently, and there is no longer one. + with pytest.raises(PolicyError) as excinfo: + policy_from_toml("not valid [[[toml") + assert "TOML parse error" in str(excinfo.value) + + def test_profile_text_with_a_nul_byte_is_refused(self): + # The C ABI takes a NUL-terminated string; truncating at the NUL + # would parse a prefix of the profile and call it valid. + with pytest.raises(PolicyError) as excinfo: + policy_from_toml('[limits]\ncpu = 50\n\x00[network]\n') + assert "NUL" in str(excinfo.value) + - def test_mount_without_suffix_still_parses(self): - # Control: the rejection must not touch ordinary specs. - p = policy_from_dict({"filesystem": {"mount": ["/work:/host"]}}) - assert p.fs_mount == {"/work": "/host"} +class TestCanonicalDrift: + """Unknown-key checking on the SDK side, so a schema change is loud.""" - def test_mount_colon_in_host_without_suffix_still_parses(self): - # Only a trailing ':ro'/':rw' is refused: inner colons belong to the - # host path (core splits on the first colon), and ':root' is not ':ro'. - p = policy_from_dict({ - "filesystem": {"mount": ["/v:/a:b", "/v2:/host:root"]}, - }) - assert p.fs_mount == {"/v": "/a:b", "/v2": "/host:root"} + def _canonical(self) -> dict: + return profile_parse('[limits]\nmemory = "512M"\n') + + def test_unknown_section_in_the_canonical_form_is_an_error(self): + canonical = self._canonical() + canonical["bogus"] = {} + with pytest.raises(PolicyError, match="out of sync"): + _from_canonical(canonical) + + def test_unknown_field_in_a_section_is_an_error(self): + canonical = self._canonical() + canonical["limits"]["bogus"] = 1 + with pytest.raises(PolicyError, match="out of sync"): + _from_canonical(canonical) + + def test_a_field_that_disappears_is_an_error(self): + canonical = self._canonical() + del canonical["limits"]["memory"] + with pytest.raises(PolicyError, match="out of sync"): + _from_canonical(canonical) + + def test_a_mount_that_loses_its_read_only_flag_is_an_error(self): + canonical = profile_parse('[filesystem]\nmount = ["/w:/h:ro"]\n') + del canonical["filesystem"]["mount"][0]["ro"] + with pytest.raises(PolicyError, match="out of sync"): + _from_canonical(canonical) + + def test_a_rule_without_a_spec_is_an_error(self): + canonical = profile_parse('[network]\nallow = ["tcp://example.com:443"]\n') + del canonical["network"]["allow"][0]["spec"] + with pytest.raises(PolicyError, match="out of sync"): + _from_canonical(canonical) class TestLoadProfilePath: @@ -278,6 +483,7 @@ def test_load_valid_toml(self, tmp_path): [filesystem] read = ["/usr", "/lib"] write = ["/tmp/work"] + mount = ["/work:/srv/work:ro"] [program] clean_env = true @@ -289,20 +495,28 @@ def test_load_valid_toml(self, tmp_path): p = load_profile_path(profile) assert p.fs_readable == ["/usr", "/lib"] assert p.fs_writable == ["/tmp/work"] + assert list(p.fs_mount) == [Mount("/work", "/srv/work", ro=True)] assert p.clean_env is True assert p.env == {"CC": "gcc"} - assert p.max_memory == "256M" + assert p.max_memory == 256 * 1024 ** 2 - def test_invalid_toml_raises(self, tmp_path): + def test_missing_file_names_the_path(self, tmp_path): + with pytest.raises(PolicyError, match="nope.toml"): + load_profile_path(tmp_path / "nope.toml") + + def test_parse_error_names_the_file_and_keeps_the_core_message(self, tmp_path): profile = tmp_path / "bad.toml" - profile.write_text("not valid [[[toml") - with pytest.raises(PolicyError, match="invalid TOML"): + profile.write_text('[limits]\nmemory = "1.5G"\n') + with pytest.raises(PolicyError) as excinfo: load_profile_path(profile) + message = str(excinfo.value) + assert str(profile) in message + assert "invalid byte size: 1.5G" in message def test_unknown_section_in_file_raises(self, tmp_path): profile = tmp_path / "bad.toml" profile.write_text("[typo]\n") - with pytest.raises(PolicyError, match="unknown section"): + with pytest.raises(PolicyError, match="unknown field"): load_profile_path(profile) def test_old_flat_format_rejected(self, tmp_path): @@ -310,7 +524,7 @@ def test_old_flat_format_rejected(self, tmp_path): # rejected (sectioned schema only). Pre-1.0 hard break. profile = tmp_path / "old.toml" profile.write_text('fs_readable = ["/usr"]\n') - with pytest.raises(PolicyError, match="unknown section"): + with pytest.raises(PolicyError, match="unknown field"): load_profile_path(profile) @@ -319,7 +533,7 @@ def test_list_profiles(self, tmp_path, monkeypatch): import sandlock._profile as mod monkeypatch.setattr(mod, "_PROFILES_DIR", tmp_path) - (tmp_path / "build.toml").write_text("[program]\nuid = 0\n") + (tmp_path / "build.toml").write_text("[program]\nuid = 0\ngid = 0\n") (tmp_path / "dev.toml").write_text("[program]\nclean_env = true\n") (tmp_path / "not-toml.txt").write_text("ignored") @@ -338,9 +552,9 @@ def test_list_profiles_no_dir(self, tmp_path, monkeypatch): class TestMergeCliOverrides: def test_scalar_override(self): - base = Sandbox(max_memory="256M", uid=0) - result = merge_cli_overrides(base, {"max_memory": "1G"}) - assert result.max_memory == "1G" + base = Sandbox(max_memory=256 * 1024 ** 2, uid=0, gid=0) + result = merge_cli_overrides(base, {"max_memory": 1024 ** 3}) + assert result.max_memory == 1024 ** 3 assert result.uid == 0 # unchanged def test_list_append(self): @@ -353,6 +567,11 @@ def test_bool_override(self): result = merge_cli_overrides(base, {"clean_env": True}) assert result.clean_env is True + def test_overrides_compose_with_a_loaded_profile(self): + base = policy_from_toml('[filesystem]\nread = ["/usr"]\n') + result = merge_cli_overrides(base, {"fs_readable": ["/etc"]}) + assert result.fs_readable == ["/usr", "/etc"] + def test_profiles_dir_is_a_path(): assert profiles_dir().is_absolute() or str(profiles_dir()).startswith("~") diff --git a/python/tests/test_profile_abi_edge_cases.py b/python/tests/test_profile_abi_edge_cases.py new file mode 100644 index 00000000..c57bece3 --- /dev/null +++ b/python/tests/test_profile_abi_edge_cases.py @@ -0,0 +1,304 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Hostile input and raw-ABI edge cases for profile parsing. + +``test_profile.py`` covers what a profile means. This covers what happens when +it is malformed, or when the export is called the way a hand-written binding +might call it rather than the way :mod:`sandlock._sdk` does. Two things are at +stake once TOML parsing moves behind a C ABI: a caller must never be told +"failed" with nothing else, and no value may be quietly shortened on the way +across, because a shortened host or path is a policy nobody wrote. +""" + +from __future__ import annotations + +import ctypes + +import pytest + +from sandlock._profile import policy_from_toml +from sandlock._sdk import _NativePolicy, _lib, profile_parse +from sandlock.exceptions import PolicyError + + +VALID = b'[limits]\nmemory = "1G"\n' +INVALID = b"[limits]\nbogus = 1\n" +# A pointer no allocator will return, so "was this written at all?" is +# answerable rather than being confused with "was written null". +POISON = ctypes.c_char_p(b"poison-sentinel") + + +def _strings(node): + """Every string anywhere in a canonical document, keys included.""" + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for key, value in node.items(): + yield key + yield from _strings(value) + elif isinstance(node, list): + for item in node: + yield from _strings(item) + + +def _raw_call(toml, pass_err=True, pass_err_msg=True): + """Call the export directly, bypassing the SDK's own argument handling.""" + err = ctypes.c_int(7) + err_msg = ctypes.c_char_p(POISON.value) + ptr = _lib.sandlock_profile_parse( + toml, + ctypes.byref(err) if pass_err else None, + ctypes.byref(err_msg) if pass_err_msg else None, + ) + result = ctypes.string_at(ptr) if ptr else None + if ptr: + _lib.sandlock_string_free(ctypes.cast(ptr, ctypes.c_char_p)) + msg = err_msg.value + if pass_err_msg and msg is not None and msg != POISON.value: + _lib.sandlock_string_free(err_msg) + return result, err.value, msg + + +class TestRawAbi: + """Called straight through ctypes, with the out-parameters varied.""" + + @pytest.mark.parametrize("pass_err", [True, False]) + @pytest.mark.parametrize("pass_err_msg", [True, False]) + @pytest.mark.parametrize( + "toml,want_json,want_msg", + [ + # A null profile is a bug in the calling binding, not a bad + # profile, so it reports the failure without a diagnosis to + # attribute to the user's file. + (None, False, False), + (VALID, True, False), + (INVALID, False, True), + ], + ids=["null", "valid", "invalid"], + ) + def test_every_out_param_combination( + self, toml, want_json, want_msg, pass_err, pass_err_msg + ): + result, err, msg = _raw_call(toml, pass_err, pass_err_msg) + + assert (result is not None) is want_json + if pass_err: + assert err == (0 if want_json else -1) + else: + assert err == 7, "err was written through a null pointer" + + if pass_err_msg: + assert msg != POISON.value, "err_msg was never written" + assert (msg is not None) is want_msg + else: + assert msg == POISON.value, "err_msg was written through a null pointer" + + def test_a_null_return_always_means_failure(self): + # The documented contract, and the only one a caller that passed null + # for both out-parameters can rely on. + assert _raw_call(VALID, False, False)[0] is not None + assert _raw_call(INVALID, False, False)[0] is None + + def test_invalid_utf8_is_reported_not_read_as_a_shorter_profile(self): + # A lossy decode would silently drop the offending byte and hand back + # a profile the file does not contain. + result, err, msg = _raw_call(b'[program]\nexec = "/bin/\xff"\n') + assert result is None + assert err == -1 + assert b"utf-8" in msg + + def test_string_free_accepts_null(self): + _lib.sandlock_string_free(None) + + +class TestNulBytes: + """The one byte a C string cannot carry, on both the value and the + diagnosis path.""" + + def test_a_nul_in_the_profile_text_is_refused_before_the_call(self): + # Passing it on would truncate the file at the NUL and validate a + # prefix, reporting a policy the user never wrote as valid. + with pytest.raises(PolicyError, match="NUL"): + profile_parse('[limits]\nmemory = "1G"\n\x00[filesystem]\nread = ["/"]\n') + + @pytest.mark.parametrize( + "toml,fragment", + [ + # TOML decodes ``\u0000``, so the parser can end up quoting a NUL + # back at the user inside its own error message. Reporting the + # failure with no message at all leaves an SDK user with a bare + # exception and nothing to search for. + ('[limits]\nmemory = "1\\u0000G"\n', "1\\0G"), + ('[syscalls]\nextra_deny = ["re\\u0000ad"]\n', "re\\0ad"), + ('[limits]\n"bo\\u0000gus" = 1\n', "bo\\0gus"), + ], + ) + def test_a_nul_in_the_diagnosis_still_reaches_the_caller(self, toml, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(toml) + assert fragment in str(excinfo.value) + + @pytest.mark.parametrize( + "toml,shortened", + [ + # Each of these is a value the CLI would use whole. If the SDK + # forwarded it as a C string it would apply the part before the + # NUL: a different host, a different path, a different mount. + ('[network]\nallow = ["tcp://ex\\u0000ample.com"]\n', "tcp://ex"), + ('[filesystem]\nchroot = "/real\\u0000/decoy"\n', "/real"), + ('[filesystem]\nmount = ["/v:/host\\u0000/decoy"]\n', "/host"), + ('[http]\nallow = ["GET ex\\u0000ample.com"]\n', "GET ex"), + # Percent-decoding gets a NUL into an HTTP path with no TOML + # escape involved. + ('[http]\nallow = ["GET example.com/a%00b"]\n', "GET example.com/a"), + ], + ) + def test_a_nul_inside_a_value_fails_loudly_rather_than_shortening_it( + self, toml, shortened + ): + # It survives the canonical form intact, because JSON escapes it, and + # what follows the NUL is exactly what a silent truncation would drop. + carriers = [s for s in _strings(profile_parse(toml)) if "\x00" in s] + assert carriers, "the NUL did not survive into the canonical form" + assert any( + s.startswith(shortened) and len(s) > len(shortened) for s in carriers + ), f"expected a value longer than {shortened!r} in {carriers!r}" + + policy = policy_from_toml(toml) + # It is refused at the boundary that cannot represent it, and the + # refusal quotes the value so the user can find it. + with pytest.raises(ValueError, match="NUL"): + _NativePolicy.from_dataclass(policy) + + +class TestMalformedProfiles: + """Structure and type errors, reported with core's wording.""" + + def test_an_empty_profile_is_an_unconstrained_sandbox_not_an_error(self): + policy = policy_from_toml("") + assert policy.max_memory is None + assert policy.fs_readable == [] + assert policy_from_toml("# only a comment\n") == policy + assert policy_from_toml(" \n\t\n") == policy + + @pytest.mark.parametrize( + "toml,fragment", + [ + ("[bogus]\nx = 1\n", "unknown field `bogus`"), + ('memory = "1G"\n', "unknown field `memory`"), + ("[limits]\nbogus = 1\n", "unknown field `bogus`"), + ('[limits]\nmemory = "1G"\nmemory = "2G"\n', "duplicate key `memory`"), + ('[limits]\nmemory = "1G"\n[limits]\ncpu = 1\n', "duplicate key"), + ('[program.env]\nA = "1"\nA = "2"\n', "duplicate key `A`"), + ("[program\n", "TOML parse error"), + ], + ) + def test_structure_errors(self, toml, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(toml) + assert fragment in str(excinfo.value) + + @pytest.mark.parametrize( + "toml,fragment", + [ + # Wrong type in both directions. A parser that coerced would make + # these load here and fail in the CLI. + ('[limits]\ncpu = "1"\n', "expected u8"), + ("[limits]\nmemory = 1024\n", "expected a string"), + ("[determinism]\ntime_start = 1700000000\n", "expected a string"), + ('[determinism]\nrandom_seed = "5"\n', "expected u64"), + ('[program]\nclean_env = "true"\n', "expected a boolean"), + ('[filesystem]\nread = "/a"\n', "invalid type: string"), + ("[program]\nargs = [1, 2]\n", "invalid type: integer"), + ], + ) + def test_type_errors(self, toml, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(toml) + assert fragment in str(excinfo.value) + + @pytest.mark.parametrize( + "toml,fragment", + [ + ('[filesystem]\nmount = [""]\n', 'invalid mount spec ""'), + ('[limits]\nmemory = ""\n', "empty byte size string"), + ('[determinism]\ntime_start = ""\n', "[determinism].time_start"), + ('[filesystem]\non_exit = ""\n', "invalid branch action"), + ('[network]\nallow = [""]\n', "--net-allow: empty rule"), + ('[network]\nallow_bind = [""]\n', "--net-allow-bind: empty port"), + ('[http]\nallow = [""]\n', "invalid http rule"), + ('[syscalls]\nextra_allow = [""]\n', "unknown syscall group name"), + ], + ) + def test_an_empty_value_names_the_grammar_that_rejected_it(self, toml, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(toml) + assert fragment in str(excinfo.value) + + @pytest.mark.parametrize( + "toml,fragment", + [ + # Sizes and ports are the fields with a signed spelling and an + # unsigned destination, so they are where a silent wrap would live. + ('[limits]\nmemory = "-1"\n', "invalid byte size: -1"), + ('[limits]\nmemory = "18446744073709551616"\n', "invalid byte size"), + ('[limits]\nmemory = "17179869184G"\n', "byte size out of range"), + ("[limits]\nprocesses = -1\n", "invalid value"), + ("[limits]\nprocesses = 4294967296\n", "invalid value"), + ("[program]\nuid = -1\n", "invalid value"), + ("[http]\nports = [65536]\n", "invalid value"), + ('[network]\nallow_bind = ["65536"]\n', "invalid port `65536`"), + ('[network]\nallow_bind = ["-1"]\n', "invalid port range `-1`"), + ('[network]\nallow = ["tcp://example.com:65536"]\n', "invalid port `65536`"), + ], + ) + def test_a_number_outside_its_range_is_rejected(self, toml, fragment): + with pytest.raises(PolicyError) as excinfo: + policy_from_toml(toml) + assert fragment in str(excinfo.value) + + def test_the_largest_legal_values_still_load(self): + # The range checks above would also be satisfied by a parser that + # rejected everything. + assert policy_from_toml('[limits]\nmemory = "18446744073709551615"\n').max_memory == 2 ** 64 - 1 + assert policy_from_toml('[limits]\nmemory = "0"\n').max_memory == 0 + assert policy_from_toml("[limits]\ncpu = 100\n").max_cpu == 100 + assert policy_from_toml('[network]\nallow_bind = ["0-65535"]\n').net_allow_bind[-1] == 65535 + + def test_a_very_long_value_is_not_clipped(self): + path = "/" + "a" * 200_000 + policy = policy_from_toml(f'[filesystem]\nread = ["{path}"]\n') + assert policy.fs_readable == [path] + + +def _rss_kb() -> int: + with open("/proc/self/status") as fh: + for line in fh: + if line.startswith("VmRSS:"): + return int(line.split()[1]) + raise RuntimeError("VmRSS not reported") + + +def test_the_returned_strings_are_actually_released(): + """Both the JSON and the message are the caller's to free, so a caller that + frees them must not grow. + + Sized so the answer is not a judgement call: each iteration hands back + about 28 KB, and skipping the free calls grows this process by tens of + megabytes over the same loop, which is an order of magnitude above the + threshold below and two above what a correct run uses. The exact figures + are host and allocator dependent; the separation is not. + """ + # ~2048 expanded ports per call, so the result is large enough to see. + big = '[network]\nallow_bind = ["0-2047"]\n' + bad = '[limits]\nmemory = "1.5G"\n' + + for _ in range(20): # let the allocator reach a steady state first + profile_parse(big) + before = _rss_kb() + for _ in range(800): + profile_parse(big) + with pytest.raises(PolicyError): + profile_parse(bad) + growth = _rss_kb() - before + + assert growth < 4096, f"grew {growth} kB over 800 parses; expected roughly none" diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index e78ea1b0..08c34c1b 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -108,7 +108,7 @@ def test_limit_not_charged_for_exec_heap_distance(self): hoard = [bytes(4096) for _ in range(3000)] try: for i in range(8): - r = _policy(fs_writable=["/tmp"], max_memory="64M").run( + r = _policy(fs_writable=["/tmp"], max_memory=64 * 1024 ** 2).run( [sys.executable, "-c", "print('HELLO')"], timeout=15 ) assert r.success and b"HELLO" in r.stdout, ( @@ -142,7 +142,7 @@ def test_hard_exiting_children_do_not_exhaust_the_budget(self): " print(i, r.returncode, r.stdout.strip().decode(), flush=True)\n" ) result = _policy( - fs_writable=["/tmp"], max_memory="256M", max_processes=32 + fs_writable=["/tmp"], max_memory=256 * 1024 ** 2, max_processes=32 ).run([sys.executable, "-c", driver], timeout=90) lines = [ln.split() for ln in result.stdout.decode().splitlines() if ln] @@ -183,7 +183,7 @@ def test_file_map_unmap_cannot_launder_the_budget(self, tmp_dir): result = _policy( fs_readable=[*_PYTHON_READABLE, str(tmp_dir)], fs_writable=["/tmp", str(tmp_dir)], - max_memory="128M", + max_memory=128 * 1024 ** 2, ).run([sys.executable, "-c", prog], timeout=60) assert b"anon-ok" in result.stdout, result.stdout @@ -214,7 +214,7 @@ def test_grandchild_over_the_limit_is_killed(self): "print('PARENT-ALIVE', flush=True)\n" ) result = _policy( - fs_writable=["/tmp"], max_memory="128M", max_processes=32 + fs_writable=["/tmp"], max_memory=128 * 1024 ** 2, max_processes=32 ).run([sys.executable, "-c", prog], timeout=60) out = result.stdout.decode() @@ -795,8 +795,9 @@ class TestNewPolicyFields: def test_time_start(self): from datetime import datetime, timezone - # Freeze time to 2000-06-15 - t = datetime(2000, 6, 15, tzinfo=timezone.utc) + # Freeze time to 2000-06-15. time_start is epoch seconds; an aware + # datetime converts without a second timestamp grammar in the SDK. + t = datetime(2000, 6, 15, tzinfo=timezone.utc).timestamp() p = _policy(time_start=t) result = p.run(["date", "+%Y"]) assert result.success @@ -915,7 +916,7 @@ def test_cow_copy_within_quota(self, tmp_path): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), - max_disk="1M", + max_disk=1024 ** 2, ) # Opening for write triggers COW copy of the 5-byte file. result = p.run( @@ -932,7 +933,7 @@ def test_cow_copy_exceeds_quota(self, tmp_path): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), - max_disk="1K", # 1024 bytes — smaller than the 8 KiB file + max_disk=1024, # smaller than the 8 KiB file ) # Trying to open big.bin for write triggers COW copy → ENOSPC. result = p.run( @@ -950,7 +951,7 @@ def test_cumulative_cow_copies_exceed_quota(self, tmp_path): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), - max_disk="1000", + max_disk=1000, ) # First open succeeds (600 <= 1000), second fails (600+600 > 1000). result = p.run( @@ -967,7 +968,7 @@ def test_enospc_in_stderr(self, tmp_path): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), - max_disk="512", + max_disk=512, ) result = p.run( ["sh", "-c", f"echo x >> {workdir}/big.bin 2>&1"] @@ -998,18 +999,18 @@ def test_quota_dry_run_enforced(self, tmp_path): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), - max_disk="1K", + max_disk=1024, ) result = p.dry_run( ["sh", "-c", f"echo x >> {workdir}/big.bin"] ) assert not result.success - def test_quota_accepts_various_units(self, tmp_path): - """String sizes like '1G', '512M', '100K' are accepted.""" + def test_quota_accepts_various_sizes(self, tmp_path): + """A range of byte counts is accepted.""" workdir = tmp_path / "units" workdir.mkdir() - for size in ("100K", "10M", "1G"): + for size in (100 * 1024, 10 * 1024 ** 2, 1024 ** 3): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), @@ -1026,7 +1027,7 @@ def test_read_does_not_consume_quota(self, tmp_path): p = _policy( fs_writable=[str(workdir)], workdir=str(workdir), - max_disk="100", # tiny quota + max_disk=100, # tiny quota ) result = p.run( ["cat", f"{workdir}/big.bin"] @@ -1067,7 +1068,7 @@ def test_fs_storage_with_quota(self, tmp_path): fs_writable=[str(workdir)], workdir=str(workdir), fs_storage=str(storage), - max_disk="512", + max_disk=512, ) result = p.run( ["sh", "-c", f"echo x >> {workdir}/big.bin"] diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index f8f44eef..7c9c82a5 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -5,45 +5,56 @@ import pytest +import sandlock.sandbox as sandbox_module +from sandlock._sdk import _bytes_limit, _epoch_seconds from sandlock.sandbox import ( + Mount, Sandbox, - parse_memory_size, parse_ports, ) -class TestParseMemorySize: - def test_plain_bytes(self): - assert parse_memory_size("1024") == 1024 +class TestNoSecondGrammar: + """The profile grammars live in the core parser, and only there. - def test_kilobytes(self): - assert parse_memory_size("100K") == 100 * 1024 + Every helper named here used to be a second implementation of a grammar + the core already owns, and each one disagreed with it somewhere: sizes + accepted ``'1.5G'``/``'1T'`` that the core rejects, and the timestamp + helper read a naive stamp as UTC while the core requires an offset. + """ - def test_megabytes(self): - assert parse_memory_size("512M") == 512 * 1024 ** 2 + def test_size_grammar_is_gone(self): + assert not hasattr(sandbox_module, "parse_memory_size") + assert not hasattr(Sandbox, "memory_bytes") - def test_gigabytes(self): - assert parse_memory_size("1G") == 1024 ** 3 + def test_timestamp_grammar_is_gone(self): + assert not hasattr(Sandbox, "time_start_timestamp") - def test_terabytes(self): - assert parse_memory_size("2T") == 2 * 1024 ** 4 + @pytest.mark.parametrize("field", ["max_memory", "max_disk"]) + def test_size_strings_are_refused_at_construction(self, field): + with pytest.raises(TypeError, match="integer number of bytes"): + Sandbox(**{field: "512M"}) - def test_case_insensitive(self): - assert parse_memory_size("512m") == 512 * 1024 ** 2 + def test_time_start_strings_are_refused_at_construction(self): + with pytest.raises(TypeError, match="epoch seconds"): + Sandbox(time_start="2026-01-01T00:00:00Z") - def test_fractional(self): - assert parse_memory_size("1.5G") == int(1.5 * 1024 ** 3) - def test_whitespace(self): - assert parse_memory_size(" 512M ") == 512 * 1024 ** 2 +class TestFsMountField: + def test_mount_entries(self): + p = Sandbox(fs_mount=[Mount("/work", "/host"), Mount("/ro", "/h", ro=True)]) + assert p.fs_mount[0].ro is False + assert p.fs_mount[1].ro is True - def test_invalid(self): - with pytest.raises(ValueError): - parse_memory_size("not_a_size") + def test_mapping_is_refused(self): + # The old representation was dict[virt, host], which had no channel + # for the read-only flag at all. + with pytest.raises(TypeError, match="not a mapping"): + Sandbox(fs_mount={"/work": "/host"}) - def test_empty(self): - with pytest.raises(ValueError): - parse_memory_size("") + def test_non_mount_entries_are_refused(self): + with pytest.raises(TypeError, match="must be Mount"): + Sandbox(fs_mount=[("/work", "/host")]) class TestEnsureNative: @@ -84,21 +95,9 @@ def test_defaults(self): def test_mutable_config(self): # Sandbox is no longer frozen — it holds runtime state too. - p = Sandbox(max_memory="512M") - p.max_memory = "1G" - assert p.max_memory == "1G" - - def test_memory_bytes_string(self): - p = Sandbox(max_memory="512M") - assert p.memory_bytes() == 512 * 1024 ** 2 - - def test_memory_bytes_int(self): - p = Sandbox(max_memory=1024) - assert p.memory_bytes() == 1024 - - def test_memory_bytes_none(self): - p = Sandbox() - assert p.memory_bytes() is None + p = Sandbox(max_memory=512 * 1024 ** 2) + p.max_memory = 1024 ** 3 + assert p.max_memory == 1024 ** 3 def test_cpu_pct(self): p = Sandbox(max_cpu=50) @@ -118,20 +117,15 @@ def test_default_none(self): p = Sandbox() assert p.max_disk is None - def test_string_value(self): - p = Sandbox(max_disk="1G") - assert p.max_disk == "1G" + def test_byte_value(self): + p = Sandbox(max_disk=1024 ** 3) + assert p.max_disk == 1024 ** 3 def test_mutable_config(self): # Sandbox is no longer frozen — it holds runtime state too. - p = Sandbox(max_disk="512M") - p.max_disk = "1G" - assert p.max_disk == "1G" - - def test_parse_memory_size_for_disk(self): - assert parse_memory_size("1G") == 1024 ** 3 - assert parse_memory_size("512M") == 512 * 1024 ** 2 - assert parse_memory_size("100K") == 100 * 1024 + p = Sandbox(max_disk=512 * 1024 ** 2) + p.max_disk = 1024 ** 3 + assert p.max_disk == 1024 ** 3 class TestParsePorts: @@ -256,3 +250,41 @@ def test_specs_preserved_as_strings(self): p = Sandbox(net_deny=["10.0.0.0/8", "169.254.169.254:80", "udp://*"]) assert list(p.net_deny) == ["10.0.0.0/8", "169.254.169.254:80", "udp://*"] + + +class TestBuilderBoundary: + """Values the C ABI setters cannot carry are refused, not truncated. + + ``sandlock_sandbox_builder_time_start`` takes whole non-negative epoch + seconds while the profile grammar accepts pre-epoch and fractional + stamps, so those two cases have to fail loudly: passing them on would + make the same profile mean one thing through the CLI and another here, + and a negative value would wrap to a date in the far future. + """ + + def test_whole_epoch_seconds_pass(self): + assert _epoch_seconds(1767225600) == 1767225600 + assert _epoch_seconds(1767225600.0) == 1767225600 + + def test_pre_epoch_time_start_is_refused(self): + with pytest.raises(ValueError, match="before the Unix epoch"): + _epoch_seconds(-0.5) + + def test_sub_second_time_start_is_refused(self): + with pytest.raises(ValueError, match="sub-second"): + _epoch_seconds(1767225600.25) + + def test_non_numeric_time_start_is_refused(self): + with pytest.raises(TypeError, match="epoch seconds"): + _epoch_seconds("1767225600") + + def test_byte_limits_must_be_integers(self): + assert _bytes_limit(512, "max_memory") == 512 + with pytest.raises(TypeError, match="integer number of bytes"): + _bytes_limit(1.5, "max_memory") + + def test_byte_limits_must_fit_the_abi(self): + with pytest.raises(ValueError, match="out of range"): + _bytes_limit(2 ** 64, "max_memory") + with pytest.raises(ValueError, match="out of range"): + _bytes_limit(-1, "max_disk")