From 59e811dc99030083fc963cdfb5a5689d1c6503db Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 3 Aug 2026 11:37:26 +0300 Subject: [PATCH 1/2] feat: parse profiles in the core, and have the Python SDK stop parsing Closes the second half of #174. The SDK carried its own TOML parser and its own grammars, and they had already diverged from the core in two places you found: `parse_memory_size` accepted fractions and a `T` suffix that `ByteSize::parse` rejects, and `time_start` went through `int()`, so an RFC 3339 stamp worked in the CLI and raised through the SDK. A third grammar sat unused in the dataclass, `time_start_timestamp`, with naive-means-UTC semantics. `sandlock_profile_parse` takes TOML text and returns canonical JSON with every micro-grammar already resolved: mounts as `{virt, host, ro}` objects, sizes as integer bytes, `time_start` as epoch seconds. The SDK's remaining job is a field-for-field copy into its dataclass, so introspection, `dataclasses.replace` and preset composition keep working. Unknown keys are rejected on both sides, so future drift fails at load time instead of mis-parsing silently. `sandbox_to_json` was not reusable as-is: it re-emits mounts as `V:H:ro` spec strings, which would have put string parsing straight back into the SDK. The canonical form emits structured mounts instead. Its `ro` is the effective setting for the virtual path, not the flag written on one spec: the core keys read-only mounts by virtual path (`Sandbox::fs_mount_ro` is a list of virtual paths), so two specs sharing a virtual path share one verdict, and reporting the written flag would describe a policy no layer applies. Public Python API changes, deliberately and without shims: max_memory: str | int | None -> int | None max_disk: str | None -> int | None time_start: float | str | None -> float | None fs_mount: Mapping[str, str] -> Sequence[Mount] The `time_start` row understates one break, because the annotation did: python/README.md documented the field as `datetime | float | str | None`, and the builder duck-typed anything carrying a `.timestamp()`. A `datetime` is therefore refused from here on, and an aware one needs that call spelled out. A naive one never had a defined meaning on this field anyway, since it was read in whatever zone the host happened to be in. `Mount(virt, host, ro)` is new and mirrors the canonical field names. `fs_mount` becoming a sequence is what lets a read-only mount be expressed at all from Python; it reaches the C ABI through the `fs_mount_ro` setter added in #180. `tomli` is gone from the dependencies. The memory-accounting tests under python/tests/test_sandbox.py spell their ceilings as integer bytes, and the disk-quota tests next to them are converted here for the same reason; a size string is profile syntax and the core resolves it, so `max_memory` gets it back as a string only once the C ABI setter takes one, later in this series. Two more changes to the same surface, both consequences of the SDK no longer holding an opinion of its own: - `on_error` loaded from a profile now defaults to COMMIT where it defaulted to ABORT. The canonical form always resolves both branch actions, and the SDK copies what it is handed, so a profile that says nothing about the error path gets the core's answer rather than the dataclass's second opinion. Deliberate, since the CLI, a profile and the Go SDK have always meant COMMIT for that policy, but it changes what happens to a COW branch for a profile already in use, and it changes it silently. Only the profile path moves; the dataclass default is untouched here. - `parse_memory_size`, `Sandbox.memory_bytes()` and `Sandbox.time_start_timestamp()` are removed. The first two were the SDK's byte-size grammar and its accessor, the third the unused third grammar named above. Nothing replaces them: the resolved value is the field. One core change came out of this rather than the SDK: rebuilding a builder from a parsed profile ran `extend_net_allow_for_http` a second time over an allowlist that already held its derived entries, so the helper is now idempotent, with a test. An earlier revision of this commit also carried the `checked_mul` fix in `ByteSize::parse`. That landed on its own as 80ffbb6 and is no longer part of this diff; the tests here that pin `byte size out of range` on `memory = "17179869184G"` now ride on the merged fix. Verified against the CLI message for message on every grammar: the same profile loads identically, or fails identically, through both paths, with one gap left open and pinned rather than papered over. `sandlock_sandbox_builder_time_start` takes a `uint64` of seconds, so a stamp the core keeps in full loads from a profile and then cannot be handed to a builder: `"2026-01-01T00:00:00.5Z"` and any instant before 1970 are what that costs. The SDK refuses them by name instead of wrapping a negative value through an unsigned setter, and `test_time_start_the_c_abi_cannot_carry_is_refused_loudly` holds it there. Closing the gap means changing that setter's signature, which is a later commit in this series. --- README.md | 4 +- crates/sandlock-core/src/http.rs | 39 +- crates/sandlock-core/src/profile.rs | 33 +- crates/sandlock-core/src/profile/canonical.rs | 1162 +++++++++++++++++ crates/sandlock-core/src/sandbox.rs | 4 +- .../tests/profile_canonical_adversarial.rs | 444 +++++++ crates/sandlock-ffi/include/sandlock.h | 38 +- crates/sandlock-ffi/src/lib.rs | 103 +- crates/sandlock-ffi/tests/c/handler_smoke.c | 45 + crates/sandlock-ffi/tests/profile_parse.rs | 361 +++++ docs/sandbox-reference.md | 23 +- python/README.md | 28 +- python/examples/mcp_agent.py | 2 +- python/pyproject.toml | 1 - python/src/sandlock/__init__.py | 3 +- python/src/sandlock/_profile.py | 376 +++--- python/src/sandlock/_sdk.py | 115 +- python/src/sandlock/mcp/_policy.py | 2 +- python/src/sandlock/mcp/server.py | 2 +- python/src/sandlock/sandbox.py | 152 ++- python/tests/test_cli_parity.py | 677 ++++++++++ python/tests/test_fs_mount.py | 51 +- python/tests/test_mcp.py | 12 +- python/tests/test_policy_fn.py | 4 +- python/tests/test_profile.py | 647 ++++++--- python/tests/test_profile_abi_edge_cases.py | 304 +++++ python/tests/test_sandbox.py | 33 +- python/tests/test_sandbox_config.py | 132 +- 28 files changed, 4179 insertions(+), 618 deletions(-) create mode 100644 crates/sandlock-core/src/profile/canonical.rs create mode 100644 crates/sandlock-core/tests/profile_canonical_adversarial.rs create mode 100644 crates/sandlock-ffi/tests/profile_parse.rs create mode 100644 python/tests/test_cli_parity.py create mode 100644 python/tests/test_profile_abi_edge_cases.py 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") From 12d92239ca9cc454ccf5c8aae8a0e8da34c75ee2 Mon Sep 17 00:00:00 2001 From: dzerik Date: Mon, 3 Aug 2026 21:53:14 +0300 Subject: [PATCH 2/2] builder: latch a rejected setter argument instead of coercing it A builder setter returns Self, not Result, so it has no channel for a value the core cannot accept. The C ABI answered that by coercing. An on_exit discriminant with no variant became Commit through the fall-through arm of a match. An unrecognized protection discriminant was a documented no-op. Every string setter ran its argument through `to_str().unwrap_or("")`, except the two mount setters, which dropped the whole call instead. Each of those runs a configuration the caller never wrote, and says nothing while doing it. SandboxBuilder now carries a pending-error latch. `reject` records a reason a surface diagnosed itself, `reject_error` records one the core's own parser produced, and `build()` returns it instead of a Sandbox. The setter contract is otherwise untouched, which is why the bindings do not move: this commit changes nothing under go/ or python/src. The three python/tests files it does touch move because of the zero checks below, not because of binding work. Three decisions worth naming. The latch holds a String, not a SandboxError. SandboxBuilder is Clone and SandboxError is not; making it Clone would widen a public error type for the benefit of one private field. `reject_error` keeps the parser's own text rather than the wrapped Display, because build() puts the reason back into SandboxError::Invalid, so a value refused through the C ABI reads exactly as it reads on the command line instead of as a doubled "invalid sandbox: invalid sandbox: ...". The check sits in `build_unchecked`, not in `build`. `build_unchecked` is public and is what sandlock-oci calls (crates/sandlock-oci/src/policy.rs:463). A check in `build` alone would let the one caller that deliberately skips cross-section validation also skip the caller's own rejected input, which is not the invariant it asked to skip. Clone carries the latch. Dropping it there would make `.clone().build()` a laundering channel for a value the core has already refused. First write wins. The earliest bad input is the one that explains whatever follows it, so later rejections are dropped and the message names the caller's first mistake rather than its last. What now reports instead of coercing: - on_exit and on_error, on an unrecognized discriminant. BranchAction gains #[repr(u8)] with explicit discriminants and a `from_repr`, so the values the bindings pass as a u8 are a written-down contract rather than the fall-through arm of a match. Serde is unaffected: a data-less enum serializes by variant name, not by discriminant. - allow_degraded and disable, on an unrecognized protection. The no-op was documented, which meant a binding built against a newer header was told nothing when an older library did not recognize the protection it asked to be degradable: the caller believed it had opted out, and the protection stayed strict. - 22 string setters, through one `setter_arg` helper: a null pointer, and bytes that are not UTF-8. Those two stay the C ABI's own verdicts because they are representation problems the core cannot see once the value is a &str; the grammar's verdict still comes from the core untouched. `unwrap_or("")` is reachable without any bug in the caller, since a path read off readdir() is an arbitrary byte string on Linux, and the empty path it produces is a prefix of every guest path. The coercion survives only in the entry points that take no builder and so have nothing to latch a reason on. The three-argument setters report per half (`env_var key`, `fs_mount_ro host path`), so the message names the pointer to fix. fs_mount and fs_mount_ro are the two that had a coercion of their own shape: a private `mount_pair` helper answered "add no mount" for a null, non-UTF-8 or empty path, so the caller who asked for a read-only subtree got a writable one and the caller who asked for a host directory got nothing there. They go through the latch now, which is what makes the sentence above true of every `*const c_char` builder setter rather than of most of them. Zero and the empty set, in the same commit and for the same reason. The latch stops a surface from inventing a value the caller did not write; these stop a surface from having to invent a verdict the core would not give. Both have to be in place before a binding can be reduced to forwarding, and neither is visible in a binding's own diff. The max_open_files check that was already here said as much in its comment, which claimed a binding "must" filter zero itself; that comment is corrected here too, and corrected to what is true today rather than to what the series is heading for. Python already forwards whatever is not None, zero included. Go still filters (`if s.MaxOpenFiles > 0` in go/sandlock_linux.go), so a Go caller who writes zero still gets no cap and no diagnosis; reducing Go to forwarding needs its fields to spell "unset" without using the value, which is a later commit. - max_processes = 0: the supervisor compares proc_count >= limit, so a limit of zero denies every fork with EAGAIN no matter how few processes are alive, and the workload reads "Resource temporarily unavailable" from its first subprocess with nothing naming the setting. - num_cpus = 0: reaches the synthetic procfs as an empty /proc/cpuinfo and an affinity mask with no bits, so the guest reads nproc = 0. - max_memory = 0: zero is the sentinel the supervisor already carries for "no ceiling" (max_memory.map(..).unwrap_or(0) in Sandbox::run, read back as > 0 by the synthetic /proc/meminfo), but the memory handler is registered on is_some(). An explicit zero therefore installs a ceiling of zero and SIGKILLs the loader's first anonymous mmap while /proc/meminfo reports the sandbox unlimited. The two readings cannot both stand, and refusing the value is what lets the sentinel keep meaning "unset". The memory handler already states the invariant this check supplies: "this handler is only registered when that ceiling exists, so it is never the 0/unlimited sentinel" (crates/sandlock-core/src/resource.rs). Registration is on is_some(), so today an explicit zero is exactly the case that makes that sentence false. max_disk is deliberately not the same: zero is its documented spelling of "unlimited", and one reading is all it has. - cpu_cores = []: an affinity mask with no bits, which sched_setaffinity(2) refuses with EINVAL. confine_child skipped the call for an empty set instead, so the pinning the caller asked for silently did not happen and the sandbox ran on every core; that branch is deleted now that the value cannot reach it. Unlike gpu_devices, where an empty list is the spelling of "every device present", there is no cpu set an empty list could stand for, because "every core" is what omitting the field already means. - an empty virtual or host path in fs_mount and fs_mount_ro. This is the check that came back from the C ABI: `mount_pair` was making a policy judgement the core's own profile grammar already makes when it splits a VIRTUAL:HOST spec, and making it in the one place that could not report it. Neither half has a reading as "unset", and an empty virtual path is a prefix of every guest path, so ChrootCtx::is_mounted would match the whole tree and short-circuit can_read and can_write. Confinement::try_from listed on_exit and on_error among the fields a confinement cannot honour. A confinement has no branch to act on: it is applied in place, and fs_storage and workdir, the two knobs that create one, are already refused above it. The check only ever refused a field that could not have changed the outcome, and it did so by comparing against two hardcoded actions rather than against what build() resolves an unset field to, so a caller who said nothing about the error path was refused a confinement its policy allowed. The C ABI is unchanged: no signature, no struct and no discriminant value moves. include/sandlock.h changes by 171 lines (141 added, 30 removed) and every one of them is inside a comment block; with comment lines stripped the header is byte-identical to its parent. The added ones are the four new refusals written down where a binding author reads them: max_memory = 0, max_processes = 0, num_cpus = 0 and an empty cpu_cores are now in the doc comment of the setter that carries each, along with max_open_files = 0, which was already refused and had never been documented anywhere. The same rows in docs/sandbox-reference.md say the same thing. Tests: crates/sandlock-ffi/tests/builder_pending_error.rs covers the latch itself (both branch-action setters, survival across later valid calls, first-write-wins, Clone, build_unchecked, and null, non-UTF-8 and per-half arguments across every string setter). tests/fs_mount.rs had four tests pinning the drop-silently behaviour of the mount setters; they become one that pins the report, over both setters and all six unusable inputs, next to a narrowed case for the null builder, which has nothing to latch a reason on. In protection.rs the two tests that asserted the no-op now assert the report, and the third, which checked that a later valid call still took effect, becomes the first-write-wins case while keeping the memory-safety property it was really watching. sandbox/tests.rs covers the confinement change from both sides, and builder.rs covers `reject_error` directly: it has no caller yet, since the four that use it arrive with the string setters in a later commit, so the test drives it with a real ByteSize::parse error and asserts the built message is the parser's own text rather than a doubled wrapping. Closes #175. --- crates/sandlock-core/src/context.rs | 35 +- crates/sandlock-core/src/sandbox.rs | 40 +- crates/sandlock-core/src/sandbox/builder.rs | 396 +++++++++++++- crates/sandlock-core/src/sandbox/tests.rs | 60 +++ .../tests/profile_canonical_adversarial.rs | 5 +- crates/sandlock-ffi/include/sandlock.h | 171 ++++-- crates/sandlock-ffi/src/lib.rs | 496 ++++++++++++------ .../tests/builder_pending_error.rs | 408 ++++++++++++++ crates/sandlock-ffi/tests/fs_mount.rs | 197 +++---- crates/sandlock-ffi/tests/protection.rs | 80 +-- docs/sandbox-reference.md | 14 +- python/tests/test_cli_parity.py | 17 +- python/tests/test_profile.py | 13 +- python/tests/test_profile_abi_edge_cases.py | 5 +- 14 files changed, 1570 insertions(+), 367 deletions(-) create mode 100644 crates/sandlock-ffi/tests/builder_pending_error.rs diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index a051b6c0..1038b675 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -346,24 +346,25 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } } - // 4b. Optional: CPU core binding + // 4b. Optional: CPU core binding. A set that reached here is non-empty: + // the builder refuses an empty one by name. This used to skip the call for + // an empty set instead, which turned "pin me to no core" into "pinning did + // not happen" without anyone being told. if let Some(ref cores) = sandbox.cpu_cores { - if !cores.is_empty() { - let mut set = unsafe { std::mem::zeroed::() }; - unsafe { libc::CPU_ZERO(&mut set) }; - for &core in cores { - unsafe { libc::CPU_SET(core as usize, &mut set) }; - } - if unsafe { - libc::sched_setaffinity( - 0, - std::mem::size_of::(), - &set, - ) - } != 0 - { - fail!("sched_setaffinity"); - } + let mut set = unsafe { std::mem::zeroed::() }; + unsafe { libc::CPU_ZERO(&mut set) }; + for &core in cores { + unsafe { libc::CPU_SET(core as usize, &mut set) }; + } + if unsafe { + libc::sched_setaffinity( + 0, + std::mem::size_of::(), + &set, + ) + } != 0 + { + fail!("sched_setaffinity"); } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 23ac4123..073df2ff 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -176,8 +176,14 @@ impl TryFrom<&Sandbox> for Confinement { if sandbox.cwd.is_some() { unsupported.push("cwd"); } if sandbox.fs_storage.is_some() { unsupported.push("fs_storage"); } if sandbox.max_disk.is_some() { unsupported.push("max_disk"); } - if sandbox.on_exit != BranchAction::Commit { unsupported.push("on_exit"); } - if sandbox.on_error != BranchAction::Abort { unsupported.push("on_error"); } + // `on_exit` and `on_error` are deliberately absent from this list. They + // name what happens to a COW branch, and a confinement has no branch: + // it is applied in place, and `fs_storage`/`workdir` (the two knobs + // that create one) are already refused above. Rejecting them here only + // ever refused a field that could not have changed the outcome, and it + // did so by comparing against two hardcoded actions rather than the one + // `build()` resolves an unset field to, so a caller who said nothing + // about the error path was refused a confinement its policy allowed. if !sandbox.fs_mount.is_empty() { unsupported.push("fs_mount"); } if sandbox.chroot.is_some() { unsupported.push("chroot"); } if sandbox.clean_env { unsupported.push("clean_env"); } @@ -201,12 +207,36 @@ impl TryFrom<&Sandbox> for Confinement { } /// Action to take on branch exit. +/// +/// The discriminants are a stable contract: the FFI/Python/Go bindings pass +/// them as a `u8`, so they are pinned with `#[repr(u8)]` and translated back +/// by [`BranchAction::from_repr`]. Serde is unaffected (a data-less enum is +/// serialized by variant name, not by discriminant). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[repr(u8)] pub enum BranchAction { #[default] - Commit, - Abort, - Keep, + Commit = 0, + Abort = 1, + Keep = 2, +} + +impl BranchAction { + /// Translate a raw C ABI discriminant into a `BranchAction`. + /// + /// Returns `None` for anything outside the documented set. Bindings must + /// surface that as an error rather than coercing it to a default: an + /// unrecognized discriminant is a static bug in the binding, and coercing + /// it to `Commit` or `Abort` silently applies a branch policy nobody asked + /// for. + pub fn from_repr(raw: u8) -> Option { + match raw { + 0 => Some(Self::Commit), + 1 => Some(Self::Abort), + 2 => Some(Self::Keep), + _ => None, + } + } } // ============================================================ diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 83aa3965..40415521 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -234,6 +234,18 @@ pub struct SandboxBuilder { // COW fork work function: runs in each COW clone. #[cfg_attr(feature = "cli", clap(skip))] pub(crate) work_fn: Option>, + + /// First error latched by a setter that had no way to report it, surfaced + /// at `build()`. Setters return `Self`, not `Result`, so a value the core + /// cannot accept (an unrecognized C ABI discriminant, for example) is + /// recorded here instead of being coerced to a default. First write wins: + /// the earliest bad input is the one that explains the rest, and reporting + /// it alone keeps the message pointing at the caller's first mistake. + /// + /// Private on purpose: only the core writes here, and surfaces reach it + /// through [`SandboxBuilder::reject`]. + #[cfg_attr(feature = "cli", clap(skip))] + pending_error: Option, } impl std::fmt::Debug for SandboxBuilder { @@ -244,6 +256,7 @@ impl std::fmt::Debug for SandboxBuilder { .field("max_memory", &self.max_memory) .field("max_processes", &self.max_processes) .field("policy_fn", &self.policy_fn.as_ref().map(|_| "")) + .field("pending_error", &self.pending_error) .finish_non_exhaustive() } } @@ -303,6 +316,7 @@ impl Default for SandboxBuilder { mode: None, init_fn: None, work_fn: None, + pending_error: None, } } } @@ -368,6 +382,9 @@ impl Clone for SandboxBuilder { init_fn: None, // work_fn is Arc-wrapped; clone bumps the reference count. work_fn: self.work_fn.clone(), + // A latched error survives cloning. Dropping it here would make + // `.clone().build()` a laundering channel for rejected input. + pending_error: self.pending_error.clone(), } } } @@ -671,15 +688,22 @@ impl SandboxBuilder { } pub fn fs_mount(mut self, virtual_path: impl Into, host_path: impl Into) -> Self { - self.fs_mount.push((virtual_path.into(), host_path.into())); + let (virtual_path, host_path) = (virtual_path.into(), host_path.into()); + if let Some(reason) = empty_mount_half("fs_mount", &virtual_path, &host_path) { + return self.reject(reason); + } + self.fs_mount.push((virtual_path, host_path)); self } /// Add a read-only mount: the host path is visible at `virtual_path` for /// reading, but writes through it are denied (e.g. the host procfs mount). pub fn fs_mount_ro(mut self, virtual_path: impl Into, host_path: impl Into) -> Self { - let virtual_path = virtual_path.into(); - self.fs_mount.push((virtual_path.clone(), host_path.into())); + let (virtual_path, host_path) = (virtual_path.into(), host_path.into()); + if let Some(reason) = empty_mount_half("fs_mount_ro", &virtual_path, &host_path) { + return self.reject(reason); + } + self.fs_mount.push((virtual_path.clone(), host_path)); self.fs_mount_ro.push(virtual_path); self } @@ -789,11 +813,58 @@ impl SandboxBuilder { self } + /// Record a value the core cannot accept, to be reported by `build()`. + /// + /// Setters return `Self`, so they have no error channel of their own. A + /// surface that receives input the core rejects (an unrecognized C ABI + /// discriminant, for instance) latches the reason here instead of coercing + /// the value to a default: coercion runs a configuration the caller never + /// wrote, and does it silently. + /// + /// The first call wins. Later rejections are dropped, so the message names + /// the earliest mistake rather than whichever one happened to be last. + /// + /// `reason` should name the setter and quote the offending value, e.g. + /// `"on_exit: unrecognized branch action 7"`. + pub fn reject(mut self, reason: impl Into) -> Self { + self.pending_error.get_or_insert_with(|| reason.into()); + self + } + + /// Latch an error a core parser produced for a setter argument. + /// + /// Keeps the parser's own text rather than the wrapped `Display`, because + /// `build()` puts the reason back into [`SandboxError::Invalid`]: a + /// consumer then reads exactly the message a CLI user sees for the same + /// value, not a doubled `invalid sandbox: invalid sandbox: ...`. + /// + /// Use this whenever the core already diagnosed the value. [`reject`] + /// stays for the conditions only a surface can see (a null pointer, a byte + /// string that is not UTF-8, a discriminant with no variant), which have no + /// core error to carry. + /// + /// [`reject`]: Self::reject + pub fn reject_error(self, err: SandboxError) -> Self { + match err { + SandboxError::Invalid(reason) => self.reject(reason), + other => self.reject(other.to_string()), + } + } + /// Build a `Sandbox`, parsing all string fields and running per-field /// validation, but **without** the cross-section checks that /// `Sandbox::validate` performs. Use this in tests that deliberately /// construct sandboxes violating cross-section invariants. - pub fn build_unchecked(self) -> Result { + pub fn build_unchecked(mut self) -> Result { + // A setter recorded input the core could not accept. The builder no + // longer describes what the caller asked for, so every check below + // would be diagnosing a configuration nobody wrote. This lives here + // rather than in `build()` because `build_unchecked` is public and is + // the entry point used by sandlock-oci and by tests. + if let Some(reason) = self.pending_error.take() { + return Err(SandboxError::Invalid(reason)); + } + validate_syscall_names(&self.extra_deny_syscalls)?; validate_allow_groups(&self.extra_allow_syscalls)?; validate_allow_deny_disjoint(&self.extra_allow_syscalls, &self.extra_deny_syscalls)?; @@ -824,15 +895,46 @@ impl SandboxBuilder { } } + // Validate: max_processes must be non-zero. The cap is enforced by the + // seccomp supervisor as `proc_count >= limit`, so a limit of zero + // fails *every* fork/clone with EAGAIN, no matter how few processes + // are alive. The workload sees "Resource temporarily unavailable" from + // the first subprocess it starts, with nothing naming the setting + // responsible. + if self.max_processes == Some(0) { + return Err(SandboxError::Invalid( + "max_processes must be greater than 0; omit it to use the \ + default cap" + .into(), + )); + } + + // Validate: num_cpus must be non-zero. Zero is accepted all the way + // down into the synthetic procfs, where it produces an empty + // /proc/cpuinfo and an affinity mask with no bits set, so the guest + // reads `nproc` = 0 and nothing points at the setting that caused it. + if self.num_cpus == Some(0) { + return Err(SandboxError::Invalid( + "num_cpus must be greater than 0; omit it to expose the host \ + processor count" + .into(), + )); + } + // Validate: max_open_files must be non-zero. A zero cap cannot be // honoured: the child needs descriptors to reach `main` at all, so it // would die before it and exit 127 with an errno far from the setting // that caused it (EMFILE from the dynamic loader on a plain exec, EIO // from the exec-fd injection under chroot). Catching it here keeps the - // check in one place instead of one per binding: the C ABI and the CLI - // both pass the value straight through, and only the Go SDK filters - // zero, which it must, because a Go struct field cannot express "unset" - // any other way. + // check in one place instead of one per binding, which is where it had + // drifted to: the comment this replaces claimed a binding "must" filter + // zero itself. It must not. The Python SDK forwards whatever is not + // `None`, zero included, so a zero reaches this verdict; the Go SDK + // still drops one of its own accord (`if s.MaxOpenFiles > 0` in + // go/sandlock_linux.go), so a Go caller who writes zero gets a sandbox + // with no cap at all and no diagnosis. Reducing Go to forwarding needs + // its field to spell "unset" without using the value, which is a later + // commit; the verdict lives here either way. if self.max_open_files == Some(0) { return Err(SandboxError::Invalid( "max_open_files must be greater than 0; omit it to inherit the \ @@ -842,6 +944,40 @@ impl SandboxBuilder { )); } + // Validate: max_memory must be non-zero. Zero is the sentinel the + // supervisor carries for "no ceiling" (`max_memory.map(..).unwrap_or(0)` + // in Sandbox::run, read back as `max_memory_bytes > 0` by the synthetic + // /proc/meminfo), but the memory handler is registered on + // `max_memory.is_some()`, so an explicit zero installs the handler with + // a ceiling of zero and the first anonymous mmap, the dynamic loader's, + // is SIGKILLed. The caller gets a guest that dies before `main` with no + // exit status and nothing naming the setting. The two readings of the + // same value cannot both stand; refusing the value here is what lets + // the sentinel keep meaning "unset", which is what the comment in + // resource::handle_memory already assumes it does. + if self.max_memory == Some(ByteSize(0)) { + return Err(SandboxError::Invalid( + "max_memory must be greater than 0; omit it to leave memory \ + unlimited" + .into(), + )); + } + + // Validate: cpu_cores must name at least one core. An empty set asks + // for an affinity mask with no bits, which sched_setaffinity(2) refuses + // with EINVAL; the child setup skipped the call instead, so the pinning + // the caller asked for silently did not happen and the sandbox ran on + // every core. Unlike `gpu_devices`, where an empty list is the spelling + // of "every device present", there is no cpu set an empty list could + // stand for: "every core" is what omitting the field already means. + if self.cpu_cores.as_deref() == Some(&[][..]) { + return Err(SandboxError::Invalid( + "cpu_cores must name at least one core; omit it to leave the \ + child on the host's default affinity mask" + .into(), + )); + } + // Validate: http_ca and http_key must both be set or both unset if self.http_ca.is_some() != self.http_key.is_some() { return Err(SandboxError::Invalid( @@ -1076,6 +1212,29 @@ struct Exposure { deny_target: std::path::PathBuf, } +/// Refuse a mount whose virtual or host half is the empty path. +/// +/// Neither half has a reading as "unset": an empty host path names nothing to +/// expose, and an empty virtual path is a prefix of every guest path, so the +/// read-only marking of `fs_mount_ro` would cover the whole guest view. The +/// profile grammar already refuses both halves when it splits a +/// `VIRTUAL:HOST` spec, so this is the same verdict reached through the +/// setter, which is the path a binding takes. +fn empty_mount_half( + setter: &str, + virtual_path: &std::path::Path, + host_path: &std::path::Path, +) -> Option { + let half = if virtual_path.as_os_str().is_empty() { + "virtual path" + } else if host_path.as_os_str().is_empty() { + "host path" + } else { + return None; + }; + Some(format!("{setter}: {half} must not be empty")) +} + /// The first fs grant that exposes `secret` to the sandboxed child, or `None` /// when no grant reaches it or an fs-deny covers it (the deny closes the hole, /// so no warning is due). Best-effort: canonicalize where possible. @@ -1139,6 +1298,8 @@ fn exposing_grant<'a>( #[cfg(test)] mod tests { use super::exposing_grant; + use super::ByteSize; + use super::SandboxError; use std::path::PathBuf; #[test] @@ -1172,6 +1333,225 @@ mod tests { .expect("a non-zero cap must build"); } + #[test] + fn max_processes_zero_is_rejected_at_build() { + // The supervisor compares `proc_count >= limit`, so a limit of zero + // denies every fork with EAGAIN and the workload never learns why. + let err = super::SandboxBuilder::default() + .max_processes(0) + .build() + .expect_err("a zero process cap must not build"); + let msg = err.to_string(); + assert!( + msg.contains("max_processes"), + "the error must name the setting, got: {msg}" + ); + + // Unset stays valid (it means "use the default cap"), and so does a + // usable value: the check must reject only zero. + super::SandboxBuilder::default() + .build() + .expect("an unset cap must still build"); + super::SandboxBuilder::default() + .max_processes(8) + .build() + .expect("a non-zero cap must build"); + } + + #[test] + fn num_cpus_zero_is_rejected_at_build() { + // Zero reaches the synthetic procfs, where it yields an empty + // /proc/cpuinfo and an affinity mask with no bits set. + let err = super::SandboxBuilder::default() + .num_cpus(0) + .build() + .expect_err("a zero processor count must not build"); + let msg = err.to_string(); + assert!( + msg.contains("num_cpus"), + "the error must name the setting, got: {msg}" + ); + + super::SandboxBuilder::default() + .build() + .expect("an unset processor count must still build"); + super::SandboxBuilder::default() + .num_cpus(2) + .build() + .expect("a non-zero processor count must build"); + } + + #[test] + fn max_memory_zero_is_rejected_at_build() { + // Zero doubles as the "no ceiling" sentinel the supervisor carries, + // but the memory handler is registered on `is_some()`, so an explicit + // zero enforces a ceiling of zero and SIGKILLs the loader's first + // anonymous mmap while /proc/meminfo reports the sandbox unlimited. + let err = super::SandboxBuilder::default() + .max_memory(ByteSize(0)) + .build() + .expect_err("a zero memory ceiling must not build"); + let msg = err.to_string(); + assert!( + msg.contains("max_memory"), + "the error must name the setting, got: {msg}" + ); + assert!( + msg.contains("omit"), + "the error must say how to get an unlimited sandbox, got: {msg}" + ); + + // Unset stays valid, and so does a usable ceiling: the check must + // reject only the value that turns the sentinel ambiguous. + super::SandboxBuilder::default() + .build() + .expect("an unset ceiling must still build"); + super::SandboxBuilder::default() + .max_memory(ByteSize(1024 * 1024)) + .build() + .expect("a non-zero ceiling must build"); + + // max_disk is deliberately not the same: zero is its documented + // spelling of "unlimited" (see cow::seccomp::check_quota), and one + // reading is all it has. + super::SandboxBuilder::default() + .max_disk(ByteSize(0)) + .build() + .expect("a zero disk quota still means unlimited and must build"); + } + + #[test] + fn empty_cpu_cores_is_rejected_at_build() { + // An empty set asks for an affinity mask with no bits, which + // sched_setaffinity refuses; the child setup used to skip the call + // instead and run on every core without saying so. + let err = super::SandboxBuilder::default() + .cpu_cores(Vec::new()) + .build() + .expect_err("an empty core set must not build"); + let msg = err.to_string(); + assert!( + msg.contains("cpu_cores"), + "the error must name the setting, got: {msg}" + ); + + super::SandboxBuilder::default() + .build() + .expect("an unset core set must still build"); + super::SandboxBuilder::default() + .cpu_cores(vec![0]) + .build() + .expect("a core set with one core must build"); + + // gpu_devices reads an empty list as "every device present", so the + // same shape must stay accepted there: the two are not one rule. + super::SandboxBuilder::default() + .gpu_devices(Vec::new()) + .build() + .expect("an empty gpu list means every GPU and must build"); + } + + #[test] + fn reject_error_hands_back_the_parser_text_a_cli_user_would_read() { + // What `reject_error` is for: a surface that ran a core parser on a + // setter argument has an error already, and the value must read the + // same however it arrived. `reject` would wrap it a second time, + // because build() puts the reason back into SandboxError::Invalid. + let parser_error = ByteSize::parse("1.5G").expect_err("the grammar takes no fractions"); + let from_cli = parser_error.to_string(); + + let err = super::SandboxBuilder::default() + .reject_error(parser_error) + .build() + .expect_err("a latched parser error must not build"); + assert_eq!( + err.to_string(), + from_cli, + "the message must be the parser's own, not a doubled wrapping" + ); + assert!( + !err.to_string().contains("invalid sandbox: invalid sandbox:"), + "got: {err}" + ); + + // A variant that carries no free-form reason keeps its own Display, + // which is the only text it has. + let err = super::SandboxBuilder::default() + .reject_error(SandboxError::InvalidCpuPercent(0)) + .build() + .expect_err("a latched parser error must not build"); + assert!( + err.to_string().contains("max_cpu must be 1-100, got 0"), + "got: {err}" + ); + + // It shares the latch with `reject`, first write wins, and it stops + // `build_unchecked` too. + let err = super::SandboxBuilder::default() + .reject("first") + .reject_error(SandboxError::Invalid("second".into())) + .build_unchecked() + .expect_err("a latched error must not build_unchecked either"); + assert!(err.to_string().contains("first"), "got: {err}"); + assert!(!err.to_string().contains("second"), "got: {err}"); + } + + #[test] + fn an_empty_mount_half_is_rejected_at_build() { + // Neither half has a reading as "unset". An empty virtual path is the + // dangerous one: it is a prefix of every guest path, so `fs_mount_ro` + // would mark the whole guest view read-only and `is_mounted` would + // match every path. The verdict lives here rather than in a binding, + // so the C ABI can forward whatever bytes it was handed. + for (setter, err) in [ + ( + "fs_mount", + super::SandboxBuilder::default().fs_mount("", "/srv").build(), + ), + ( + "fs_mount_ro", + super::SandboxBuilder::default().fs_mount_ro("", "/srv").build(), + ), + ( + "fs_mount", + super::SandboxBuilder::default().fs_mount("/data", "").build(), + ), + ( + "fs_mount_ro", + super::SandboxBuilder::default().fs_mount_ro("/data", "").build(), + ), + ] { + let err = err.expect_err("an empty mount half must not build"); + let msg = err.to_string(); + assert!(msg.contains(setter), "the error must name the setter, got: {msg}"); + assert!(msg.contains("empty"), "the error must say what is wrong, got: {msg}"); + } + + // The message names which half, so a caller knows which pointer to fix. + let msg = super::SandboxBuilder::default() + .fs_mount("", "/srv") + .build() + .expect_err("empty virtual path") + .to_string(); + assert!(msg.contains("virtual path"), "got: {msg}"); + let msg = super::SandboxBuilder::default() + .fs_mount("/data", "") + .build() + .expect_err("empty host path") + .to_string(); + assert!(msg.contains("host path"), "got: {msg}"); + + // An ordinary pair is untouched, and a read-only one still records the + // virtual path as read-only. + let sandbox = super::SandboxBuilder::default() + .fs_mount("/data", "/srv/data") + .fs_mount_ro("/ref", "/srv/ref") + .build() + .expect("two well-formed mounts must build"); + assert_eq!(sandbox.fs_mount.len(), 2); + assert_eq!(sandbox.fs_mount_ro, vec![PathBuf::from("/ref")]); + } + #[test] fn exposing_grant_reports_overlap_and_fs_deny_suppresses() { let dir = std::env::temp_dir().join(format!("sandlock-grant-{}", std::process::id())); diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index 278d6b09..1388e5ea 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -437,3 +437,63 @@ async fn a_finished_capture_survives_a_cancellation_at_the_sibling_join() { "a capture that finished before the cancellation must still be parked", ); } + +// --------------------------------------------------------------- +// Confinement::try_from +// --------------------------------------------------------------- + +#[test] +fn a_default_sandbox_confines() { + // The shape every binding produces when the caller says nothing about the + // COW branch: `build()` resolves both branch actions to the core's + // default. `Confinement::try_from` used to demand `on_error == Abort`, + // which no default-built sandbox has, so `confine()` failed for the + // policy in the SDK quickstarts. + let sb = Sandbox::builder() + .fs_read("/usr") + .fs_write("/tmp") + .build() + .expect("a read/write-only policy builds"); + let c = Confinement::try_from(&sb).expect("a default sandbox must be confinable"); + assert_eq!(c.fs_readable, vec![PathBuf::from("/usr")]); + assert_eq!(c.fs_writable, vec![PathBuf::from("/tmp")]); +} + +#[test] +fn branch_actions_do_not_block_a_confinement() { + // A confinement has no COW branch to act on, so neither action can change + // what it does. Every spelling has to be accepted, not just the two that + // the removed check happened to name. + for on_exit in [BranchAction::Commit, BranchAction::Abort, BranchAction::Keep] { + for on_error in [BranchAction::Commit, BranchAction::Abort, BranchAction::Keep] { + let sb = Sandbox::builder() + .fs_read("/usr") + .on_exit(on_exit.clone()) + .on_error(on_error.clone()) + .build() + .expect("branch actions alone do not make a policy invalid"); + assert!( + Confinement::try_from(&sb).is_ok(), + "on_exit={:?} on_error={:?} must still confine", + on_exit, + on_error, + ); + } + } +} + +#[test] +fn a_field_a_confinement_cannot_honor_is_still_refused() { + // The guard rail for the test above: dropping the branch-action rows must + // not have loosened the list itself. + let sb = Sandbox::builder() + .fs_read("/usr") + .cwd("/tmp") + .build() + .expect("cwd alone is a valid sandbox"); + let err = Confinement::try_from(&sb).expect_err("cwd cannot be applied in place"); + assert!( + matches!(err, SandboxError::UnsupportedForConfine(ref f) if f.contains("cwd")), + "expected cwd to be named, got {err:?}", + ); +} diff --git a/crates/sandlock-core/tests/profile_canonical_adversarial.rs b/crates/sandlock-core/tests/profile_canonical_adversarial.rs index 186e4829..6461f374 100644 --- a/crates/sandlock-core/tests/profile_canonical_adversarial.rs +++ b/crates/sandlock-core/tests/profile_canonical_adversarial.rs @@ -350,7 +350,10 @@ fn the_largest_legal_values_are_still_accepted() { ok("[limits]\nmemory = \"18446744073709551615\"\n")["limits"]["memory"], u64::MAX ); - assert_eq!(ok("[limits]\nmemory = \"0\"\n")["limits"]["memory"], 0); + // The smallest legal size, on the knob that takes it: zero is the disk + // quota's spelling of "unlimited", while the memory ceiling refuses it + // because zero is what the supervisor already carries for "no ceiling". + assert_eq!(ok("[limits]\ndisk = \"0\"\n")["limits"]["disk"], 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); diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 078515e1..b625d07f 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -313,25 +313,33 @@ sandlock_builder_t *sandlock_sandbox_builder_new(void); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_fs_read(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_fs_write(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_fs_deny(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_fs_storage(sandlock_builder_t *b, const char *path); @@ -345,30 +353,38 @@ sandlock_builder_t *sandlock_sandbox_builder_gpu_devices(sandlock_builder_t *b, /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_workdir(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_cwd(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_chroot(sandlock_builder_t *b, const char *path); /** * Add a filesystem mount mapping (virtual_path -> host_path). * - * Both paths must be non-empty UTF-8; anything else is ignored and adds no - * mount (an empty virtual path would match every guest path). + * A null or non-UTF-8 path, and a path that is empty, are reported by + * `sandlock_sandbox_build` rather than dropped: a mount that silently did not + * happen leaves the guest a filesystem view nobody asked for. * * # Safety - * `b`, `virtual_path`, and `host_path` must be valid pointers. + * `b` must be a valid builder pointer. Each path must be null or point at a + * NUL-terminated string. */ sandlock_builder_t *sandlock_sandbox_builder_fs_mount(sandlock_builder_t *b, const char *virtual_path, @@ -392,11 +408,15 @@ sandlock_builder_t *sandlock_sandbox_builder_fs_mount(sandlock_builder_t *b, * when [`sandlock_sandbox_builder_chroot`] is also set; without a chroot this * call has no effect on the guest's filesystem view. * - * Both paths must be non-empty UTF-8; anything else is ignored and adds no - * mount (an empty virtual path would match every guest path). + * A null or non-UTF-8 path, and a path that is empty, are reported by + * `sandlock_sandbox_build` rather than dropped. An empty virtual path is the + * sharper case here: it is a prefix of every guest path, so dropping the call + * and dropping the read-only marking are the two things the caller cannot tell + * apart, and one of them is a fully writable guest. * * # Safety - * `b`, `virtual_path`, and `host_path` must be valid pointers. + * `b` must be a valid builder pointer. Each path must be null or point at a + * NUL-terminated string. */ sandlock_builder_t *sandlock_sandbox_builder_fs_mount_ro(sandlock_builder_t *b, const char *virtual_path, @@ -406,6 +426,11 @@ sandlock_builder_t *sandlock_sandbox_builder_fs_mount_ro(sandlock_builder_t *b, * Set the COW branch action on successful exit. * `action`: 0 = Commit, 1 = Abort, 2 = Keep. * + * Any other value is a static bug in the calling binding, not a runtime + * condition. It is latched in the builder and reported by + * `sandlock_sandbox_build`, which returns -1 with a message naming this + * setter and the offending value. + * * # Safety * `b` must be a valid builder pointer. */ @@ -415,24 +440,48 @@ sandlock_builder_t *sandlock_sandbox_builder_on_exit(sandlock_builder_t *b, uint * Set the COW branch action on error exit. * `action`: 0 = Commit, 1 = Abort, 2 = Keep. * + * Any other value is a static bug in the calling binding, not a runtime + * condition. It is latched in the builder and reported by + * `sandlock_sandbox_build`, which returns -1 with a message naming this + * setter and the offending value. + * * # Safety * `b` must be a valid builder pointer. */ sandlock_builder_t *sandlock_sandbox_builder_on_error(sandlock_builder_t *b, uint8_t action); /** + * Set the memory ceiling, in bytes. + * + * Zero is refused, reported by `sandlock_sandbox_build`: it is also the + * sentinel the supervisor carries for "no ceiling", so an explicit zero would + * install a ceiling of zero while the synthetic `/proc/meminfo` reports the + * sandbox unlimited. Omit the call to leave memory unlimited. + * * # Safety * `b` must be a valid builder pointer. */ sandlock_builder_t *sandlock_sandbox_builder_max_memory(sandlock_builder_t *b, uint64_t bytes); /** + * Set the COW storage quota, in bytes. + * + * Zero is accepted here, unlike `sandlock_sandbox_builder_max_memory`: for a + * disk quota zero is the documented spelling of "unlimited" and has no second + * reading. + * * # Safety * `b` must be a valid builder pointer. */ sandlock_builder_t *sandlock_sandbox_builder_max_disk(sandlock_builder_t *b, uint64_t bytes); /** + * Set the peak concurrent process limit. + * + * Zero is refused, reported by `sandlock_sandbox_build`: the supervisor + * compares `proc_count >= limit`, so a limit of zero denies every fork with + * EAGAIN however few processes are alive. Omit the call for the default cap. + * * # Safety * `b` must be a valid builder pointer. */ @@ -445,12 +494,27 @@ sandlock_builder_t *sandlock_sandbox_builder_max_processes(sandlock_builder_t *b sandlock_builder_t *sandlock_sandbox_builder_max_cpu(sandlock_builder_t *b, uint8_t pct); /** + * Set the processor count the guest sees. + * + * Zero is refused, reported by `sandlock_sandbox_build`: it reaches the + * synthetic procfs as an empty `/proc/cpuinfo` and an affinity mask with no + * bits, so the guest reads `nproc = 0`. Omit the call to expose the host + * processor count. + * * # Safety * `b` must be a valid builder pointer. */ sandlock_builder_t *sandlock_sandbox_builder_num_cpus(sandlock_builder_t *b, uint32_t n); /** + * Pin the guest to the listed CPU cores. + * + * A `len` of zero is refused, reported by `sandlock_sandbox_build`: an + * affinity mask with no bits is what `sched_setaffinity(2)` rejects with + * EINVAL, and unlike `sandlock_sandbox_builder_gpu_devices`, where an empty + * list means "every device present", omitting this call is already how "every + * core" is spelled. Omit it rather than passing an empty list. + * * # Safety * `b` must be a valid builder pointer. `cores` must point to `len` u32 values. */ @@ -464,13 +528,17 @@ sandlock_builder_t *sandlock_sandbox_builder_cpu_cores(sandlock_builder_t *b, * invalid specs surface as a build error. * * # Safety - * `b` and `spec` must be valid pointers. + * `b` must be a valid builder pointer. `spec` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `spec` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_net_allow(sandlock_builder_t *b, const char *spec); /** * # Safety - * `b` and `spec` must be valid pointers. + * `b` must be a valid builder pointer. `spec` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `spec` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_net_deny(sandlock_builder_t *b, const char *spec); @@ -481,7 +549,9 @@ sandlock_builder_t *sandlock_sandbox_builder_net_deny(sandlock_builder_t *b, con * (including `"*"` mixed with port lists) surface as a build error. * * # Safety - * `b` and `spec` must be valid pointers. + * `b` must be a valid builder pointer. `spec` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `spec` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_net_allow_bind(sandlock_builder_t *b, const char *spec); @@ -493,7 +563,9 @@ sandlock_builder_t *sandlock_sandbox_builder_net_allow_bind(sandlock_builder_t * * surface as a build error. * * # Safety - * `b` and `spec` must be valid pointers. + * `b` must be a valid builder pointer. `spec` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `spec` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_net_deny_bind(sandlock_builder_t *b, const char *spec); @@ -516,13 +588,17 @@ sandlock_builder_t *sandlock_sandbox_builder_user(sandlock_builder_t *b, /** * # Safety - * `b` and `rule` must be valid pointers. + * `b` must be a valid builder pointer. `rule` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `rule` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_http_allow(sandlock_builder_t *b, const char *rule); /** * # Safety - * `b` and `rule` must be valid pointers. + * `b` must be a valid builder pointer. `rule` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `rule` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_http_deny(sandlock_builder_t *b, const char *rule); @@ -534,26 +610,34 @@ sandlock_builder_t *sandlock_sandbox_builder_http_port(sandlock_builder_t *b, ui /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_http_ca(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_http_key(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_http_inject_ca(sandlock_builder_t *b, const char *path); /** * # Safety - * `b` and `path` must be valid pointers. + * `b` must be a valid builder pointer. `path` must be null or point at a + * NUL-terminated string; a null or non-UTF-8 `path` is reported by + * `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_http_ca_out(sandlock_builder_t *b, const char *path); @@ -571,7 +655,9 @@ sandlock_builder_t *sandlock_sandbox_builder_clean_env(sandlock_builder_t *b, bo /** * # Safety - * `b`, `key`, and `value` must be valid pointers. + * `b` must be a valid builder pointer. `key` and `value` must each be null + * or point at a NUL-terminated string; a null or non-UTF-8 half is reported + * by `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_env_var(sandlock_builder_t *b, const char *key, @@ -585,14 +671,18 @@ sandlock_builder_t *sandlock_sandbox_builder_time_start(sandlock_builder_t *b, u /** * # Safety - * `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. + * `b` must be a valid builder pointer. `names` must be null or point at a + * comma-separated NUL-terminated string; a null or non-UTF-8 `names` is + * reported by `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_extra_deny_syscalls(sandlock_builder_t *b, const char *names); /** * # Safety - * `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. + * `b` must be a valid builder pointer. `names` must be null or point at a + * comma-separated NUL-terminated string; a null or non-UTF-8 `names` is + * reported by `sandlock_sandbox_build` rather than dereferenced or coerced. */ sandlock_builder_t *sandlock_sandbox_builder_extra_allow_syscalls(sandlock_builder_t *b, const char *names); @@ -616,6 +706,13 @@ sandlock_builder_t *sandlock_sandbox_builder_extra_allow_syscalls(sandlock_build int64_t sandlock_syscall_nr(const char *name); /** + * Set the open file-descriptor limit (RLIMIT_NOFILE, soft and hard). + * + * Zero is refused, reported by `sandlock_sandbox_build`: the child needs + * descriptors to reach `main`, so a zero cap kills it before the workload + * starts with an errno far from the setting responsible. A workable floor is + * well above 1. Omit the call to inherit the system limit. + * * # Safety * `b` must be a valid builder pointer. */ @@ -663,8 +760,15 @@ uint32_t sandlock_protection_min_abi(uint32_t protection); * Returns the (possibly relocated) builder pointer, mirroring the * move-semantics convention used by every other * `sandlock_sandbox_builder_*` setter. A null `b` is returned - * unchanged. An unknown `protection` discriminant is treated as a - * no-op: the builder is returned untouched. + * unchanged. + * + * An unknown `protection` discriminant is a static bug in the calling + * binding, not a runtime condition, exactly as it is for + * `sandlock_sandbox_builder_on_exit`. It is latched in the builder and + * reported by `sandlock_sandbox_build`, which returns -1 with a message + * naming this setter and the offending value. It used to be dropped, so a + * binding built against a newer header was told nothing when an older + * library did not recognise what it sent. * * # Safety * `b` must be a valid builder pointer returned by @@ -681,8 +785,15 @@ sandlock_builder_t *sandlock_sandbox_builder_allow_degraded(sandlock_builder_t * * Returns the (possibly relocated) builder pointer, mirroring the * move-semantics convention used by every other * `sandlock_sandbox_builder_*` setter. A null `b` is returned - * unchanged. An unknown `protection` discriminant is treated as a - * no-op: the builder is returned untouched. + * unchanged. + * + * An unknown `protection` discriminant is a static bug in the calling + * binding, not a runtime condition, exactly as it is for + * `sandlock_sandbox_builder_on_exit`. It is latched in the builder and + * reported by `sandlock_sandbox_build`, which returns -1 with a message + * naming this setter and the offending value. It used to be dropped, so a + * binding built against a newer header was told nothing when an older + * library did not recognise what it sent. * * # Safety * `b` must be a valid builder pointer returned by diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 866fc9fa..58188282 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -58,6 +58,37 @@ pub struct sandlock_pipeline_t { stages: Vec<(Sandbox, Vec)>, } +/// Borrow a setter argument that the core is about to parse. +/// +/// Reports the two conditions the core cannot see once the value is a Rust +/// string: a null pointer, and bytes that are not UTF-8. Both are static bugs +/// in the calling binding, and both are representation problems rather than +/// policy ones, so this is the only diagnosis the C ABI writes itself; the +/// grammar's verdict comes from the core untouched. +/// +/// The reason travels back through the builder's pending-error latch and +/// surfaces at `sandlock_sandbox_build`. Every `*const c_char` builder setter +/// goes through here, the two-argument ones (`env_var`, `fs_mount`, +/// `fs_mount_ro`) once per half so the message names the pointer to fix. The +/// alternative, `to_str().unwrap_or("")`, +/// hands the core an empty string and makes it diagnose a value the caller +/// never passed: a path read off `readdir()` is an arbitrary byte string on +/// Linux, so this is reachable without any bug in the caller, and the resulting +/// empty path is a prefix of every guest path. It survives only in the entry +/// points that take no builder, and so have nothing to latch the reason on. +/// +/// # Safety +/// `s` must be null or point to a NUL-terminated string that stays valid for +/// as long as the returned borrow is used. +unsafe fn setter_arg<'a>(s: *const c_char, setter: &str) -> Result<&'a str, String> { + if s.is_null() { + return Err(format!("{setter}: value must not be NULL")); + } + CStr::from_ptr(s) + .to_str() + .map_err(|_| format!("{setter}: value is not valid UTF-8")) +} + // ---------------------------------------------------------------- // Sandbox Builder — filesystem // ---------------------------------------------------------------- @@ -68,63 +99,83 @@ pub extern "C" fn sandlock_sandbox_builder_new() -> *mut SandboxBuilder { } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_fs_read( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.fs_read(path))) + let builder = match setter_arg(path, "fs_read") { + Ok(path) => builder.fs_read(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_fs_write( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.fs_write(path))) + let builder = match setter_arg(path, "fs_write") { + Ok(path) => builder.fs_write(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_fs_deny( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.fs_deny(path))) + let builder = match setter_arg(path, "fs_deny") { + Ok(path) => builder.fs_deny(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_fs_storage( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.fs_storage(path))) + let builder = match setter_arg(path, "fs_storage") { + Ok(path) => builder.fs_storage(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety @@ -148,95 +199,110 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_gpu_devices( } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_workdir( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.workdir(path))) + let builder = match setter_arg(path, "workdir") { + Ok(path) => builder.workdir(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_cwd( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.cwd(path))) + let builder = match setter_arg(path, "cwd") { + Ok(path) => builder.cwd(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_chroot( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.chroot(path))) + let builder = match setter_arg(path, "chroot") { + Ok(path) => builder.chroot(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } -/// Validate one mount pair coming in over the C ABI. +/// Borrow both halves of a mount pair, naming whichever one it cannot take. /// -/// Returns `None` (meaning "add no mount") for anything that is not a pair -/// of non-empty UTF-8 strings. The usual `to_str().unwrap_or("")` degradation -/// is unsafe here specifically: an empty virtual path is a prefix of *every* -/// guest path, so `ChrootCtx::is_mounted` would match the whole tree and -/// short-circuit both `can_read` and `can_write` to true, voiding the read -/// and write allowlists. Core's `parse_mount_spec` enforces the same -/// non-empty rule for `--fs-mount` specs. +/// The same two representation verdicts as [`setter_arg`], reported per half so +/// the message says which pointer the caller has to fix. Emptiness is not +/// checked here: it is a policy question (an empty virtual path is a prefix of +/// every guest path, so the read-only marking would cover the whole guest view) +/// and the core's setter answers it, the same way `parse_mount_spec` answers it +/// for a `VIRTUAL:HOST` profile spec. /// /// # Safety -/// Both pointers must be non-null and point at valid NUL-terminated strings. +/// Both pointers must be null or point at valid NUL-terminated strings. unsafe fn mount_pair<'a>( + setter: &str, virtual_path: *const c_char, host_path: *const c_char, -) -> Option<(&'a str, &'a str)> { - let vp = CStr::from_ptr(virtual_path).to_str().ok()?; - let hp = CStr::from_ptr(host_path).to_str().ok()?; - if vp.is_empty() || hp.is_empty() { - return None; - } - Some((vp, hp)) +) -> Result<(&'a str, &'a str), String> { + let vp = setter_arg(virtual_path, &format!("{setter} virtual path"))?; + let hp = setter_arg(host_path, &format!("{setter} host path"))?; + Ok((vp, hp)) } /// Add a filesystem mount mapping (virtual_path -> host_path). /// -/// Both paths must be non-empty UTF-8; anything else is ignored and adds no -/// mount (an empty virtual path would match every guest path). +/// A null or non-UTF-8 path, and a path that is empty, are reported by +/// `sandlock_sandbox_build` rather than dropped: a mount that silently did not +/// happen leaves the guest a filesystem view nobody asked for. /// /// # Safety -/// `b`, `virtual_path`, and `host_path` must be valid pointers. +/// `b` must be a valid builder pointer. Each path must be null or point at a +/// NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_fs_mount( b: *mut SandboxBuilder, virtual_path: *const c_char, host_path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || virtual_path.is_null() || host_path.is_null() { + if b.is_null() { return b; } - let Some((vp, hp)) = mount_pair(virtual_path, host_path) else { - return b; - }; let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.fs_mount(vp, hp))) + let builder = match mount_pair("fs_mount", virtual_path, host_path) { + Ok((vp, hp)) => builder.fs_mount(vp, hp), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// Add a read-only filesystem mount mapping (virtual_path -> host_path). @@ -256,30 +322,40 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_fs_mount( /// when [`sandlock_sandbox_builder_chroot`] is also set; without a chroot this /// call has no effect on the guest's filesystem view. /// -/// Both paths must be non-empty UTF-8; anything else is ignored and adds no -/// mount (an empty virtual path would match every guest path). +/// A null or non-UTF-8 path, and a path that is empty, are reported by +/// `sandlock_sandbox_build` rather than dropped. An empty virtual path is the +/// sharper case here: it is a prefix of every guest path, so dropping the call +/// and dropping the read-only marking are the two things the caller cannot tell +/// apart, and one of them is a fully writable guest. /// /// # Safety -/// `b`, `virtual_path`, and `host_path` must be valid pointers. +/// `b` must be a valid builder pointer. Each path must be null or point at a +/// NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_fs_mount_ro( b: *mut SandboxBuilder, virtual_path: *const c_char, host_path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || virtual_path.is_null() || host_path.is_null() { + if b.is_null() { return b; } - let Some((vp, hp)) = mount_pair(virtual_path, host_path) else { - return b; - }; let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.fs_mount_ro(vp, hp))) + let builder = match mount_pair("fs_mount_ro", virtual_path, host_path) { + Ok((vp, hp)) => builder.fs_mount_ro(vp, hp), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// Set the COW branch action on successful exit. /// `action`: 0 = Commit, 1 = Abort, 2 = Keep. /// +/// Any other value is a static bug in the calling binding, not a runtime +/// condition. It is latched in the builder and reported by +/// `sandlock_sandbox_build`, which returns -1 with a message naming this +/// setter and the offending value. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -291,17 +367,21 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_on_exit( return b; } let builder = *Box::from_raw(b); - let action = match action { - 1 => BranchAction::Abort, - 2 => BranchAction::Keep, - _ => BranchAction::Commit, + let builder = match BranchAction::from_repr(action) { + Some(a) => builder.on_exit(a), + None => builder.reject(format!("on_exit: unrecognized branch action {action}")), }; - Box::into_raw(Box::new(builder.on_exit(action))) + Box::into_raw(Box::new(builder)) } /// Set the COW branch action on error exit. /// `action`: 0 = Commit, 1 = Abort, 2 = Keep. /// +/// Any other value is a static bug in the calling binding, not a runtime +/// condition. It is latched in the builder and reported by +/// `sandlock_sandbox_build`, which returns -1 with a message naming this +/// setter and the offending value. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -313,18 +393,24 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_on_error( return b; } let builder = *Box::from_raw(b); - let action = match action { - 1 => BranchAction::Abort, - 2 => BranchAction::Keep, - _ => BranchAction::Commit, + let builder = match BranchAction::from_repr(action) { + Some(a) => builder.on_error(a), + None => builder.reject(format!("on_error: unrecognized branch action {action}")), }; - Box::into_raw(Box::new(builder.on_error(action))) + Box::into_raw(Box::new(builder)) } // ---------------------------------------------------------------- // Sandbox Builder — resource limits // ---------------------------------------------------------------- +/// Set the memory ceiling, in bytes. +/// +/// Zero is refused, reported by `sandlock_sandbox_build`: it is also the +/// sentinel the supervisor carries for "no ceiling", so an explicit zero would +/// install a ceiling of zero while the synthetic `/proc/meminfo` reports the +/// sandbox unlimited. Omit the call to leave memory unlimited. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -339,6 +425,12 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_max_memory( Box::into_raw(Box::new(builder.max_memory(ByteSize(bytes)))) } +/// Set the COW storage quota, in bytes. +/// +/// Zero is accepted here, unlike `sandlock_sandbox_builder_max_memory`: for a +/// disk quota zero is the documented spelling of "unlimited" and has no second +/// reading. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -353,6 +445,12 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_max_disk( Box::into_raw(Box::new(builder.max_disk(ByteSize(bytes)))) } +/// Set the peak concurrent process limit. +/// +/// Zero is refused, reported by `sandlock_sandbox_build`: the supervisor +/// compares `proc_count >= limit`, so a limit of zero denies every fork with +/// EAGAIN however few processes are alive. Omit the call for the default cap. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -381,6 +479,13 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_max_cpu( Box::into_raw(Box::new(builder.max_cpu(pct))) } +/// Set the processor count the guest sees. +/// +/// Zero is refused, reported by `sandlock_sandbox_build`: it reaches the +/// synthetic procfs as an empty `/proc/cpuinfo` and an affinity mask with no +/// bits, so the guest reads `nproc = 0`. Omit the call to expose the host +/// processor count. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -395,6 +500,14 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_num_cpus( Box::into_raw(Box::new(builder.num_cpus(n))) } +/// Pin the guest to the listed CPU cores. +/// +/// A `len` of zero is refused, reported by `sandlock_sandbox_build`: an +/// affinity mask with no bits is what `sched_setaffinity(2)` rejects with +/// EINVAL, and unlike `sandlock_sandbox_builder_gpu_devices`, where an empty +/// list means "every device present", omitting this call is already how "every +/// core" is spelled. Omit it rather than passing an empty list. +/// /// # Safety /// `b` must be a valid builder pointer. `cores` must point to `len` u32 values. #[no_mangle] @@ -424,33 +537,43 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_cpu_cores( /// invalid specs surface as a build error. /// /// # Safety -/// `b` and `spec` must be valid pointers. +/// `b` must be a valid builder pointer. `spec` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `spec` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow( b: *mut SandboxBuilder, spec: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || spec.is_null() { + if b.is_null() { return b; } - let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_allow(spec))) + let builder = match setter_arg(spec, "net_allow") { + Ok(spec) => builder.net_allow(spec), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `spec` must be valid pointers. +/// `b` must be a valid builder pointer. `spec` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `spec` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny( b: *mut SandboxBuilder, spec: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || spec.is_null() { + if b.is_null() { return b; } - let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_deny(spec))) + let builder = match setter_arg(spec, "net_deny") { + Ok(spec) => builder.net_deny(spec), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// Append a `--net-allow-bind` port spec: a comma-separated list of single @@ -459,18 +582,23 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny( /// (including `"*"` mixed with port lists) surface as a build error. /// /// # Safety -/// `b` and `spec` must be valid pointers. +/// `b` must be a valid builder pointer. `spec` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `spec` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow_bind( b: *mut SandboxBuilder, spec: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || spec.is_null() { + if b.is_null() { return b; } - let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_allow_bind(spec))) + let builder = match setter_arg(spec, "net_allow_bind") { + Ok(spec) => builder.net_allow_bind(spec), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// Append a `--net-deny-bind` port spec: a comma-separated list of single @@ -479,18 +607,23 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow_bind( /// surface as a build error. /// /// # Safety -/// `b` and `spec` must be valid pointers. +/// `b` must be a valid builder pointer. `spec` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `spec` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny_bind( b: *mut SandboxBuilder, spec: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || spec.is_null() { + if b.is_null() { return b; } - let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_deny_bind(spec))) + let builder = match setter_arg(spec, "net_deny_bind") { + Ok(spec) => builder.net_deny_bind(spec), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety @@ -536,33 +669,43 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_user( // ---------------------------------------------------------------- /// # Safety -/// `b` and `rule` must be valid pointers. +/// `b` must be a valid builder pointer. `rule` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `rule` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_http_allow( b: *mut SandboxBuilder, rule: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || rule.is_null() { + if b.is_null() { return b; } - let rule = CStr::from_ptr(rule).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.http_allow(rule))) + let builder = match setter_arg(rule, "http_allow") { + Ok(rule) => builder.http_allow(rule), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `rule` must be valid pointers. +/// `b` must be a valid builder pointer. `rule` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `rule` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_http_deny( b: *mut SandboxBuilder, rule: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || rule.is_null() { + if b.is_null() { return b; } - let rule = CStr::from_ptr(rule).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.http_deny(rule))) + let builder = match setter_arg(rule, "http_deny") { + Ok(rule) => builder.http_deny(rule), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety @@ -580,63 +723,83 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_http_port( } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_http_ca( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.http_ca(path))) + let builder = match setter_arg(path, "http_ca") { + Ok(path) => builder.http_ca(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_http_key( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.http_key(path))) + let builder = match setter_arg(path, "http_key") { + Ok(path) => builder.http_key(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_http_inject_ca( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.http_inject_ca(path))) + let builder = match setter_arg(path, "http_inject_ca") { + Ok(path) => builder.http_inject_ca(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` and `path` must be valid pointers. +/// `b` must be a valid builder pointer. `path` must be null or point at a +/// NUL-terminated string; a null or non-UTF-8 `path` is reported by +/// `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_http_ca_out( b: *mut SandboxBuilder, path: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || path.is_null() { + if b.is_null() { return b; } - let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.http_ca_out(path))) + let builder = match setter_arg(path, "http_ca_out") { + Ok(path) => builder.http_ca_out(path), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } // ---------------------------------------------------------------- @@ -672,20 +835,24 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_clean_env( } /// # Safety -/// `b`, `key`, and `value` must be valid pointers. +/// `b` must be a valid builder pointer. `key` and `value` must each be null +/// or point at a NUL-terminated string; a null or non-UTF-8 half is reported +/// by `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_env_var( b: *mut SandboxBuilder, key: *const c_char, value: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || key.is_null() || value.is_null() { + if b.is_null() { return b; } - let key = CStr::from_ptr(key).to_str().unwrap_or(""); - let value = CStr::from_ptr(value).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.env_var(key, value))) + let builder = match (setter_arg(key, "env_var key"), setter_arg(value, "env_var value")) { + (Ok(key), Ok(value)) => builder.env_var(key, value), + (Err(reason), _) | (_, Err(reason)) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety @@ -704,43 +871,53 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_time_start( } /// # Safety -/// `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. +/// `b` must be a valid builder pointer. `names` must be null or point at a +/// comma-separated NUL-terminated string; a null or non-UTF-8 `names` is +/// reported by `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_extra_deny_syscalls( b: *mut SandboxBuilder, names: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || names.is_null() { + if b.is_null() { return b; } let builder = *Box::from_raw(b); - let s = CStr::from_ptr(names).to_str().unwrap_or(""); - let calls: Vec = s - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - Box::into_raw(Box::new(builder.extra_deny_syscalls(calls))) + let builder = match setter_arg(names, "extra_deny_syscalls") { + Ok(s) => builder.extra_deny_syscalls( + s.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>(), + ), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// # Safety -/// `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. +/// `b` must be a valid builder pointer. `names` must be null or point at a +/// comma-separated NUL-terminated string; a null or non-UTF-8 `names` is +/// reported by `sandlock_sandbox_build` rather than dereferenced or coerced. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_extra_allow_syscalls( b: *mut SandboxBuilder, names: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() || names.is_null() { + if b.is_null() { return b; } let builder = *Box::from_raw(b); - let s = CStr::from_ptr(names).to_str().unwrap_or(""); - let names: Vec = s - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - Box::into_raw(Box::new(builder.extra_allow_syscalls(names))) + let builder = match setter_arg(names, "extra_allow_syscalls") { + Ok(s) => builder.extra_allow_syscalls( + s.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>(), + ), + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } /// Resolve a syscall name (e.g. `"openat"`) to its kernel syscall @@ -772,6 +949,13 @@ pub unsafe extern "C" fn sandlock_syscall_nr(name: *const c_char) -> i64 { } } +/// Set the open file-descriptor limit (RLIMIT_NOFILE, soft and hard). +/// +/// Zero is refused, reported by `sandlock_sandbox_build`: the child needs +/// descriptors to reach `main`, so a zero cap kills it before the workload +/// starts with an errno far from the setting responsible. A workable floor is +/// well above 1. Omit the call to inherit the system limit. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -899,8 +1083,15 @@ pub extern "C" fn sandlock_protection_min_abi(protection: u32) -> u32 { /// Returns the (possibly relocated) builder pointer, mirroring the /// move-semantics convention used by every other /// `sandlock_sandbox_builder_*` setter. A null `b` is returned -/// unchanged. An unknown `protection` discriminant is treated as a -/// no-op: the builder is returned untouched. +/// unchanged. +/// +/// An unknown `protection` discriminant is a static bug in the calling +/// binding, not a runtime condition, exactly as it is for +/// `sandlock_sandbox_builder_on_exit`. It is latched in the builder and +/// reported by `sandlock_sandbox_build`, which returns -1 with a message +/// naming this setter and the offending value. It used to be dropped, so a +/// binding built against a newer header was told nothing when an older +/// library did not recognise what it sent. /// /// # Safety /// `b` must be a valid builder pointer returned by @@ -914,12 +1105,14 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( if b.is_null() { return b; } - let p = match try_protection_from_raw(protection) { - Some(p) => p, - None => return b, - }; let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.allow_degraded(p))) + let builder = match try_protection_from_raw(protection) { + Some(p) => builder.allow_degraded(p), + None => builder.reject(format!( + "allow_degraded: unrecognized protection {protection}" + )), + }; + Box::into_raw(Box::new(builder)) } /// Mark `protection` as disabled on the builder: never enforced, even @@ -928,8 +1121,15 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( /// Returns the (possibly relocated) builder pointer, mirroring the /// move-semantics convention used by every other /// `sandlock_sandbox_builder_*` setter. A null `b` is returned -/// unchanged. An unknown `protection` discriminant is treated as a -/// no-op: the builder is returned untouched. +/// unchanged. +/// +/// An unknown `protection` discriminant is a static bug in the calling +/// binding, not a runtime condition, exactly as it is for +/// `sandlock_sandbox_builder_on_exit`. It is latched in the builder and +/// reported by `sandlock_sandbox_build`, which returns -1 with a message +/// naming this setter and the offending value. It used to be dropped, so a +/// binding built against a newer header was told nothing when an older +/// library did not recognise what it sent. /// /// # Safety /// `b` must be a valid builder pointer returned by @@ -943,12 +1143,14 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_disable( if b.is_null() { return b; } - let p = match try_protection_from_raw(protection) { - Some(p) => p, - None => return b, - }; let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.disable(p))) + let builder = match try_protection_from_raw(protection) { + Some(p) => builder.disable(p), + None => builder.reject(format!( + "disable: unrecognized protection {protection}" + )), + }; + Box::into_raw(Box::new(builder)) } // ---------------------------------------------------------------- diff --git a/crates/sandlock-ffi/tests/builder_pending_error.rs b/crates/sandlock-ffi/tests/builder_pending_error.rs new file mode 100644 index 00000000..24fc5011 --- /dev/null +++ b/crates/sandlock-ffi/tests/builder_pending_error.rs @@ -0,0 +1,408 @@ +//! Integration tests for the builder's pending-error latch and for the setters +//! that need it. +//! +//! `sandlock_sandbox_builder_on_exit` / `_on_error` take a raw `u8` +//! discriminant and have no error channel of their own. A value outside the +//! documented set is a static bug in the calling binding, so the setter latches +//! it in the builder and `sandlock_sandbox_build` reports it: -1 plus a message +//! naming the setter and the offending value. It used to be coerced to +//! `Commit`, which silently ran a branch policy nobody asked for (and, for +//! `on_error`, discarded the guest work on the error path by committing it). +//! +//! These drive the FFI symbols directly (no C compilation step), the same way +//! `tests/protection.rs` does. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; +use std::ptr; + +use sandlock_core::sandbox::{BranchAction, Sandbox, SandboxBuilder}; +use sandlock_ffi::{ + sandlock_sandbox_build, sandlock_sandbox_builder_chroot, sandlock_sandbox_builder_cwd, + sandlock_sandbox_builder_env_var, sandlock_sandbox_builder_extra_allow_syscalls, + sandlock_sandbox_builder_extra_deny_syscalls, sandlock_sandbox_builder_fs_deny, + sandlock_sandbox_builder_fs_mount, sandlock_sandbox_builder_fs_mount_ro, + sandlock_sandbox_builder_fs_read, sandlock_sandbox_builder_fs_storage, + sandlock_sandbox_builder_fs_write, sandlock_sandbox_builder_http_allow, + sandlock_sandbox_builder_http_ca, sandlock_sandbox_builder_http_ca_out, + sandlock_sandbox_builder_http_deny, sandlock_sandbox_builder_http_inject_ca, + sandlock_sandbox_builder_http_key, sandlock_sandbox_builder_net_allow, + sandlock_sandbox_builder_net_allow_bind, sandlock_sandbox_builder_net_deny, + sandlock_sandbox_builder_net_deny_bind, sandlock_sandbox_builder_new, + sandlock_sandbox_builder_on_error, sandlock_sandbox_builder_on_exit, + sandlock_sandbox_builder_workdir, sandlock_sandbox_free, sandlock_string_free, +}; + +/// Values no binding should ever pass: `BranchAction` documents 0, 1 and 2. +/// 3 is the classic off-by-one past the last variant, 7 is the value the issue +/// used as its example, and `u8::MAX` is the top of the range. +const INVALID_DISCRIMINANTS: &[u8] = &[3, 7, 42, u8::MAX]; + +/// Run `configure` over a fresh builder and build it through the C ABI. +/// +/// Returns `(built, err, err_msg)` with the message copied out and the original +/// released, so a leak in the test cannot mask one in the implementation. The +/// sandbox itself is freed here: these tests only assert on success/failure. +fn build_via_ffi(configure: F) -> (bool, c_int, Option) +where + F: FnOnce(*mut SandboxBuilder) -> *mut SandboxBuilder, +{ + let b = sandlock_sandbox_builder_new(); + assert!(!b.is_null(), "builder_new returned null"); + let b = configure(b); + assert!(!b.is_null(), "configure returned a null builder"); + + let mut err: c_int = 7; // poison: build must overwrite this + let mut err_msg: *mut c_char = ptr::null_mut(); + // SAFETY: `b` came from builder_new and was possibly relocated by setters. + let sandbox = unsafe { sandlock_sandbox_build(b, &mut err, &mut err_msg) }; + + let built = !sandbox.is_null(); + if built { + unsafe { sandlock_sandbox_free(sandbox) }; + } + 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) + }; + (built, err, msg) +} + +/// Build and assert the latch fired, returning the message for content checks. +fn expect_rejected(configure: F) -> String +where + F: FnOnce(*mut SandboxBuilder) -> *mut SandboxBuilder, +{ + let (built, err, msg) = build_via_ffi(configure); + assert!( + !built, + "build must return null after a rejected setter value" + ); + assert_eq!( + err, -1, + "build must report -1 after a rejected setter value" + ); + msg.expect("a rejected setter value must produce an err_msg") +} + +#[test] +fn on_exit_unrecognized_discriminant_fails_the_build() { + for &raw in INVALID_DISCRIMINANTS { + let msg = expect_rejected(|b| unsafe { sandlock_sandbox_builder_on_exit(b, raw) }); + assert!( + msg.contains("on_exit"), + "message must name the setter, got: {msg:?}", + ); + assert!( + msg.contains(&raw.to_string()), + "message must quote the offending value {raw}, got: {msg:?}", + ); + } +} + +#[test] +fn on_error_unrecognized_discriminant_fails_the_build() { + for &raw in INVALID_DISCRIMINANTS { + let msg = expect_rejected(|b| unsafe { sandlock_sandbox_builder_on_error(b, raw) }); + assert!( + msg.contains("on_error"), + "message must name the setter, got: {msg:?}", + ); + assert!( + msg.contains(&raw.to_string()), + "message must quote the offending value {raw}, got: {msg:?}", + ); + } +} + +#[test] +fn documented_discriminants_still_build_and_reach_the_sandbox() { + // Without this, an implementation that rejected every value would pass the + // tests above. Read the action back off the built Sandbox so the assertion + // covers the translation, not just the absence of an error. + for (raw, expected) in [ + (0u8, BranchAction::Commit), + (1, BranchAction::Abort), + (2, BranchAction::Keep), + ] { + let b = sandlock_sandbox_builder_new(); + let b = unsafe { sandlock_sandbox_builder_on_exit(b, raw) }; + let b = unsafe { sandlock_sandbox_builder_on_error(b, raw) }; + // SAFETY: `b` came from builder_new and was relocated by the setters. + let sandbox = unsafe { *Box::from_raw(b) } + .build() + .expect("documented discriminants must build"); + assert_eq!(sandbox.on_exit, expected, "on_exit({raw}) mistranslated"); + assert_eq!(sandbox.on_error, expected, "on_error({raw}) mistranslated"); + } +} + +#[test] +fn a_latched_error_survives_later_valid_calls() { + // The latch is not a transient flag: a valid call after the bad one must + // not clear it, or a binding could hide its own bug by setting the field + // twice. + let msg = expect_rejected(|b| unsafe { + let b = sandlock_sandbox_builder_on_exit(b, 9); + sandlock_sandbox_builder_on_exit(b, 1) + }); + assert!( + msg.contains("on_exit") && msg.contains('9'), + "the latched error must survive the later valid call, got: {msg:?}", + ); +} + +#[test] +fn the_first_rejection_wins() { + // Two bad values, one per setter: the message must name the first one. The + // earliest bad input is the one that explains the rest. + let msg = expect_rejected(|b| unsafe { + let b = sandlock_sandbox_builder_on_exit(b, 3); + sandlock_sandbox_builder_on_error(b, 4) + }); + assert!( + msg.contains("on_exit") && msg.contains('3'), + "the first rejection must win, got: {msg:?}", + ); + assert!( + !msg.contains("on_error"), + "the later rejection must not overwrite the first, got: {msg:?}", + ); +} + +#[test] +fn cloning_a_rejected_builder_keeps_it_rejected() { + // `SandboxBuilder: Clone` is hand-written, so a forgotten field here would + // make `.clone().build()` a laundering channel for rejected input. The + // pipeline API clones builders, so this is reachable, not theoretical. + let b = sandlock_sandbox_builder_new(); + let b = unsafe { sandlock_sandbox_builder_on_exit(b, 7) }; + // SAFETY: `b` came from builder_new and was relocated by the setter. + let builder = unsafe { *Box::from_raw(b) }; + let err = builder + .clone() + .build() + .expect_err("a clone of a rejected builder must stay rejected"); + let msg = err.to_string(); + assert!( + msg.contains("on_exit") && msg.contains('7'), + "the clone must carry the original reason, got: {msg:?}", + ); +} + +#[test] +fn build_unchecked_also_refuses_a_rejected_builder() { + // `build_unchecked` is public and is what sandlock-oci calls. Checking only + // in `build()` would let rejected input straight through that path. + let err = SandboxBuilder::default() + .reject("on_exit: unrecognized branch action 7") + .build_unchecked() + .expect_err("build_unchecked must refuse a rejected builder"); + assert!( + err.to_string().contains("on_exit"), + "build_unchecked must report the latched reason, got: {err}", + ); +} + +/// Build through the C ABI setters and hand back the `Sandbox`, so a test can +/// read what the core parsed instead of only observing that nothing failed. +fn build_ok(configure: F) -> Sandbox +where + F: FnOnce(*mut SandboxBuilder) -> *mut SandboxBuilder, +{ + let b = sandlock_sandbox_builder_new(); + assert!(!b.is_null(), "builder_new returned null"); + let b = configure(b); + assert!(!b.is_null(), "configure returned a null builder"); + // SAFETY: `b` came from builder_new and was relocated by the setters. + unsafe { *Box::from_raw(b) } + .build() + .expect("a value the core accepts must build") +} + +// ---------------------------------------------------------------- +// Every `*const c_char` builder setter reports what it cannot represent +// ---------------------------------------------------------------- + +/// One `*const c_char` builder setter, by the name it reports itself under. +type StrSetter = ( + &'static str, + fn(*mut SandboxBuilder, *const c_char) -> *mut SandboxBuilder, +); + +fn string_setters() -> Vec { + vec![ + ("fs_read", |b, s| unsafe { sandlock_sandbox_builder_fs_read(b, s) }), + ("fs_write", |b, s| unsafe { sandlock_sandbox_builder_fs_write(b, s) }), + ("fs_deny", |b, s| unsafe { sandlock_sandbox_builder_fs_deny(b, s) }), + ("fs_storage", |b, s| unsafe { sandlock_sandbox_builder_fs_storage(b, s) }), + ("workdir", |b, s| unsafe { sandlock_sandbox_builder_workdir(b, s) }), + ("cwd", |b, s| unsafe { sandlock_sandbox_builder_cwd(b, s) }), + ("chroot", |b, s| unsafe { sandlock_sandbox_builder_chroot(b, s) }), + ("net_allow", |b, s| unsafe { sandlock_sandbox_builder_net_allow(b, s) }), + ("net_deny", |b, s| unsafe { sandlock_sandbox_builder_net_deny(b, s) }), + ("net_allow_bind", |b, s| unsafe { sandlock_sandbox_builder_net_allow_bind(b, s) }), + ("net_deny_bind", |b, s| unsafe { sandlock_sandbox_builder_net_deny_bind(b, s) }), + ("http_allow", |b, s| unsafe { sandlock_sandbox_builder_http_allow(b, s) }), + ("http_deny", |b, s| unsafe { sandlock_sandbox_builder_http_deny(b, s) }), + ("http_ca", |b, s| unsafe { sandlock_sandbox_builder_http_ca(b, s) }), + ("http_key", |b, s| unsafe { sandlock_sandbox_builder_http_key(b, s) }), + ("http_inject_ca", |b, s| unsafe { sandlock_sandbox_builder_http_inject_ca(b, s) }), + ("http_ca_out", |b, s| unsafe { sandlock_sandbox_builder_http_ca_out(b, s) }), + ("extra_deny_syscalls", |b, s| unsafe { + sandlock_sandbox_builder_extra_deny_syscalls(b, s) + }), + ("extra_allow_syscalls", |b, s| unsafe { + sandlock_sandbox_builder_extra_allow_syscalls(b, s) + }), + ] +} + +#[test] +fn a_non_utf8_setter_argument_is_reported_by_every_string_setter() { + // A path is an arbitrary byte string on Linux: `readdir()` hands one back + // and a C caller forwards it. `to_str().unwrap_or("")` turned that into + // the empty string, so the core either diagnosed a value nobody passed or, + // for the grant side, recorded an empty path. An empty path is a prefix of + // every guest path, which is how it voids an allowlist rather than merely + // losing one entry. + let bytes = CString::new(&b"\xff\xfe/secret"[..]).unwrap(); + for (name, set) in string_setters() { + let msg = expect_rejected(|b| set(b, bytes.as_ptr())); + assert!( + msg.contains(name) && msg.contains("UTF-8"), + "{name} must report the non-UTF-8 argument, got: {msg:?}", + ); + } +} + +#[test] +fn a_null_setter_argument_is_reported_by_every_string_setter() { + // The other half: a null argument used to return the builder untouched, so + // the grant, deny or limit the caller asked for simply was not there. + for (name, set) in string_setters() { + let msg = expect_rejected(|b| set(b, ptr::null())); + assert!( + msg.contains(name) && msg.contains("NULL"), + "{name} must report the null argument, got: {msg:?}", + ); + } +} + +#[test] +fn an_env_var_pair_reports_whichever_half_it_cannot_represent() { + let good = CString::new("KEY").unwrap(); + let bad = CString::new(&b"\xff\xfe"[..]).unwrap(); + + let msg = expect_rejected(|b| unsafe { + sandlock_sandbox_builder_env_var(b, bad.as_ptr(), good.as_ptr()) + }); + assert!(msg.contains("env_var key"), "got: {msg:?}"); + + let msg = expect_rejected(|b| unsafe { + sandlock_sandbox_builder_env_var(b, good.as_ptr(), bad.as_ptr()) + }); + assert!(msg.contains("env_var value"), "got: {msg:?}"); + + let msg = expect_rejected(|b| unsafe { + sandlock_sandbox_builder_env_var(b, ptr::null(), good.as_ptr()) + }); + assert!(msg.contains("env_var key") && msg.contains("NULL"), "got: {msg:?}"); +} + +#[test] +fn a_mount_pair_reports_whichever_half_it_cannot_represent() { + // Both mount setters used to route through a helper that answered "add no + // mount" for a null, non-UTF-8 or empty path, which is the coercion this + // commit is about: the caller asked for a mount, got none, and heard + // nothing. For `fs_mount_ro` the two outcomes are a read-only view and a + // writable one. + let good = CString::new("/srv/data").unwrap(); + let bad = CString::new(&b"\xff\xfe"[..]).unwrap(); + let empty = CString::new("").unwrap(); + + type MountSetter = ( + &'static str, + fn(*mut SandboxBuilder, *const c_char, *const c_char) -> *mut SandboxBuilder, + ); + let setters: Vec = vec![ + ("fs_mount", |b, v, h| unsafe { + sandlock_sandbox_builder_fs_mount(b, v, h) + }), + ("fs_mount_ro", |b, v, h| unsafe { + sandlock_sandbox_builder_fs_mount_ro(b, v, h) + }), + ]; + + for (name, set) in setters { + for (arg, what) in [(bad.as_ptr(), "UTF-8"), (ptr::null(), "NULL")] { + let msg = expect_rejected(|b| set(b, arg, good.as_ptr())); + assert!( + msg.contains(&format!("{name} virtual path")) && msg.contains(what), + "{name} must name the virtual path, got: {msg:?}", + ); + let msg = expect_rejected(|b| set(b, good.as_ptr(), arg)); + assert!( + msg.contains(&format!("{name} host path")) && msg.contains(what), + "{name} must name the host path, got: {msg:?}", + ); + } + + // Emptiness is the core's verdict, forwarded rather than pre-empted, so + // it names the setter but not the C ABI's own half labels. + let msg = expect_rejected(|b| set(b, empty.as_ptr(), good.as_ptr())); + assert!( + msg.contains(name) && msg.contains("virtual path") && msg.contains("empty"), + "{name} must forward the core's empty-path verdict, got: {msg:?}", + ); + let msg = expect_rejected(|b| set(b, good.as_ptr(), empty.as_ptr())); + assert!( + msg.contains(name) && msg.contains("host path") && msg.contains("empty"), + "{name} must forward the core's empty-path verdict, got: {msg:?}", + ); + } + + // The guard rail: a well-formed pair still lands, and `fs_mount_ro` still + // marks the virtual path read-only. + let sandbox = build_ok(|b| unsafe { + let virt = CString::new("/data").unwrap(); + let virt_ro = CString::new("/ref").unwrap(); + let b = sandlock_sandbox_builder_fs_mount(b, virt.as_ptr(), good.as_ptr()); + sandlock_sandbox_builder_fs_mount_ro(b, virt_ro.as_ptr(), good.as_ptr()) + }); + assert_eq!(sandbox.fs_mount.len(), 2); + assert_eq!(sandbox.fs_mount_ro, vec![std::path::PathBuf::from("/ref")]); +} + +#[test] +fn a_valid_string_setter_argument_is_untouched_by_the_check() { + // The guard rail: the checks above must not have made the ordinary path + // reject anything. One representative from each family. + let sandbox = build_ok(|b| unsafe { + let read = CString::new("/usr").unwrap(); + let deny = CString::new("/etc/shadow").unwrap(); + let rule = CString::new("tcp://example.com:443").unwrap(); + let key = CString::new("LANG").unwrap(); + let value = CString::new("C").unwrap(); + let calls = CString::new("ptrace, mount").unwrap(); + let b = sandlock_sandbox_builder_fs_read(b, read.as_ptr()); + let b = sandlock_sandbox_builder_fs_deny(b, deny.as_ptr()); + let b = sandlock_sandbox_builder_net_allow(b, rule.as_ptr()); + let b = sandlock_sandbox_builder_env_var(b, key.as_ptr(), value.as_ptr()); + sandlock_sandbox_builder_extra_deny_syscalls(b, calls.as_ptr()) + }); + assert_eq!(sandbox.fs_readable, vec![std::path::PathBuf::from("/usr")]); + assert_eq!(sandbox.fs_denied, vec![std::path::PathBuf::from("/etc/shadow")]); + assert_eq!(sandbox.net_allow.len(), 1); + assert_eq!(sandbox.env.get("LANG").map(String::as_str), Some("C")); + assert_eq!( + sandbox.extra_deny_syscalls, + vec!["ptrace".to_string(), "mount".to_string()], + ); +} diff --git a/crates/sandlock-ffi/tests/fs_mount.rs b/crates/sandlock-ffi/tests/fs_mount.rs index 10ab275f..287ac83b 100644 --- a/crates/sandlock-ffi/tests/fs_mount.rs +++ b/crates/sandlock-ffi/tests/fs_mount.rs @@ -119,129 +119,104 @@ fn builder_mount_setters_chain_and_stay_distinguishable() { } #[test] -fn builder_fs_mount_ro_tolerates_null_arguments() { +fn builder_fs_mount_ro_tolerates_a_null_builder() { + // Null builder in, null out: the convention every other + // `sandlock_sandbox_builder_*` setter follows. The builder pointer is the + // only argument that still gets that treatment, because it is the thing a + // reason would have been latched on. let (vp, hp) = (cstr("/work"), cstr("/host/work")); - - // Null in, builder out unchanged: the convention every other - // `sandlock_sandbox_builder_*` setter follows. let out = unsafe { sandlock_sandbox_builder_fs_mount_ro(ptr::null_mut(), vp.as_ptr(), hp.as_ptr()) }; assert!(out.is_null(), "fs_mount_ro(null, _, _) must return null"); - - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount_ro(b, ptr::null(), hp.as_ptr()) - }); - assert!( - sandbox.fs_mount.is_empty() && sandbox.fs_mount_ro.is_empty(), - "a null virtual_path must add no mount, got {:?} / {:?}", - sandbox.fs_mount, - sandbox.fs_mount_ro, - ); - - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount_ro(b, vp.as_ptr(), ptr::null()) - }); - assert!( - sandbox.fs_mount.is_empty() && sandbox.fs_mount_ro.is_empty(), - "a null host_path must add no mount, got {:?} / {:?}", - sandbox.fs_mount, - sandbox.fs_mount_ro, - ); -} - -#[test] -fn builder_fs_mount_ro_refuses_empty_paths() { - // An empty virtual path is a prefix of *every* path, so recording it - // would mount the whole tree and make ChrootCtx::can_read return true - // everywhere, and the read allowlist would be gone. Core's - // parse_mount_spec rejects empty components for the same reason, so - // the C ABI must not be the one door that accepts them. - let (vp, hp) = (cstr("/work"), cstr("/host/work")); - let empty = cstr(""); - - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount_ro(b, empty.as_ptr(), hp.as_ptr()) - }); - assert!( - sandbox.fs_mount.is_empty() && sandbox.fs_mount_ro.is_empty(), - "an empty virtual_path must add no mount, got {:?} / {:?}", - sandbox.fs_mount, - sandbox.fs_mount_ro, - ); - - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount_ro(b, vp.as_ptr(), empty.as_ptr()) - }); - assert!( - sandbox.fs_mount.is_empty() && sandbox.fs_mount_ro.is_empty(), - "an empty host_path must add no mount, got {:?} / {:?}", - sandbox.fs_mount, - sandbox.fs_mount_ro, - ); } -#[test] -fn builder_fs_mount_ro_refuses_non_utf8_paths() { - // A lossy conversion would have collapsed these to "", i.e. to the - // tree-wide mount above; dropping the mount is the fail-closed choice - // for a setter with no error channel. - let (vp, hp) = (cstr("/work"), cstr("/host/work")); - let bad = CString::new(vec![b'/', 0xff, b'x']).unwrap(); - - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount_ro(b, bad.as_ptr(), hp.as_ptr()) - }); - assert!( - sandbox.fs_mount.is_empty() && sandbox.fs_mount_ro.is_empty(), - "a non-UTF-8 virtual_path must add no mount, got {:?} / {:?}", - sandbox.fs_mount, - sandbox.fs_mount_ro, - ); - - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount_ro(b, vp.as_ptr(), bad.as_ptr()) - }); - assert!( - sandbox.fs_mount.is_empty() && sandbox.fs_mount_ro.is_empty(), - "a non-UTF-8 host_path must add no mount, got {:?} / {:?}", - sandbox.fs_mount, - sandbox.fs_mount_ro, - ); +/// Run `builder_new` + the supplied setter chain + `build()`, returning the +/// error text a caller of `sandlock_sandbox_build` would read. +fn build_error_via_ffi(configure: F) -> String +where + F: FnOnce( + *mut sandlock_core::sandbox::SandboxBuilder, + ) -> *mut sandlock_core::sandbox::SandboxBuilder, +{ + let b = sandlock_sandbox_builder_new(); + assert!(!b.is_null(), "builder_new returned null"); + let b = configure(b); + assert!(!b.is_null(), "configure returned null builder"); + // SAFETY: `b` is a valid Box pointer produced by builder_new and possibly + // relocated through builder setters. + let builder = unsafe { *Box::from_raw(b) }; + builder + .build() + .expect_err("a mount path the setter cannot use must fail the build") + .to_string() } #[test] -fn builder_fs_mount_refuses_unusable_paths_exactly_like_fs_mount_ro() { - // The plain setter is the *more* dangerous door for the same input: - // an empty virtual path there voids the write allowlist as well as the - // read one (`ChrootCtx::can_write` short-circuits on `is_mounted`), - // with no read-only marking left to hold writes closed. Both setters - // must therefore drop the mount rather than degrade the path to "". - // `sandlock_sandbox_builder_fs_mount` is what the Go binding - // (go/sandlock_linux.go) and the Python `Sandbox` dataclass - // (python/src/sandlock/_sdk.py) call, so this is the reachable one. +fn both_mount_setters_report_a_path_they_cannot_use() { + // These four inputs used to add no mount and say nothing, which is the + // worst of the two outcomes for either setter. For `fs_mount_ro` the + // caller believes a subtree is read-only and it is writable; for + // `fs_mount` the caller believes a host directory is exposed and it is + // not. Both now travel back through the builder's pending-error latch, so + // `sandlock_sandbox_build` returns -1 with the reason. + // + // Which layer answers which question: a null pointer and non-UTF-8 bytes + // are representation problems the core cannot see once the value is a + // `&str`, so the C ABI diagnoses them; emptiness is a policy question and + // the core's setter answers it, the same way `parse_mount_spec` answers it + // for a `VIRTUAL:HOST` profile spec. An empty virtual path is the one that + // matters: it is a prefix of every guest path, so `ChrootCtx::is_mounted` + // would match the whole tree and short-circuit `can_read` and `can_write`. let good = cstr("/work"); - let bad_utf8 = CString::new(vec![b'/', 0xff, b'x']).unwrap(); + let host = cstr("/host/work"); let empty = cstr(""); + let bad_utf8 = CString::new(vec![b'/', 0xff, b'x']).unwrap(); - let cases: [(&str, &CString, &CString); 4] = [ - ("empty virtual_path", &empty, &good), - ("empty host_path", &good, &empty), - ("non-UTF-8 virtual_path", &bad_utf8, &good), - ("non-UTF-8 host_path", &good, &bad_utf8), - ]; - - for (label, vp, hp) in cases { - let sandbox = build_via_ffi(|b| unsafe { - sandlock_sandbox_builder_fs_mount(b, vp.as_ptr(), hp.as_ptr()) - }); - assert!( - sandbox.fs_mount.is_empty(), - "fs_mount with {label} must add no mount, got {:?}", - sandbox.fs_mount, - ); - // Nothing marks it read-only either, so a recorded mount here would - // be a tree-wide read-write mapping. - assert!(sandbox.fs_mount_ro.is_empty()); + type MountSetter = unsafe extern "C" fn( + *mut sandlock_core::sandbox::SandboxBuilder, + *const c_char, + *const c_char, + ) -> *mut sandlock_core::sandbox::SandboxBuilder; + + for (setter_name, setter) in [ + ( + "fs_mount", + sandlock_sandbox_builder_fs_mount as MountSetter, + ), + ( + "fs_mount_ro", + sandlock_sandbox_builder_fs_mount_ro as MountSetter, + ), + ] { + let cases: [(&str, *const c_char, *const c_char, &str); 6] = [ + ("empty virtual path", empty.as_ptr(), host.as_ptr(), "empty"), + ("empty host path", good.as_ptr(), empty.as_ptr(), "empty"), + ("non-UTF-8 virtual path", bad_utf8.as_ptr(), host.as_ptr(), "UTF-8"), + ("non-UTF-8 host path", good.as_ptr(), bad_utf8.as_ptr(), "UTF-8"), + ("null virtual path", ptr::null(), host.as_ptr(), "NULL"), + ("null host path", good.as_ptr(), ptr::null(), "NULL"), + ]; + + for (label, vp, hp, expected) in cases { + let msg = build_error_via_ffi(|b| unsafe { setter(b, vp, hp) }); + assert!( + msg.contains(setter_name), + "{setter_name} with {label} must name the setter, got: {msg}", + ); + assert!( + msg.contains(expected), + "{setter_name} with {label} must say what is wrong, got: {msg}", + ); + let half = if label.ends_with("virtual path") { + "virtual path" + } else { + "host path" + }; + assert!( + msg.contains(half), + "{setter_name} with {label} must name the half to fix, got: {msg}", + ); + } } } diff --git a/crates/sandlock-ffi/tests/protection.rs b/crates/sandlock-ffi/tests/protection.rs index eca58eff..591bcc19 100644 --- a/crates/sandlock-ffi/tests/protection.rs +++ b/crates/sandlock-ffi/tests/protection.rs @@ -109,6 +109,27 @@ where builder.build().expect("build failed") } +/// Same as [`build_via_ffi`], for a configuration the build must refuse. +/// Returns the error text so the test can assert on what it names. +fn build_err_via_ffi(configure: F) -> String +where + F: FnOnce( + *mut sandlock_core::sandbox::SandboxBuilder, + ) -> *mut sandlock_core::sandbox::SandboxBuilder, +{ + let b = sandlock_sandbox_builder_new(); + assert!(!b.is_null(), "builder_new returned null"); + let b = configure(b); + assert!(!b.is_null(), "configure returned null builder"); + // SAFETY: `b` is a valid Box pointer produced by builder_new and + // possibly relocated through builder setters. + let builder = unsafe { *Box::from_raw(b) }; + builder + .build() + .expect_err("build must refuse a discriminant the library does not know") + .to_string() +} + #[test] fn builder_allow_degraded_marks_protection_degradable() { let sandbox = @@ -209,52 +230,45 @@ fn protection_min_abi_returns_zero_sentinel_for_unknown_discriminant() { } #[test] -fn allow_degraded_with_unknown_discriminant_is_a_noop() { - // The builder pointer must be returned untouched, and the - // resulting Sandbox must have no `Degradable` state set. +fn allow_degraded_with_unknown_discriminant_is_reported() { + // It used to be dropped and the build used to succeed, so a binding built + // against a newer header was told nothing when an older library did not + // recognise the protection it asked to be degradable: the caller believed + // it had opted out, and the protection stayed strict. for &raw in INVALID_DISCRIMINANTS { - let sandbox = build_via_ffi(|b| unsafe { sandlock_sandbox_builder_allow_degraded(b, raw) }); - for p in Protection::all() { - assert_eq!( - sandbox.protection_policy.state(p), - ProtectionState::Strict, - "raw discriminant {} must leave {:?} at the default Strict state", - raw, - p, - ); - } + let err = build_err_via_ffi(|b| unsafe { sandlock_sandbox_builder_allow_degraded(b, raw) }); + assert!( + err.contains("allow_degraded") && err.contains(&raw.to_string()), + "discriminant {raw} must be named by the build error, got {err:?}", + ); } } #[test] -fn disable_with_unknown_discriminant_is_a_noop() { +fn disable_with_unknown_discriminant_is_reported() { for &raw in INVALID_DISCRIMINANTS { - let sandbox = build_via_ffi(|b| unsafe { sandlock_sandbox_builder_disable(b, raw) }); - for p in Protection::all() { - assert_eq!( - sandbox.protection_policy.state(p), - ProtectionState::Strict, - "raw discriminant {} must leave {:?} at the default Strict state", - raw, - p, - ); - } + let err = build_err_via_ffi(|b| unsafe { sandlock_sandbox_builder_disable(b, raw) }); + assert!( + err.contains("disable") && err.contains(&raw.to_string()), + "discriminant {raw} must be named by the build error, got {err:?}", + ); } } #[test] -fn unknown_discriminant_does_not_corrupt_subsequent_valid_calls() { - // A bad call must not poison the builder — a following valid call - // must succeed normally. Catches a class of bug where the bad path - // leaks/double-frees the builder allocation. - let sandbox = build_via_ffi(|b| unsafe { +fn a_later_valid_call_does_not_wash_out_an_unknown_discriminant() { + // The latch survives every setter that follows it, so a binding cannot + // hide a discriminant the library did not understand behind a call it did. + // This also still covers the memory-safety property the previous version + // of this test watched: the rejecting path must hand back a builder the + // following setters can keep using, with no leak or double free. + let err = build_err_via_ffi(|b| unsafe { let b = sandlock_sandbox_builder_allow_degraded(b, 9999); let b = sandlock_sandbox_builder_disable(b, u32::MAX); sandlock_sandbox_builder_disable(b, PROT_SIGNAL_SCOPE) }); - assert_eq!( - sandbox.protection_policy.state(Protection::SignalScope), - ProtectionState::Disabled, - "valid call after two invalid ones must still take effect", + assert!( + err.contains("9999"), + "the first unrecognized discriminant must be the one reported, got {err:?}", ); } diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 75c15505..b7ade623 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -279,7 +279,7 @@ 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` | `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. | +| `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. An empty virtual or host path is refused at build time: an empty virtual path is a prefix of every guest path, so it would match the whole tree. | | `on_exit` | `on_exit` | `BranchAction` | `BranchAction.COMMIT` | Branch action on normal sandbox exit. | | `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. | @@ -352,14 +352,14 @@ prefix redundant; the GPU and CPU placement fields keep their names. | Python | TOML | Type | Default | Description | | ---------------- | ------------- | ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `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_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. `0` is refused at build time: it is also the supervisor's sentinel for "no ceiling", so an explicit zero would install a ceiling of zero while the synthetic `/proc/meminfo` reported the sandbox unlimited. Omit the field to leave memory unlimited. | +| `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. `0` is refused at build time: the supervisor compares `proc_count >= limit`, so a limit of zero denies every fork with `EAGAIN` however few processes are alive. Omit the field for the default cap. | +| `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. `0` is refused at build time; omit the field to inherit the system limit. | | `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` | `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. | +| `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. `0` is accepted, unlike `max_memory`: for a disk quota zero is the documented spelling of "unlimited" and has no second reading. | | `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. | +| `cpu_cores` | `cpu_cores` | `Sequence[int] \| None` | `None` | CPU cores to pin the sandbox to via `sched_setaffinity` in the child. An empty sequence is refused at build time: an affinity mask with no bits is what `sched_setaffinity(2)` rejects with `EINVAL`, and unlike `gpu_devices` there is no cpu set it could stand for, since `None` already means "every core". | +| `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. `0` is refused at build time: it reaches the synthetic procfs as an empty `/proc/cpuinfo` and an affinity mask with no bits, so the guest reads `nproc = 0`. Omit the field to expose the host processor count. | ## Runtime kwargs (Python-only) diff --git a/python/tests/test_cli_parity.py b/python/tests/test_cli_parity.py index 818f81c9..3d67d1a2 100644 --- a/python/tests/test_cli_parity.py +++ b/python/tests/test_cli_parity.py @@ -186,17 +186,18 @@ class Case: 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. + name="byte_size_zero_disk", + # "0" is a size the grammar reads, and the disk quota is the knob that + # takes it: zero is its spelling of "unlimited". The memory ceiling + # refuses the same text (see the memory_zero reject), so the grammar + # and the policy are pinned apart rather than together. toml=""" [filesystem] read = {base_read} [limits] - memory = "0" + disk = "0" """, - compare="load", - expect={"max_memory": 0}, + expect={"max_disk": 0}, ), Case( name="limits_scalars", @@ -464,6 +465,10 @@ class Case: ("cpu_zero", "[limits]\ncpu = 0\n"), ("cpu_above_hundred", "[limits]\ncpu = 101\n"), ("open_files_zero", "[limits]\nopen_files = 0\n"), + ("processes_zero", "[limits]\nprocesses = 0\n"), + ("num_cpus_zero", "[limits]\nnum_cpus = 0\n"), + ("memory_zero", '[limits]\nmemory = "0"\n'), + ("cpu_cores_empty", "[limits]\ncpu_cores = []\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'), diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index 96b0c71d..f6167fcf 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -252,12 +252,23 @@ class TestCoreParity: @pytest.mark.parametrize( "size,expected", - [("512M", 512 * 1024 ** 2), ("1G", 1024 ** 3), ("512", 512), ("0", 0)], + [("512M", 512 * 1024 ** 2), ("1G", 1024 ** 3), ("512", 512)], ) 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 + def test_zero_is_a_size_the_grammar_reads_and_a_ceiling_it_refuses(self): + # "0" parses: it is a well-formed count of bytes, and the disk quota + # takes it as its spelling of "unlimited". The memory ceiling does not, + # because zero is what the supervisor already carries for "no ceiling", + # so the two readings are told apart at the one place that can tell + # them apart. Both verdicts come from the core, not from here. + assert policy_from_toml('[limits]\ndisk = "0"\n').max_disk == 0 + with pytest.raises(PolicyError) as excinfo: + policy_from_toml('[limits]\nmemory = "0"\n') + assert "max_memory must be greater than 0" in str(excinfo.value) + @pytest.mark.parametrize( "size,fragment", [ diff --git a/python/tests/test_profile_abi_edge_cases.py b/python/tests/test_profile_abi_edge_cases.py index c57bece3..34c95c71 100644 --- a/python/tests/test_profile_abi_edge_cases.py +++ b/python/tests/test_profile_abi_edge_cases.py @@ -260,7 +260,10 @@ 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 + # The smallest legal size, on the knob that takes it: zero is the disk + # quota's spelling of "unlimited", while the memory ceiling refuses it + # because zero is what the supervisor already carries for "no ceiling". + assert policy_from_toml('[limits]\ndisk = "0"\n').max_disk == 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