diff --git a/Cargo.lock b/Cargo.lock index 77e69509..56cdd49c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1372,7 +1372,6 @@ version = "0.8.6" dependencies = [ "anyhow", "clap", - "jiff", "libc", "sandlock-core", "serde", 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-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index ec81cdeb..d4ff667d 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -20,7 +20,6 @@ anyhow = "1" toml = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" -jiff = "0.2" libc = "0.2" [dev-dependencies] diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index b4c56a95..db15f187 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Result}; use sandlock_core::policy_fn::{SyscallEvent, Verdict}; use sandlock_core::profile::{FilesystemSection, ProfileInput}; -use sandlock_core::sandbox::BranchAction; +use sandlock_core::sandbox::{BranchAction, ByteSize}; use sandlock_core::Sandbox; @@ -686,7 +686,10 @@ pub async fn run(args: LearnArgs) -> Result<()> { profile_out.network.allow_bind = merged_bind; // Limits: take the max of old vs observed. - profile_out.limits.memory = max_bytesize(existing.limits.memory.as_deref(), observed.limits.memory.as_deref()); + profile_out.limits.memory = max_bytesize( + existing.limits.memory.as_deref(), + observed.limits.memory.as_deref(), + )?; profile_out.limits.processes = max_opt(existing.limits.processes, observed.limits.processes); profile_out.limits.open_files = max_opt(existing.limits.open_files, observed.limits.open_files); } @@ -727,32 +730,29 @@ pub async fn run(args: LearnArgs) -> Result<()> { } -/// Parse a bytesize string like "128M", "1G", "512K" into bytes. -fn parse_bytesize_bytes(s: &str) -> Option { - let s = s.trim(); - let (num, mult) = if let Some(n) = s.strip_suffix('G') { - (n, 1024 * 1024 * 1024u64) - } else if let Some(n) = s.strip_suffix('M') { - (n, 1024 * 1024u64) - } else if let Some(n) = s.strip_suffix('K') { - (n, 1024u64) - } else { - (s, 1u64) - }; - num.trim().parse::().ok().map(|n| n * mult) -} - /// Return the larger of two optional bytesize strings. -fn max_bytesize(a: Option<&str>, b: Option<&str>) -> Option { - match (a, b) { +/// +/// Both sides are read with the core's own grammar. A merge file is written by +/// hand, so a size it carries is whatever the flag and the profile accept, and +/// a second grammar here would disagree with them: this one was case sensitive +/// where `ByteSize::parse` is not, so `512m` resolved to nothing, and the +/// caller's `unwrap_or(0)` then made it the smaller of the two. A profile +/// merged against `1M` came back with a ceiling five hundred times lower than +/// the one it went in with, and nothing said so. +fn max_bytesize(a: Option<&str>, b: Option<&str>) -> Result> { + let parse = |s: &str| { + ByteSize::parse(s) + .map(|b| b.0) + .map_err(|e| anyhow!("[limits].memory in the merge file: {e}")) + }; + Ok(match (a, b) { (None, None) => None, (Some(s), None) | (None, Some(s)) => Some(s.to_string()), (Some(sa), Some(sb)) => { - let va = parse_bytesize_bytes(sa).unwrap_or(0); - let vb = parse_bytesize_bytes(sb).unwrap_or(0); + let (va, vb) = (parse(sa)?, parse(sb)?); Some(if va >= vb { sa.to_string() } else { sb.to_string() }) } - } + }) } /// Return the larger of two optional u32 values. @@ -763,3 +763,53 @@ fn max_opt(a: Option, b: Option) -> Option { (Some(va), Some(vb)) => Some(va.max(vb)), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merging_a_size_reads_it_with_the_cores_grammar() { + // A lowercase suffix is what the flag and the profile both accept, so + // it has to mean the same here. The private parser this replaced was + // case sensitive, resolved "512m" to nothing, and the caller turned + // that into 0, so merging a 512MiB profile against a 1MiB observation + // silently rewrote the ceiling down to 1MiB. + let merged = max_bytesize(Some("512m"), Some("1M")).unwrap(); + assert_eq!(merged.as_deref(), Some("512m")); + + // The same pair spelled the way the old parser could read, to show the + // comparison itself is unchanged. + assert_eq!( + max_bytesize(Some("512M"), Some("1M")).unwrap().as_deref(), + Some("512M") + ); + assert_eq!( + max_bytesize(Some("1M"), Some("512M")).unwrap().as_deref(), + Some("512M") + ); + } + + #[test] + fn a_size_the_core_refuses_stops_the_merge_instead_of_becoming_zero() { + // Out of range and plain nonsense both used to read as 0 and lose the + // comparison. Naming the section matters: the value comes from a file + // the caller wrote, not from a flag they just typed. + for spec in ["17179869184G", "not-a-size", "1.5G"] { + let err = max_bytesize(Some(spec), Some("1M")) + .expect_err(&format!("{spec} must stop the merge")) + .to_string(); + assert!( + err.contains("[limits].memory"), + "the error must name the field, got {err:?}" + ); + } + } + + #[test] + fn one_sided_and_absent_limits_are_carried_through() { + assert_eq!(max_bytesize(None, None).unwrap(), None); + assert_eq!(max_bytesize(Some("8M"), None).unwrap().as_deref(), Some("8M")); + assert_eq!(max_bytesize(None, Some("8M")).unwrap().as_deref(), Some("8M")); + } +} diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index f5fc5a30..767f7084 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -4,7 +4,6 @@ use sandlock_core::sandbox::{BranchAction, ByteSize, SandboxBuilder}; use sandlock_core::profile; use anyhow::{Result, anyhow}; use std::path::PathBuf; -use std::time::SystemTime; mod learn; #[derive(Parser)] @@ -610,7 +609,7 @@ async fn run_command(args: RunArgs) -> Result { // CLI overrides — non-clap-friendly fields (still parsed here) if let Some(ref m) = args.max_memory { builder = builder.max_memory(ByteSize::parse(m)?); } if let Some(ref ts) = args.time_start { - let t = parse_time_start(ts)?; + let t = profile::parse_time_start(ts, "--time-start")?; builder = builder.time_start(t); } if let Some(ref s) = args.max_disk { builder = builder.max_disk(ByteSize::parse(s)?); } @@ -919,19 +918,6 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> Ok(()) } -/// Render a parsed `NetRule` back into a `--net-allow` / `--net-deny` spec -/// string, so a profile loaded via `--profile-file` round-trips through the -/// builder. Allow and deny share one grammar. The scheme is always -/// rendered: a scheme-less spec parses as a TCP + UDP pair, so a -/// single-protocol rule must carry its scheme to round-trip exactly. -/// IPv6 is bracketed only when a port follows, and the all-ports case -/// drops the redundant `:*`. -fn parse_time_start(s: &str) -> Result { - let ts: jiff::Timestamp = s.parse() - .map_err(|e| anyhow!("invalid --time-start '{}': {}", s, e))?; - Ok(ts.into()) -} - fn parse_branch_action(flag: &str, s: &str) -> Result { match s { "commit" => Ok(BranchAction::Commit), 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/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..3243f0d5 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -1,10 +1,12 @@ use crate::sandbox::{ByteSize, Sandbox}; -use crate::error::SandlockError; +use crate::error::{SandboxError, SandlockError}; use serde::{Deserialize, Serialize}; 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)] @@ -217,7 +219,7 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo // [determinism] if let Some(s) = input.determinism.random_seed { b = b.random_seed(s); } if let Some(s) = input.determinism.time_start.as_deref() { - b = b.time_start(parse_time_start(s)?); + b = b.time_start(parse_time_start(s, TIME_START_LABEL).map_err(SandlockError::Sandbox)?); } if input.determinism.deterministic_dirs { b = b.deterministic_dirs(true); } if input.determinism.no_randomize_memory { b = b.no_randomize_memory(true); } @@ -335,15 +337,71 @@ pub fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf, bool), SandlockErr Ok((PathBuf::from(virt), PathBuf::from(host), read_only)) } +/// The knob name the profile loader puts in a rejected `time_start`. +/// +/// Shared by both profile paths (`parse_input` and the canonical resolver) so +/// the two cannot drift: `parse_error_text_matches_what_the_cli_prints` asserts +/// they produce the same text. +pub(crate) const TIME_START_LABEL: &str = "[determinism].time_start"; + /// Parses an RFC3339 timestamp string into `SystemTime`. -fn parse_time_start(s: &str) -> Result { - use crate::error::SandboxError; - let ts: jiff::Timestamp = s.parse().map_err(|e| { - SandlockError::Sandbox(SandboxError::Invalid( - format!("invalid [determinism].time_start {s:?}: {e}"), - )) - })?; - Ok(ts.into()) +/// +/// This is the one place the `time_start` grammar lives: the profile loader, +/// the CLI's `--time-start` and the C ABI setter all resolve their string +/// through it, so a rejected value reads the same wherever it was typed. +/// `label` names the knob that carried the value, the way +/// [`crate::sandbox::NetRule::parse_allow`] names `--net-allow`; it is the only +/// part of the message that differs between surfaces. +pub fn parse_time_start(s: &str, label: &str) -> Result { + Ok(parse_timestamp(s, label)?.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. +pub(crate) fn parse_timestamp(s: &str, label: &str) -> Result { + s.parse() + .map_err(|e| SandboxError::Invalid(format!("invalid {label} {s:?}: {e}"))) +} + +/// Resolves an epoch split into `SystemTime`, without going through a grammar. +/// +/// This is the numeric door to the instant [`parse_time_start`] reaches through +/// RFC 3339, and it takes exactly the pair +/// [`CanonicalTimestamp`](canonical::CanonicalTimestamp) hands out: whole +/// seconds, which may be negative, plus a non-negative sub-second remainder. +/// +/// It exists because the two halves have to meet. A binding that consumes +/// `sandlock_profile_parse` holds a resolved instant, not the text it came +/// from, and the string setter is the only other way back into a builder; so +/// without this door every such binding would have to render RFC 3339 itself, +/// which is a grammar in the binding and the exact drift this module removes. +/// +/// `nanoseconds` at or above a full second is rejected rather than carried +/// into the seconds: the canonical form normalizes, so a caller that has not +/// is working from a different contract, and quietly agreeing with it would +/// hide that. +pub fn time_start_from_epoch( + seconds: i64, + nanoseconds: u32, + label: &str, +) -> Result { + if nanoseconds >= 1_000_000_000 { + return Err(SandboxError::Invalid(format!( + "invalid {label}: nanoseconds must be below 1000000000, got {nanoseconds}" + ))); + } + // i64 seconds times a billion is about 9.2e27, well inside i128. + let total = i128::from(seconds) * 1_000_000_000 + i128::from(nanoseconds); + jiff::Timestamp::from_nanosecond(total) + .map_err(|e| { + SandboxError::Invalid(format!( + "invalid {label} {{seconds: {seconds}, nanoseconds: {nanoseconds}}}: {e}" + )) + }) + .map(Into::into) } // ============================================================ @@ -423,10 +481,16 @@ fn byte_size_str(b: crate::sandbox::ByteSize) -> String { } /// Render an RFC3339 timestamp from a `SystemTime` (inverse of `parse_time_start`). +/// +/// Both halves of the grammar have to survive the round trip, so this goes +/// through the same signed conversion `parse_time_start` comes back on rather +/// than through `duration_since`, which is unsigned and errors before 1970, +/// and rather than through whole seconds, which drops the sub-second part. +/// Rendering either of those away would make the effective policy this prints +/// (`sandlock inspect --toml`, the control-socket `config` verb, `learn`) +/// disagree with the policy that is actually running. fn time_start_str(t: SystemTime) -> Option { - let d = t.duration_since(SystemTime::UNIX_EPOCH).ok()?; - let ts = jiff::Timestamp::from_second(d.as_secs() as i64).ok()?; - Some(ts.to_string()) + jiff::Timestamp::try_from(t).ok().map(|ts| ts.to_string()) } /// Build a `ProfileInput` from a `Sandbox` (the effective policy). @@ -578,13 +642,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. @@ -638,6 +709,28 @@ mod tests { ); } + #[test] + fn time_start_round_trips_through_the_effective_policy() { + // The two spellings the grammar accepts and a `SystemTime` cannot + // carry as a plain second count: an instant before 1970, and a + // sub-second remainder. Both reach `Sandbox::time_start` from a + // profile, so both have to come back out of one. + for stamp in [ + "1969-07-20T20:17:00Z", + "2026-01-01T00:00:00.5Z", + "1969-12-31T23:59:59.5Z", + "2026-01-01T00:00:00Z", + ] { + let (sb, _) = parse_profile(&format!("[determinism]\ntime_start = \"{stamp}\"\n")) + .unwrap_or_else(|e| panic!("{stamp} must parse: {e}")); + assert_eq!( + sandbox_to_profile(&sb, &[]).determinism.time_start.as_deref(), + Some(stamp), + "{stamp} did not survive the round trip through the effective policy", + ); + } + } + #[test] fn list_profiles_empty_dir() { // With no profile dir, list_profiles() should return an empty vec. @@ -868,6 +961,49 @@ mod tests { assert!(msg.contains("time_start"), "got: {msg}"); } + /// The two doors into `time_start` have to reach the same instant, or a + /// profile means one thing when it is loaded as text and another when a + /// binding feeds the resolved form back. + #[test] + fn epoch_door_and_grammar_door_reach_the_same_instant() { + for text in [ + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00.5Z", + "1969-07-20T20:17:00Z", + // Half a second before the epoch: the case where the canonical + // split borrows, so seconds is -1 and the remainder is positive. + "1969-12-31T23:59:59.5Z", + "1970-01-01T00:00:00Z", + ] { + let ts = parse_timestamp(text, "time_start").unwrap(); + let split = canonical::CanonicalTimestamp::from(ts); + let through_epoch = + time_start_from_epoch(split.seconds, split.nanoseconds, "time_start").unwrap(); + assert_eq!( + through_epoch, + parse_time_start(text, "time_start").unwrap(), + "{text} resolved differently through the epoch door" + ); + } + } + + #[test] + fn epoch_door_rejects_an_unnormalized_remainder() { + let err = time_start_from_epoch(0, 1_000_000_000, "time_start").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("nanoseconds must be below 1000000000"), + "got: {msg}" + ); + } + + #[test] + fn epoch_door_rejects_an_instant_outside_the_supported_range() { + let err = time_start_from_epoch(i64::MAX, 0, "time_start").unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("invalid time_start"), "got: {msg}"); + } + #[test] fn profile_network_deny_parses() { let toml = r#" diff --git a/crates/sandlock-core/src/profile/canonical.rs b/crates/sandlock-core/src/profile/canonical.rs new file mode 100644 index 00000000..6fd64b60 --- /dev/null +++ b/crates/sandlock-core/src/profile/canonical.rs @@ -0,0 +1,1215 @@ +//! 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, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CanonicalTimestamp { + pub seconds: i64, + pub nanoseconds: u32, + /// The instant re-rendered as RFC 3339, for the same reason as + /// [`CanonicalNetRule::spec`]: the builder ABI takes `time_start` as a + /// string, so a consumer holding this form has something to forward that + /// it did not have to render itself. + /// + /// Without it, a binding whose scalar type cannot hold both halves is + /// pushed back into a lossy one. The Python SDK collapsed the pair into a + /// double, which has about 238ns of spacing at 2026 epoch values, so + /// `"...T00:00:00.9999999Z"` rounded up to the next whole second and the + /// profile ran one second later through the SDK than through the CLI. + pub rfc3339: String, +} + +/// `[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, super::TIME_START_LABEL).map_err(SandlockError::Sandbox)?, + )), + 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, + rfc3339: ts.to_string(), + } + } +} + +/// `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, + "rfc3339": "2026-01-01T00:00:00Z", + }) + ); + } + + #[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, + "rfc3339": "2026-01-01T00:00:00.25Z", + }) + ); + } + + #[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, + "rfc3339": "1969-12-31T23:59:59.5Z", + }) + ); + } + + #[test] + fn the_rendered_time_start_parses_back_to_the_same_instant() { + // The point of carrying the text: a consumer forwards it to the string + // setter instead of rendering RFC 3339 itself, which would be a second + // grammar in the binding. That only holds if what we render is what + // the grammar reads. + for text in [ + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00.25Z", + "2026-01-01T00:00:00+03:00", + "1969-12-31T23:59:59.5Z", + "1969-07-20T20:17:00Z", + // Finer than a double can hold at these magnitudes: the case that + // made a consumer that re-derived the text from the pair round to + // the next whole second. + "2026-01-01T00:00:00.9999999Z", + ] { + let v = json(&format!("[determinism]\ntime_start = \"{text}\"")); + let rendered = v["determinism"]["time_start"]["rfc3339"].as_str().unwrap(); + assert_eq!( + super::super::parse_timestamp(rendered, "time_start").unwrap(), + super::super::parse_timestamp(text, "time_start").unwrap(), + "{text} rendered as {rendered}, which is a different instant", + ); + } + } + + #[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..457e3aeb 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, + } + } } // ============================================================ @@ -2958,7 +2988,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)?)); @@ -2974,8 +3004,12 @@ fn parse_allow_bind_ports(specs: &[String], label: &str) -> Result Result, SandboxError> { +/// `lo-hi` ranges (`8000-8010`). +/// +/// This is the definition of that grammar. Every surface forwards its specs +/// here as written; none of them re-implements it, and this function does not +/// follow any of them. +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/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/src/time.rs b/crates/sandlock-core/src/time.rs index 8a24a6bb..aa96eb16 100644 --- a/crates/sandlock-core/src/time.rs +++ b/crates/sandlock-core/src/time.rs @@ -15,20 +15,32 @@ const CLOCK_MONOTONIC_RAW: u32 = 4; const CLOCK_MONOTONIC_COARSE: u32 = 6; const CLOCK_BOOTTIME: u32 = 7; +/// Whole seconds between `t` and the UNIX epoch, negative before it. +/// +/// `SystemTime::duration_since` reports an earlier instant as `Err`, so the +/// usual `.unwrap_or_default()` collapses every pre-1970 instant to the epoch. +/// The grammar the profile, the CLI and the C ABI all share accepts such an +/// instant (`"1969-07-20T20:17:00Z"` parses), so swallowing the sign here made +/// the sandbox run a clock the caller never asked for, with no error anywhere. +/// +/// Rounding is floor in both directions, matching `CanonicalTimestamp`: half a +/// second before the epoch is second -1, not second 0. +fn epoch_seconds(t: SystemTime) -> i64 { + match t.duration_since(SystemTime::UNIX_EPOCH) { + Ok(d) => d.as_secs() as i64, + Err(e) => { + let d = e.duration(); + let whole = d.as_secs() as i64; + if d.subsec_nanos() > 0 { -whole - 1 } else { -whole } + } + } +} + /// Calculate the time offset in seconds. /// offset = desired_start_time - current_real_time /// So that: virtual_time = real_time + offset pub(crate) fn calculate_time_offset(time_start: SystemTime) -> i64 { - let now = SystemTime::now(); - let desired = time_start - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let actual = now - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - desired - actual + epoch_seconds(time_start) - epoch_seconds(SystemTime::now()) } /// Handle clock_nanosleep/timerfd_settime/timer_settime with TIMER_ABSTIME. @@ -137,4 +149,51 @@ mod tests { let adjusted = shifted_deadline - offset; assert_eq!(adjusted, 1700003600); } + + #[test] + fn a_pre_epoch_instant_keeps_its_sign() { + // `--time-start 1969-07-20T20:17:00Z` and its profile and C ABI + // spellings all parse. `duration_since(UNIX_EPOCH).unwrap_or_default()` + // used to report this instant as second 0, so the guest ran at + // 1970-01-01T00:00:00Z instead, silently and 14182980 seconds off. + let moon_landing = SystemTime::UNIX_EPOCH - Duration::from_secs(14_182_980); + assert_eq!(epoch_seconds(moon_landing), -14_182_980); + } + + #[test] + fn the_epoch_itself_is_second_zero() { + // Boundary between the two arms: `duration_since` returns `Ok(0)` + // here, so the sign flip must not fire and produce `-0` by the + // sub-second path. + assert_eq!(epoch_seconds(SystemTime::UNIX_EPOCH), 0); + } + + #[test] + fn a_sub_second_pre_epoch_instant_floors_like_the_canonical_form() { + // `CanonicalTimestamp` documents half a second before the epoch as + // `{seconds: -1, nanoseconds: 500000000}`. This agrees, so the same + // stamp means the same second whichever surface read it. + assert_eq!( + epoch_seconds(SystemTime::UNIX_EPOCH - Duration::from_millis(500)), + -1, + ); + assert_eq!( + epoch_seconds(SystemTime::UNIX_EPOCH + Duration::from_millis(500)), + 0, + ); + } + + #[test] + fn a_pre_epoch_start_reaches_the_guest_as_itself() { + // What the guest's clock reads: real_time + offset. Within a second, + // because `calculate_time_offset` samples `now` itself. + let moon_landing = SystemTime::UNIX_EPOCH - Duration::from_secs(14_182_980); + let offset = calculate_time_offset(moon_landing); + let now = epoch_seconds(SystemTime::now()); + let landed = now + offset; + assert!( + (landed - -14_182_980).abs() <= 1, + "a pre-epoch start must reach the guest as itself, not as the epoch; got {landed}", + ); + } } 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..6461f374 --- /dev/null +++ b/crates/sandlock-core/tests/profile_canonical_adversarial.rs @@ -0,0 +1,447 @@ +//! 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 + ); + // 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); + 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..a6cee026 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,58 @@ 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 limit from a byte-size string, e.g. `"512M"`. + * + * `size` uses the grammar the core accepts everywhere else: a decimal integer + * with an optional `K`/`M`/`G` suffix (case-insensitive), where a bare number + * is a count of bytes. The core parses it, so a binding does not have to + * carry a grammar of its own and cannot drift from this one. + * + * A value the core rejects is latched in the builder and reported by + * `sandlock_sandbox_build`, which returns -1 with the core's own message. + * `"0"` is one of them: zero 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` reported the sandbox unlimited. Omit the call to + * leave memory unlimited. + * * # Safety - * `b` must be a valid builder pointer. + * `b` must be a valid builder pointer. `size` must be null or a + * NUL-terminated string. */ -sandlock_builder_t *sandlock_sandbox_builder_max_memory(sandlock_builder_t *b, uint64_t bytes); +sandlock_builder_t *sandlock_sandbox_builder_max_memory(sandlock_builder_t *b, const char *size); /** + * Set the disk limit from a byte-size string, e.g. `"10G"`. + * + * Same grammar and same error path as + * [`sandlock_sandbox_builder_max_memory`], with one difference: `"0"` is + * accepted, because for a COW storage quota zero is the documented spelling + * of "unlimited" and has no second reading. + * * # Safety - * `b` must be a valid builder pointer. + * `b` must be a valid builder pointer. `size` must be null or a + * NUL-terminated string. */ -sandlock_builder_t *sandlock_sandbox_builder_max_disk(sandlock_builder_t *b, uint64_t bytes); +sandlock_builder_t *sandlock_sandbox_builder_max_disk(sandlock_builder_t *b, const char *size); /** + * 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 +504,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 +538,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 +559,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 +573,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 +598,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 +620,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,28 +665,79 @@ 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, const char *value); /** + * Set the sandbox start time from an RFC3339 timestamp, e.g. + * `"2026-01-01T00:00:00Z"`. + * + * The core parses it, the same way it parses `[determinism].time_start` in a + * profile and `--time-start` on the command line. This takes a string rather + * than an epoch count so the ABI can carry what the grammar can express: + * sub-second precision, an explicit offset, and instants before 1970, none of + * which fit in the unsigned second count this setter used to take. + * + * A value the core rejects is latched in the builder and reported by + * `sandlock_sandbox_build`, which returns -1 with the core's own message. + * * # Safety - * `b` must be a valid builder pointer. `epoch_secs` is seconds since UNIX epoch. + * `b` must be a valid builder pointer. `timestamp` must be null or a + * NUL-terminated string. */ -sandlock_builder_t *sandlock_sandbox_builder_time_start(sandlock_builder_t *b, uint64_t epoch_secs); +sandlock_builder_t *sandlock_sandbox_builder_time_start(sandlock_builder_t *b, + const char *timestamp); /** + * Set the sandbox start time from an already-resolved epoch instant. + * + * `seconds` is a signed count of whole seconds since 1970-01-01T00:00:00Z and + * `nanoseconds` is a sub-second remainder below one second, which is exactly + * the pair `sandlock_profile_parse` reports under + * `[determinism].time_start`. Note the normalization: half a second before + * the epoch is `{-1, 500000000}`, not `{0, -500000000}`. + * + * This is the numeric counterpart of + * [`sandlock_sandbox_builder_time_start`], not a second grammar. It exists + * for the caller whose value is a number and never was text: an epoch count + * out of `datetime.timestamp()`, out of arithmetic on one, or out of a parsed + * profile. Without it such a caller would have to render RFC 3339 itself + * before it could pass anything, which is a grammar *writer* in the binding, + * with its own ways to be wrong about offsets and sub-second digits, one line + * away from the reader the string setter exists to abolish. A caller that + * still has the user's text must use the string setter instead, so the + * grammar is read once, by the core. + * + * A pair the core rejects (an unnormalized remainder, an instant outside the + * supported range) is latched in the builder and reported by + * `sandlock_sandbox_build`, which returns -1 with the core's own message. + * * # Safety - * `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. + * `b` must be a valid builder pointer. + */ +sandlock_builder_t *sandlock_sandbox_builder_time_start_epoch(sandlock_builder_t *b, + int64_t seconds, + uint32_t nanoseconds); + +/** + * # Safety + * `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 +761,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 +815,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 +840,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 @@ -712,6 +878,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", "rfc3339"}`, + * 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 +1140,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..d800e2c7 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,46 +393,88 @@ 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 limit from a byte-size string, e.g. `"512M"`. +/// +/// `size` uses the grammar the core accepts everywhere else: a decimal integer +/// with an optional `K`/`M`/`G` suffix (case-insensitive), where a bare number +/// is a count of bytes. The core parses it, so a binding does not have to +/// carry a grammar of its own and cannot drift from this one. +/// +/// A value the core rejects is latched in the builder and reported by +/// `sandlock_sandbox_build`, which returns -1 with the core's own message. +/// `"0"` is one of them: zero 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` reported the sandbox unlimited. Omit the call to +/// leave memory unlimited. +/// /// # Safety -/// `b` must be a valid builder pointer. +/// `b` must be a valid builder pointer. `size` must be null or a +/// NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_max_memory( b: *mut SandboxBuilder, - bytes: u64, + size: *const c_char, ) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.max_memory(ByteSize(bytes)))) + let builder = match setter_arg(size, "max_memory") { + Ok(s) => match ByteSize::parse(s) { + Ok(v) => builder.max_memory(v), + Err(e) => builder.reject_error(e), + }, + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } +/// Set the disk limit from a byte-size string, e.g. `"10G"`. +/// +/// Same grammar and same error path as +/// [`sandlock_sandbox_builder_max_memory`], with one difference: `"0"` is +/// accepted, because for a COW storage quota zero is the documented spelling +/// of "unlimited" and has no second reading. +/// /// # Safety -/// `b` must be a valid builder pointer. +/// `b` must be a valid builder pointer. `size` must be null or a +/// NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_max_disk( b: *mut SandboxBuilder, - bytes: u64, + size: *const c_char, ) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.max_disk(ByteSize(bytes)))) + let builder = match setter_arg(size, "max_disk") { + Ok(s) => match ByteSize::parse(s) { + Ok(v) => builder.max_disk(v), + Err(e) => builder.reject_error(e), + }, + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } +/// 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 +503,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 +524,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 +561,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 +606,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 +631,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 +693,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 +747,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,75 +859,151 @@ 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)) } +/// Set the sandbox start time from an RFC3339 timestamp, e.g. +/// `"2026-01-01T00:00:00Z"`. +/// +/// The core parses it, the same way it parses `[determinism].time_start` in a +/// profile and `--time-start` on the command line. This takes a string rather +/// than an epoch count so the ABI can carry what the grammar can express: +/// sub-second precision, an explicit offset, and instants before 1970, none of +/// which fit in the unsigned second count this setter used to take. +/// +/// A value the core rejects is latched in the builder and reported by +/// `sandlock_sandbox_build`, which returns -1 with the core's own message. +/// /// # Safety -/// `b` must be a valid builder pointer. `epoch_secs` is seconds since UNIX epoch. +/// `b` must be a valid builder pointer. `timestamp` must be null or a +/// NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_time_start( b: *mut SandboxBuilder, - epoch_secs: u64, + timestamp: *const c_char, ) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); - let t = std::time::UNIX_EPOCH + Duration::from_secs(epoch_secs); - Box::into_raw(Box::new(builder.time_start(t))) + let builder = match setter_arg(timestamp, "time_start") { + Ok(s) => match sandlock_core::profile::parse_time_start(s, "time_start") { + Ok(t) => builder.time_start(t), + Err(e) => builder.reject_error(e), + }, + Err(reason) => builder.reject(reason), + }; + Box::into_raw(Box::new(builder)) } +/// Set the sandbox start time from an already-resolved epoch instant. +/// +/// `seconds` is a signed count of whole seconds since 1970-01-01T00:00:00Z and +/// `nanoseconds` is a sub-second remainder below one second, which is exactly +/// the pair `sandlock_profile_parse` reports under +/// `[determinism].time_start`. Note the normalization: half a second before +/// the epoch is `{-1, 500000000}`, not `{0, -500000000}`. +/// +/// This is the numeric counterpart of +/// [`sandlock_sandbox_builder_time_start`], not a second grammar. It exists +/// for the caller whose value is a number and never was text: an epoch count +/// out of `datetime.timestamp()`, out of arithmetic on one, or out of a parsed +/// profile. Without it such a caller would have to render RFC 3339 itself +/// before it could pass anything, which is a grammar *writer* in the binding, +/// with its own ways to be wrong about offsets and sub-second digits, one line +/// away from the reader the string setter exists to abolish. A caller that +/// still has the user's text must use the string setter instead, so the +/// grammar is read once, by the core. +/// +/// A pair the core rejects (an unnormalized remainder, an instant outside the +/// supported range) is latched in the builder and reported by +/// `sandlock_sandbox_build`, which returns -1 with the core's own message. +/// /// # Safety -/// `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. +/// `b` must be a valid builder pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_time_start_epoch( + b: *mut SandboxBuilder, + seconds: i64, + nanoseconds: u32, +) -> *mut SandboxBuilder { + if b.is_null() { + return b; + } + let builder = *Box::from_raw(b); + let builder = + match sandlock_core::profile::time_start_from_epoch(seconds, nanoseconds, "time_start") { + Ok(t) => builder.time_start(t), + Err(e) => builder.reject_error(e), + }; + Box::into_raw(Box::new(builder)) +} + +/// # Safety +/// `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 +1035,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 +1169,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 +1191,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 +1207,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 +1229,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)) } // ---------------------------------------------------------------- @@ -1021,6 +1309,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", "rfc3339"}`, +/// 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 +2071,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/builder_pending_error.rs b/crates/sandlock-ffi/tests/builder_pending_error.rs new file mode 100644 index 00000000..55f2f25e --- /dev/null +++ b/crates/sandlock-ffi/tests/builder_pending_error.rs @@ -0,0 +1,727 @@ +//! 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). +//! +//! The latch is also what lets the size and time setters take the strings a +//! user writes instead of pre-parsed numbers. They used to take `uint64_t`, +//! which forced every binding to carry its own grammar, and the two SDKs that +//! did agreed with each other while both diverging from the core (fractions and +//! a `T` suffix the core has never accepted). Now the core parses, and a +//! rejected value comes back with the core's own text. +//! +//! 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 std::time::{Duration, UNIX_EPOCH}; + +use sandlock_core::profile::parse_time_start; +use sandlock_core::sandbox::{BranchAction, ByteSize, Sandbox, SandboxBuilder}; +use sandlock_ffi::{ + sandlock_profile_parse, 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_max_disk, sandlock_sandbox_builder_max_memory, + 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_time_start, + sandlock_sandbox_builder_time_start_epoch, 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}", + ); +} + +// ---------------------------------------------------------------- +// String setters: the core owns the grammar +// ---------------------------------------------------------------- + +/// 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") +} + +fn set_max_memory(b: *mut SandboxBuilder, value: &str) -> *mut SandboxBuilder { + let c = CString::new(value).expect("test value must not contain NUL"); + unsafe { sandlock_sandbox_builder_max_memory(b, c.as_ptr()) } +} + +fn set_max_disk(b: *mut SandboxBuilder, value: &str) -> *mut SandboxBuilder { + let c = CString::new(value).expect("test value must not contain NUL"); + unsafe { sandlock_sandbox_builder_max_disk(b, c.as_ptr()) } +} + +fn set_time_start(b: *mut SandboxBuilder, value: &str) -> *mut SandboxBuilder { + let c = CString::new(value).expect("test value must not contain NUL"); + unsafe { sandlock_sandbox_builder_time_start(b, c.as_ptr()) } +} + +/// Every shape of the core's byte-size grammar, driven through the C ABI: +/// a bare count of bytes, each documented suffix, the lowercase spellings and +/// surrounding whitespace. Read back off the built `Sandbox`, so a setter that +/// accepted the string and dropped it would fail here. +#[test] +fn max_memory_accepts_the_whole_core_byte_size_grammar() { + for (text, expected) in [ + ("1024", 1024u64), + ("100K", 100 * 1024), + ("512M", 512 * 1024 * 1024), + ("1G", 1024 * 1024 * 1024), + ("100k", 100 * 1024), + ("512m", 512 * 1024 * 1024), + ("1g", 1024 * 1024 * 1024), + (" 512M ", 512 * 1024 * 1024), + // The core trims around the number as well as around the whole string, + // so this is accepted; pinned here because it is the kind of detail a + // hand-written binding parser gets wrong in one direction or the other. + ("512 M", 512 * 1024 * 1024), + ] { + let sandbox = build_ok(|b| set_max_memory(b, text)); + assert_eq!( + sandbox.max_memory, + Some(ByteSize(expected)), + "max_memory({text:?}) reached the sandbox as the wrong size", + ); + } +} + +#[test] +fn max_disk_accepts_the_whole_core_byte_size_grammar() { + for (text, expected) in [ + ("1024", 1024u64), + ("100K", 100 * 1024), + ("10G", 10 * 1024 * 1024 * 1024), + ("10g", 10 * 1024 * 1024 * 1024), + ] { + let sandbox = build_ok(|b| set_max_disk(b, text)); + assert_eq!( + sandbox.max_disk, + Some(ByteSize(expected)), + "max_disk({text:?}) reached the sandbox as the wrong size", + ); + } +} + +/// Values the core rejects, with the exact text a CLI user gets for the same +/// value. `sandlock --max-memory 1.5G` runs `ByteSize::parse` and prints its +/// error (crates/sandlock-cli/src/main.rs), and `build()` puts the latched +/// reason back into `SandboxError::Invalid`, so the two strings must be equal +/// rather than merely similar. A setter that reworded the diagnosis, or parsed +/// the string itself, fails this. +#[test] +fn max_memory_rejects_with_the_text_the_cli_prints() { + for value in BAD_BYTE_SIZES { + let msg = expect_rejected(|b| set_max_memory(b, value)); + let cli = format!("{}", ByteSize::parse(value).unwrap_err()); + assert_eq!(msg, cli, "max_memory({value:?}) must speak with the core"); + } +} + +#[test] +fn max_disk_rejects_with_the_text_the_cli_prints() { + for value in BAD_BYTE_SIZES { + let msg = expect_rejected(|b| set_max_disk(b, value)); + let cli = format!("{}", ByteSize::parse(value).unwrap_err()); + assert_eq!(msg, cli, "max_disk({value:?}) must speak with the core"); + } +} + +/// `1.5G` and `512T` are not arbitrary: both SDKs accepted them while the core +/// never has, because each shipped a parser of its own and the two agreed with +/// each other instead of with the grammar they were feeding. Routing the string +/// to the core is what makes them errors here. +const BAD_BYTE_SIZES: &[&str] = &[ + "", + " ", + "1.5G", + "512T", + "not_a_size", + "M", + "-1", + "17179869184G", +]; + +/// The `time_start` grammar, including the three things the old `uint64_t` +/// epoch-seconds parameter could not carry: sub-second precision, a non-UTC +/// offset, and an instant before 1970. +#[test] +fn time_start_accepts_the_core_timestamp_grammar() { + // 2026-01-01T00:00:00Z, and 1969-07-20T20:17:00Z (Apollo 11 touchdown), + // both as plain epoch counts so the expectation does not go through the + // parser under test. + let y2026 = UNIX_EPOCH + Duration::from_secs(1_767_225_600); + for (text, expected) in [ + ("2026-01-01T00:00:00Z", y2026), + ("2026-01-01T00:00:00+00:00", y2026), + // Same instant written in another zone: the offset has to be applied, + // not ignored. + ("2026-01-01T01:00:00+01:00", y2026), + ( + "2026-01-01T00:00:00.500Z", + y2026 + Duration::from_millis(500), + ), + ( + "1969-07-20T20:17:00Z", + UNIX_EPOCH - Duration::from_secs(14_182_980), + ), + ] { + let sandbox = build_ok(|b| set_time_start(b, text)); + assert_eq!( + sandbox.time_start, + Some(expected), + "time_start({text:?}) reached the sandbox as the wrong instant", + ); + } +} + +/// Values the core rejects. The message must be the core's, so the only thing +/// that differs from what a CLI user sees for `--time-start` is the knob name +/// the surface passes in: same parser, same diagnosis, same value quoted. +#[test] +fn time_start_rejects_with_the_core_diagnosis() { + for value in [ + "", + "nope", + // No offset: a local wall-clock reading is not an instant. + "2026-01-01T00:00:00", + // A bare epoch count, which is exactly what this setter used to take. + // It is not RFC3339, so it has to fail rather than be re-interpreted. + "1700000000", + "2026-13-01T00:00:00Z", + ] { + let msg = expect_rejected(|b| set_time_start(b, value)); + let core = format!("{}", parse_time_start(value, "time_start").unwrap_err()); + assert_eq!(msg, core, "time_start({value:?}) must speak with the core"); + + let cli = format!("{}", parse_time_start(value, "--time-start").unwrap_err()); + assert_eq!( + msg.replacen("time_start", "--time-start", 1), + cli, + "only the knob name may differ from the CLI text, got: {msg:?}", + ); + } +} + +/// A null pointer and a non-UTF-8 byte string are the two conditions the core +/// cannot see, and the only ones the C ABI diagnoses itself. The old setters +/// took a number and could not meet them; the sibling string setters in this +/// file still answer both with `unwrap_or("")` or a silent return, which hands +/// the core a value the caller never passed. +#[test] +fn a_null_or_non_utf8_argument_is_latched_rather_than_swallowed() { + let msg = expect_rejected(|b| unsafe { sandlock_sandbox_builder_max_memory(b, ptr::null()) }); + assert!( + msg.contains("max_memory") && msg.contains("NULL"), + "a null size must name the setter and the condition, got: {msg:?}", + ); + + let msg = expect_rejected(|b| unsafe { sandlock_sandbox_builder_time_start(b, ptr::null()) }); + assert!( + msg.contains("time_start") && msg.contains("NULL"), + "a null timestamp must name the setter and the condition, got: {msg:?}", + ); + + // 0xFF is not a valid UTF-8 lead byte anywhere in a sequence. + let garbage = CString::new(vec![0x35u8, 0x31, 0x32, 0xff]).unwrap(); + let msg = + expect_rejected(|b| unsafe { sandlock_sandbox_builder_max_disk(b, garbage.as_ptr()) }); + assert!( + msg.contains("max_disk") && msg.contains("UTF-8"), + "a non-UTF-8 size must name the setter and the condition, got: {msg:?}", + ); +} + +/// One latch, shared by both kinds of setter: a rejected string still wins over +/// a later rejected discriminant, so the message points at the first mistake +/// whichever setter made it. +#[test] +fn the_first_rejection_wins_across_setter_kinds() { + let msg = expect_rejected(|b| { + let b = set_max_memory(b, "1.5G"); + unsafe { sandlock_sandbox_builder_on_exit(b, 7) } + }); + assert!( + msg.contains("1.5G") && !msg.contains("on_exit"), + "the earlier rejected string must win, got: {msg:?}", + ); +} + +// ================================================================ +// The numeric door to `time_start` +// ================================================================ + +/// `sandlock_sandbox_builder_time_start_epoch` is the other half of the string +/// setter, and it exists because a binding that consumes `sandlock_profile_parse` +/// holds a resolved instant rather than the text it came from. Its input is the +/// `{seconds, nanoseconds}` pair that export reports, so the two have to land on +/// the same instant as the grammar the profile was written in; otherwise a +/// profile means one thing when a CLI loads it and another when a binding +/// re-applies what it was told the profile resolved to. +#[test] +fn the_epoch_door_lands_where_the_grammar_door_lands() { + for text in [ + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00.500Z", + "1969-07-20T20:17:00Z", + // Half a second before the epoch, where the canonical split borrows: + // the pair is (-1, 500000000), not (0, -500000000). + "1969-12-31T23:59:59.500Z", + "1970-01-01T00:00:00Z", + ] { + let through_text = build_ok(|b| set_time_start(b, text)).time_start.unwrap(); + + let (seconds, nanoseconds) = epoch_split(text); + let through_epoch = build_ok(|b| unsafe { + sandlock_sandbox_builder_time_start_epoch(b, seconds, nanoseconds) + }) + .time_start + .unwrap(); + + assert_eq!( + through_epoch, through_text, + "{text} resolved differently through the two doors", + ); + } +} + +/// Split a stamp the way `sandlock_profile_parse` reports it, through the core +/// itself: a split computed here would be this test agreeing with itself rather +/// than with the export whose output the setter claims to take. +fn epoch_split(text: &str) -> (i64, u32) { + let json = profile_parse_json(&format!( + "[determinism]\ntime_start = {}\n", + serde_json::to_string(text).unwrap() + )); + let ts = &json["determinism"]["time_start"]; + ( + ts["seconds"].as_i64().unwrap(), + ts["nanoseconds"].as_u64().unwrap() as u32, + ) +} + +/// Resolve a profile through `sandlock_profile_parse` and return its canonical +/// JSON. +fn profile_parse_json(toml: &str) -> serde_json::Value { + let text = CString::new(toml).unwrap(); + let mut err: c_int = 7; + let mut err_msg: *mut c_char = ptr::null_mut(); + // SAFETY: `text` is a valid NUL-terminated string and both out-params are + // live for the call. + let raw = unsafe { sandlock_profile_parse(text.as_ptr(), &mut err, &mut err_msg) }; + assert!(!raw.is_null() && err == 0, "profile_parse refused {toml:?}"); + // SAFETY: a non-null return is an owned NUL-terminated JSON document. + let json = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned(); + unsafe { sandlock_string_free(raw) }; + serde_json::from_str(&json).unwrap() +} + +/// The pair is normalized on both sides: the canonical form never emits a +/// remainder of a whole second or more, so a caller that does is working from a +/// different contract, and agreeing with it silently would hide that. +#[test] +fn the_epoch_door_refuses_an_unnormalized_remainder() { + let msg = expect_rejected(|b| unsafe { + sandlock_sandbox_builder_time_start_epoch(b, 0, 1_000_000_000) + }); + assert!( + msg.contains("nanoseconds must be below 1000000000"), + "an unnormalized remainder must say so, got: {msg:?}", + ); +} + +/// Out of range for the timestamp type, and therefore latched rather than +/// wrapped into some other instant. +#[test] +fn the_epoch_door_refuses_an_instant_it_cannot_represent() { + let msg = + expect_rejected(|b| unsafe { sandlock_sandbox_builder_time_start_epoch(b, i64::MAX, 0) }); + assert!( + msg.contains("time_start"), + "an out-of-range instant must name the knob, got: {msg:?}", + ); +} + +// ---------------------------------------------------------------- +// 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) + }), + ("max_memory", |b, s| unsafe { sandlock_sandbox_builder_max_memory(b, s) }), + ("max_disk", |b, s| unsafe { sandlock_sandbox_builder_max_disk(b, s) }), + ("time_start", |b, s| unsafe { sandlock_sandbox_builder_time_start(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/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/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/profile_parse.rs b/crates/sandlock-ffi/tests/profile_parse.rs new file mode 100644 index 00000000..07b259e2 --- /dev/null +++ b/crates/sandlock-ffi/tests/profile_parse.rs @@ -0,0 +1,367 @@ +//! 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, with the instant re-rendered so a + // consumer whose scalar type cannot hold both halves has something exact + // to forward to the string setter. + assert_eq!( + v["determinism"]["time_start"], + serde_json::json!({ + "seconds": 1767225600i64, + "nanoseconds": 0, + "rfc3339": "2026-01-01T00:00:00Z", + }) + ); + // 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/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 5c05f860..47ba5e3a 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -30,14 +30,14 @@ sandbox = Sandbox( deterministic_dirs=False, no_randomize_memory=False, # [program] (process knobs only; exec/args are arguments to .run/.cmd) - env={}, cwd=None, uid=None, gid=None, + env={}, cwd=None, user=None, clean_env=False, no_coredump=False, no_huge_pages=False, no_supervisor=False, # [filesystem] fs_readable=(), fs_writable=(), fs_denied=(), - chroot=None, fs_mount={}, - on_exit=BranchAction.COMMIT, on_error=BranchAction.ABORT, + chroot=None, fs_mount=(), + on_exit=None, on_error=None, # [network] net_allow_bind=(), net_allow=(), port_remap=False, @@ -49,7 +49,7 @@ sandbox = Sandbox( extra_allow_syscalls=(), extra_deny_syscalls=(), # [limits] - max_memory=None, max_processes=64, max_open_files=None, + max_memory=None, max_processes=None, max_open_files=None, max_cpu=None, max_disk=None, gpu_devices=None, cpu_cores=None, num_cpus=None, @@ -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` | `str \| int \| float \| None` | `None` | Frozen start time. Both surfaces take the RFC 3339 stamp with an explicit offset (`"2026-01-01T00:00:00Z"`), resolved by one parser in the core; the Python field also takes the epoch seconds a loaded profile resolves to, which reach the core through the epoch setter rather than being rendered back into a stamp. 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)`. | @@ -254,12 +253,19 @@ fields on `Sandbox`. | --------------- | --------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `env` | `env` | `Mapping[str, str]` | `{}` | Variables to set or override in the child. Applied after `clean_env`. | | `cwd` | `cwd` | `str \| None` | `None` | Child working directory (`chdir` target). Independent of `workdir`. | -| `uid` | `uid` | `int \| None` | `None` | UID to map the child to inside a user namespace (e.g. `0` for fake root). Must be set together with `gid` (both or neither). The child retains no host privileges regardless of the mapped UID. Requires user namespaces to be available. | -| `gid` | `gid` | `int \| None` | `None` | GID to map the child to inside the user namespace. Must be set together with `uid`. An unprivileged user namespace maps a single id, so supplementary groups are not available. | +| `user` | `uid` + `gid` | `User \| None` | `None` | Identity to map the child to inside a user namespace: `User(uid, gid)`, e.g. `User(0, 0)` for fake root. One value carrying both ids, mirroring the core's own pair, so half-set is not a state the Python field can hold; the two TOML keys must still both be present, which the core checks when the profile is loaded. The child retains no host privileges regardless of the mapped ids, an unprivileged user namespace maps a single pair (so supplementary groups are not available), and user namespaces must be available. | | `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 +278,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`. | -| `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. | +| `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 \| None` | `None` | Branch action on normal sandbox exit. `None` means unset and the core applies its default, `commit`, which is also what a profile that omits the key resolves to. | +| `on_error` | `on_error` | `BranchAction \| None` | `None` | Branch action on sandbox error or exception. `None` means unset and the core applies its default, `commit`, which is also what a profile that omits the key resolves to. The Python field used to default to `BranchAction.ABORT`, a second default that disagreed with the one the core applies to the same field. | Landlock rules are kernel-evaluated and TOCTOU-immune. @@ -345,14 +351,14 @@ 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_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` | `str \| int \| None` | `None` | Memory limit. Both surfaces take the same size grammar (`"512M"`, `"1G"`, or a bare count of bytes), resolved by one parser in the core; a loaded profile carries the resolved count. `None` is the only spelling of "unlimited": a ceiling of zero is refused, because zero is what the supervisor already carries for "no ceiling" while setting the field at all is what installs the memory handler. | +| `max_processes` | `processes` | `int \| None` | `None` | Maximum number of **concurrent** processes in the sandbox (peak, not lifetime; threads do not count). Also enables fork interception used by checkpoint freeze. `None` means unset and the core applies its default of 64, which is not repeated on the Python side. `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. | +| `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` | `str \| None` | `None` | COW storage quota (e.g. `"1G"`). Returned as `ENOSPC` when the upper layer exceeds it. | +| `max_disk` | `disk` | `str \| int \| None` | `None` | COW storage quota, in the same size grammar as `max_memory` on both surfaces. Returned as `ENOSPC` when the upper layer exceeds it. Zero is its documented spelling of "unlimited", unlike `max_memory`. | | `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. Unlike `gpu_devices`, an empty list is refused rather than read as "every core": it is an affinity mask with no bits, and "every core" is what `None` already means. | +| `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/go/README.md b/go/README.md index 1db66635..791b8454 100644 --- a/go/README.md +++ b/go/README.md @@ -97,10 +97,29 @@ fresh native policy on each call. | Syscalls | `ExtraAllowSyscalls`, `ExtraDenySyscalls` | | Determinism | `RandomSeed`, `TimeStart`, `NoRandomizeMemory`, `NoHugePages`, `DeterministicDirs` | | Environment | `CleanEnv`, `Env` | -| Misc | `UID`, `GID`, `NoCoredump`, `Name` | +| Misc | `User`, `NoCoredump`, `Name` | | COW branch | `FSStorage`, `OnExit`, `OnError` | | Dynamic policy | `PolicyFn` | +Values are forwarded to sandlock as written; sandlock parses them and reports +what it cannot accept. So a field whose "unset" state is not expressible in the +value itself is a pointer, set with `sandlock.Ptr`: + +```go +sb := &sandlock.Sandbox{ + MaxCPU: sandlock.Ptr[uint8](50), // 0 is a value sandlock rejects, not "unset" + User: &sandlock.RunAs{UID: 0, GID: 0}, // both ids or neither, as the kernel maps them + OnExit: sandlock.Ptr(sandlock.BranchActionKeep), +} +``` + +`MaxMemory` and `MaxDisk` are byte-size strings in sandlock's own grammar: a +decimal integer with an optional `K`/`M`/`G` suffix, case-insensitive, a bare +number being bytes (`"512M"`, `"1G"`, `"1048576"`). Fractions and a `T` suffix +are not part of it. `TimeStart` is an RFC3339 timestamp +(`"2026-01-01T00:00:00Z"`), which unlike a plain epoch count can carry +sub-second precision, an explicit offset, and instants before 1970. + `NetAllow` entries follow sandlock's rule grammar: bare `host:port` is TCP (`"api.openai.com:443"`, `"github.com:22,443"`, `":53"`); a target may be a host, IP, or CIDR (`"10.0.0.0/8:443"`, `"[2606:4700::/32]:443"`); scheme diff --git a/go/core_verdict_linux_test.go b/go/core_verdict_linux_test.go new file mode 100644 index 00000000..0930fac7 --- /dev/null +++ b/go/core_verdict_linux_test.go @@ -0,0 +1,413 @@ +//go:build linux + +package sandlock_test + +import ( + "context" + "reflect" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + sandlock "github.com/multikernel/sandlock/go" +) + +// The binding forwards configuration values to sandlock and translates its +// verdict; it does not parse or filter them itself. These tests pin that from +// the outside: each one asserts on sandlock's own error text, which the Go SDK +// has no way to produce on its own. They are also the regression guard for the +// grammar the SDK used to carry, so every case here is one the deleted +// go/internal/policy accepted, truncated, or swallowed. +// +// None of them needs Landlock: the policy is rejected while it is being built, +// before any child is forked. + +// buildErr runs sb far enough to build the native policy and returns the error. +// The command never executes in these tests; the build fails first. +func buildErr(t *testing.T, sb *sandlock.Sandbox) string { + t.Helper() + _, err := sb.Run(context.Background(), "true") + if err == nil { + t.Fatal("expected the configuration to be rejected, got no error") + } + return err.Error() +} + +func TestByteSizeGrammarIsTheCores(t *testing.T) { + // The deleted SDK parser took a float and a T suffix and converted with + // uint64(), so it accepted all three of these: "1.5G" and "2T" built a + // sandbox sandlock would have refused, and "0.5K" silently became 512. + cases := []struct { + size string + want string + }{ + {"1.5G", "invalid byte size: 1.5G"}, + {"0.5K", "invalid byte size: 0.5K"}, + {"2T", "unknown byte size suffix: T"}, + {"", "empty byte size string"}, + } + for _, c := range cases { + t.Run("MaxMemory/"+c.size, func(t *testing.T) { + // An empty MaxMemory means "unset" and is not forwarded at all, so + // the empty-string verdict is exercised through a lone space. + size := c.size + if size == "" { + size = " " + } + got := buildErr(t, &sandlock.Sandbox{MaxMemory: size}) + if !strings.Contains(got, c.want) { + t.Fatalf("error = %q, want it to contain sandlock's own %q", got, c.want) + } + }) + t.Run("MaxDisk/"+c.size, func(t *testing.T) { + size := c.size + if size == "" { + size = " " + } + got := buildErr(t, &sandlock.Sandbox{MaxDisk: size}) + if !strings.Contains(got, c.want) { + t.Fatalf("error = %q, want it to contain sandlock's own %q", got, c.want) + } + }) + } +} + +func TestByteSizeGrammarAcceptsWhatTheCoreAccepts(t *testing.T) { + // A bare byte count is part of sandlock's grammar, so it must reach the + // core unchanged rather than being rejected on the way. + requireLandlock(t) + sb := &sandlock.Sandbox{FSReadable: rootfs, MaxMemory: "268435456", MaxDisk: "1G"} + res, err := sb.Run(context.Background(), "true") + if err != nil { + t.Fatalf("Run: %v", err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } +} + +func TestTimeStartGrammarIsTheCores(t *testing.T) { + // The deleted SDK parser resolved a bare number of seconds itself and + // clamped negatives. sandlock parses RFC3339 only, so a plain epoch count + // is now refused with sandlock's own wording instead of silently working. + for _, spec := range []string{"1700000000", "1700000000.5", "not-a-time"} { + t.Run(spec, func(t *testing.T) { + got := buildErr(t, &sandlock.Sandbox{TimeStart: spec}) + if !strings.Contains(got, "time_start") { + t.Fatalf("error = %q, want it to name time_start", got) + } + }) + } +} + +func TestTimeStartCarriesWhatTheOldEpochCountCouldNot(t *testing.T) { + // Sub-second precision and a pre-1970 instant both survive the trip now + // that the ABI takes the timestamp as a string: the u64 epoch count the + // SDK used to compute could express neither (it truncated the fraction and + // the SDK refused the negative outright). + // + // Each case reads the guest's own clock rather than only asserting that + // the run succeeded: the parse landing in the policy is not the same + // claim as the guest running at the requested instant, and the pre-1970 + // case used to satisfy the first while failing the second (the offset + // collapsed to the epoch, so `date` printed 1970-01-01T00:00:00Z). + requireLandlock(t) + for _, tc := range []struct{ spec, want string }{ + {"2026-01-01T00:00:00.5Z", "2026-01-01T00:00:00"}, + {"1969-07-20T20:17:00Z", "1969-07-20T20:17:00"}, + // 03:00 east of UTC, so the same instant reads three hours earlier in UTC. + {"2026-01-01T00:00:00+03:00", "2025-12-31T21:00:00"}, + } { + t.Run(tc.spec, func(t *testing.T) { + sb := &sandlock.Sandbox{FSReadable: rootfs, TimeStart: tc.spec} + res, err := sb.Run(context.Background(), "/usr/bin/date", "-u", "+%Y-%m-%dT%H:%M:%S") + if err != nil { + t.Fatalf("Run: %v", err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + raw := strings.TrimSpace(string(res.Stdout)) + got, err := time.Parse("2006-01-02T15:04:05", raw) + if err != nil { + t.Fatalf("guest printed %q, which is not a timestamp: %v", raw, err) + } + want, err := time.Parse("2006-01-02T15:04:05", tc.want) + if err != nil { + t.Fatalf("bad want %q: %v", tc.want, err) + } + // The guest clock ticks at real speed from the requested start, so + // it has advanced by however long the sandbox took to come up. + if d := got.Sub(want); d < 0 || d > 30*time.Second { + t.Fatalf("guest clock = %q, want %q plus the startup delay (off by %s)", raw, tc.want, d) + } + }) + } +} + +func TestZeroCapsReachTheCore(t *testing.T) { + // Each of these was filtered by a `> 0` guard in the binding, so the + // caller's explicit zero was dropped and the field silently took its + // default. Now the zero travels and sandlock names the setting. + cases := []struct { + name string + sb *sandlock.Sandbox + want string + }{ + {"MaxCPU", &sandlock.Sandbox{MaxCPU: sandlock.Ptr[uint8](0)}, "max_cpu must be 1-100, got 0"}, + {"MaxProcesses", &sandlock.Sandbox{MaxProcesses: sandlock.Ptr[uint32](0)}, "max_processes must be greater than 0"}, + {"MaxOpenFiles", &sandlock.Sandbox{MaxOpenFiles: sandlock.Ptr[uint32](0)}, "max_open_files must be greater than 0"}, + {"NumCPUs", &sandlock.Sandbox{NumCPUs: sandlock.Ptr[uint32](0)}, "num_cpus must be greater than 0"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := buildErr(t, c.sb) + if !strings.Contains(got, c.want) { + t.Fatalf("error = %q, want it to contain sandlock's own %q", got, c.want) + } + }) + } +} + +func TestMaxCPUOutOfRangeKeepsTheCoresWording(t *testing.T) { + got := buildErr(t, &sandlock.Sandbox{MaxCPU: sandlock.Ptr[uint8](200)}) + if !strings.Contains(got, "max_cpu must be 1-100, got 200") { + t.Fatalf("error = %q, want sandlock's own out-of-range message", got) + } +} + +func TestBranchActionDiscriminantsAreTheABIs(t *testing.T) { + // The binding used to prepend its own Default sentinel and send + // action-1, so a value sandlock rejects came back naming a different + // number, and 3 was a valid "keep". Both properties are pinned here: the + // error quotes the number the caller wrote, and 3 is no longer a value. + bad := sandlock.Ptr(sandlock.BranchAction(3)) + cases := []struct { + field string + sb *sandlock.Sandbox + setter string + }{ + {"OnExit", &sandlock.Sandbox{OnExit: bad}, "on_exit"}, + {"OnError", &sandlock.Sandbox{OnError: bad}, "on_error"}, + } + for _, c := range cases { + t.Run(c.field, func(t *testing.T) { + got := buildErr(t, c.sb) + want := c.setter + ": unrecognized branch action 3" + if !strings.Contains(got, want) { + t.Fatalf("error = %q, want it to contain %q", got, want) + } + }) + } +} + +func TestBranchActionCommitIsForwarded(t *testing.T) { + // Commit is discriminant 0, which the old sentinel numbering made + // indistinguishable from "unset" and dropped. It must reach sandlock now. + requireLandlock(t) + dir := t.TempDir() + sb := &sandlock.Sandbox{ + FSReadable: rootfs, + FSWritable: []string{dir}, + Workdir: dir, + OnExit: sandlock.Ptr(sandlock.BranchActionCommit), + OnError: sandlock.Ptr(sandlock.BranchActionAbort), + } + res, err := sb.Run(context.Background(), "true") + if err != nil { + t.Fatalf("Run: %v", err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } +} + +func TestExplicitEmptyNameIsNotAutoGenerated(t *testing.T) { + // The binding used to turn an empty Name into the NULL that asks sandlock + // to invent one, so a caller whose name came from an empty variable got a + // random sandbox instead of a diagnosis. nil is now the only spelling of + // "auto-generate"; a name the caller set is forwarded as written, and + // sandlock refuses the empty one. + requireLandlock(t) + sb := &sandlock.Sandbox{FSReadable: rootfs, Name: sandlock.Ptr("")} + if _, err := sb.Run(context.Background(), "true"); err == nil { + t.Fatal("an explicitly empty Name must be refused, not auto-generated") + } + + // A nil Name still auto-generates, and a real one is still accepted. + for _, name := range []*string{nil, sandlock.Ptr("go-sdk-named")} { + sb := &sandlock.Sandbox{FSReadable: rootfs, Name: name} + res, err := sb.Run(context.Background(), "true") + if err != nil { + t.Fatalf("Run(name=%v): %v", name, err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + } +} + +func TestZeroIsNotUnsetForTheStringSizedCaps(t *testing.T) { + // MaxMemory is a string, so it never met the numeric `> 0` filter that + // used to guard the caps above; the deleted SDK parser turned "0" into a + // uint64 zero and the filter then dropped it, which is why this case + // needs its own test now that the string travels. + // + // Zero is what the supervisor carries internally for "no ceiling", but + // the memory handler is installed on the field being set at all, so an + // explicit zero used to install a handler enforcing a ceiling of zero: + // the guest was SIGKILLed on the loader's first anonymous mmap, with no + // exit status and nothing naming the setting. + got := buildErr(t, &sandlock.Sandbox{FSReadable: rootfs, MaxMemory: "0"}) + if !strings.Contains(got, "max_memory must be greater than 0") { + t.Fatalf("error = %q, want sandlock's own verdict on a zero ceiling", got) + } + + // MaxDisk is deliberately not the same knob: zero is its documented + // spelling of "unlimited", so it must still build. A binding that + // generalised the rule would take a working policy away. + requireLandlock(t) + res, err := (&sandlock.Sandbox{FSReadable: rootfs, MaxDisk: "0"}).Run(context.Background(), "true") + if err != nil { + t.Fatalf("a zero disk quota means unlimited and must be accepted: %v", err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } +} + +func TestAnEmptyCoreSetIsNotAnUnsetOne(t *testing.T) { + // CPUCores moved from a `len(...) > 0` guard to `!= nil`, so an empty + // non-nil slice now reaches sandlock instead of being dropped by the + // binding. It asks for an affinity mask with no bits, which the kernel + // refuses; the child setup used to skip the call for it, so the pinning + // the caller asked for silently did not happen. + got := buildErr(t, &sandlock.Sandbox{FSReadable: rootfs, CPUCores: []uint32{}}) + if !strings.Contains(got, "cpu_cores must name at least one core") { + t.Fatalf("error = %q, want sandlock's own verdict on an empty core set", got) + } + + // GPUDevices shares the shape and not the rule: an empty list there is + // the spelling of "every GPU present", so it must still build. + requireLandlock(t) + res, err := (&sandlock.Sandbox{FSReadable: rootfs, GPUDevices: []uint32{}}).Run(context.Background(), "true") + if err != nil { + t.Fatalf("an empty GPU list means every GPU and must be accepted: %v", err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } +} + +func TestAnUnsetCapIsNeverSent(t *testing.T) { + // The other half of TestZeroCapsReachTheCore: a nil pointer must reach + // no setter at all, so the guest sees the host's own value rather than + // anything this binding chose. Both knobs here are readable from inside + // the sandbox, so the assertion is on what the child actually got, not + // on the build succeeding. + requireLandlock(t) + + read := func(t *testing.T, sb *sandlock.Sandbox, script string) string { + t.Helper() + sb.FSReadable = rootfs + res, err := sb.Run(context.Background(), "sh", "-c", script) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + return strings.TrimSpace(string(res.Stdout)) + } + + hostCPUs := strconv.Itoa(runtime.NumCPU()) + if got := read(t, &sandlock.Sandbox{}, "nproc"); got != hostCPUs { + t.Fatalf("an unset NumCPUs must leave the host count visible: nproc = %q, want %q", got, hostCPUs) + } + if got := read(t, &sandlock.Sandbox{NumCPUs: sandlock.Ptr[uint32](2)}, "nproc"); got != "2" { + t.Fatalf("a set NumCPUs must reach sandlock: nproc = %q, want \"2\"", got) + } + + var lim syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil { + t.Fatalf("Getrlimit: %v", err) + } + hostNoFile := strconv.FormatUint(lim.Cur, 10) + if got := read(t, &sandlock.Sandbox{}, "ulimit -n"); got != hostNoFile { + t.Fatalf("an unset MaxOpenFiles must inherit the host limit: ulimit -n = %q, want %q", got, hostNoFile) + } + if got := read(t, &sandlock.Sandbox{MaxOpenFiles: sandlock.Ptr[uint32](32)}, "ulimit -n"); got != "32" { + t.Fatalf("a set MaxOpenFiles must reach sandlock: ulimit -n = %q, want \"32\"", got) + } +} + +func TestOnlyAUIDIsNotAStateThisBindingCanSpell(t *testing.T) { + // The binding used to carry UID and GID as two separate *int fields and + // answer "UID and GID must both be set" itself. RunAs carries the pair + // the core's own Option carries, so the half-set state is gone + // from the type rather than from a check: this compiles only because + // both ids are required, and stops compiling if either becomes optional. + requireLandlock(t) + var _ = sandlock.RunAs{UID: 1000, GID: 1000} + + f, ok := reflect.TypeOf(sandlock.Sandbox{}).FieldByName("User") + if !ok { + t.Fatal("Sandbox.User is gone; the identity must still be one field") + } + if f.Type.Kind() != reflect.Pointer || f.Type.Elem() != reflect.TypeOf(sandlock.RunAs{}) { + t.Fatalf("Sandbox.User is %s, want *sandlock.RunAs: one pointer is the whole \"unset\"", f.Type) + } + for _, name := range []string{"UID", "GID"} { + id, ok := f.Type.Elem().FieldByName(name) + if !ok { + t.Fatalf("RunAs.%s is gone", name) + } + if id.Type.Kind() == reflect.Pointer { + t.Fatalf("RunAs.%s is %s: an optional id puts the half-set state back", name, id.Type) + } + } + if _, ok := reflect.TypeOf(sandlock.Sandbox{}).FieldByName("UID"); ok { + t.Fatal("Sandbox.UID is back; the pair must be the only spelling") + } + + // And the pair itself still works end to end. An unprivileged user + // namespace can map only the id that created it, so the pair under test + // is this process's own: a hardcoded 1000 passes on a host whose user is + // 1000 and fails to create the sandbox anywhere else. + uid, gid := uint32(syscall.Getuid()), uint32(syscall.Getgid()) + sb := &sandlock.Sandbox{FSReadable: rootfs, User: &sandlock.RunAs{UID: uid, GID: gid}} + res, err := sb.Run(context.Background(), "sh", "-c", "id -u; id -g") + if err != nil { + t.Fatalf("Run: %v", err) + } + wantUID, wantGID := strconv.FormatUint(uint64(uid), 10), strconv.FormatUint(uint64(gid), 10) + if got := strings.Fields(string(res.Stdout)); len(got) != 2 || got[0] != wantUID || got[1] != wantGID { + t.Fatalf("id inside the sandbox = %q, want uid %s and gid %s", res.Stdout, wantUID, wantGID) + } +} + +func TestAnUnknownProtectionIsRefusedNotDropped(t *testing.T) { + // `Protection` is a plain uint32 with six named values, so a number with + // no variant is expressible and reaches the C ABI. The setter used to + // drop it and the build used to succeed, which told a caller built + // against a newer header nothing at all when an older library did not + // recognise the protection it asked to relax: the caller believed it had + // opted out and the protection stayed strict. + for _, tc := range []struct { + name string + sb *sandlock.Sandbox + want string + }{ + {"allow_degraded", &sandlock.Sandbox{AllowDegraded: []sandlock.Protection{42}}, "allow_degraded: unrecognized protection 42"}, + {"disable", &sandlock.Sandbox{Disable: []sandlock.Protection{99}}, "disable: unrecognized protection 99"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := buildErr(t, tc.sb); !strings.Contains(got, tc.want) { + t.Fatalf("error = %q, want it to contain %q", got, tc.want) + } + }) + } +} diff --git a/go/examples/basic/main.go b/go/examples/basic/main.go index 0cd4fb1f..17c8ec9c 100644 --- a/go/examples/basic/main.go +++ b/go/examples/basic/main.go @@ -17,13 +17,26 @@ import ( sandlock "github.com/multikernel/sandlock/go" ) +// present keeps the system paths this host actually has. /lib64 is absent on +// arm64, and sandlock refuses a readable path that is not there, so filter +// here rather than hardcode one architecture's layout. +func present(paths ...string) []string { + var out []string + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out +} + func main() { if v, min := sandlock.LandlockABIVersion(), sandlock.MinLandlockABI(); v < min { log.Fatalf("kernel Landlock ABI v%d < required v%d", v, min) } sb := &sandlock.Sandbox{ - FSReadable: []string{"/usr", "/lib", "/lib64", "/bin", "/etc"}, + FSReadable: present("/usr", "/lib", "/lib64", "/bin", "/etc"), FSWritable: []string{"/tmp"}, MaxMemory: "256M", } diff --git a/go/grammar_parity_linux_test.go b/go/grammar_parity_linux_test.go new file mode 100644 index 00000000..a8887807 --- /dev/null +++ b/go/grammar_parity_linux_test.go @@ -0,0 +1,543 @@ +//go:build linux + +package sandlock_test + +// The Go SDK's half of the four-surface parity proof. +// +// sandlock owns the byte-size and RFC 3339 grammars. Four surfaces reach them: +// a CLI flag, a profile key, the Python SDK and this one. Until the C ABI +// setters started taking strings, the last two parsed the value themselves, +// agreed with each other and both disagreed with the core, so 1.5G and 1T +// built sandboxes that no flag and no profile could have built. +// +// The corpus of values is not in this file. It is tests/grammar-corpus.json, +// shared with python/tests/test_setter_grammar_parity.py, which drives the +// other three surfaces over the same entries. A list of values pasted into +// each language would be the very failure being fixed here. +// +// Two things are compared, and neither of them is this file's own opinion: +// +// - a rejected value has to come back with the core's sentence, and the +// sentence is checked against what the CLI prints for the same text rather +// than against a string typed here, so the two languages are compared +// directly and not each to a copy of the expected answer; +// - an accepted value has to produce the same live policy, read back over +// `sandlock inspect`, which serializes the policy through one core routine +// whichever surface configured it. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + sandlock "github.com/multikernel/sandlock/go" +) + +// ============================================================ +// The corpus +// ============================================================ + +type corpusKnob struct { + Flag string `json:"flag"` + Profile []string `json:"profile"` + Setter string `json:"setter"` + Python string `json:"python"` + Go string `json:"go"` + Labels map[string]string `json:"labels"` +} + +// label is the knob name a surface hands the core so the core can name it in a +// diagnosis. It is the one part of the sentence a surface supplies, so it is +// folded away before two surfaces are compared. An empty result means this +// grammar's message names no knob and the surfaces must match word for word. +func (k corpusKnob) label(surface string) string { return k.Labels[surface] } + +type corpusAccepted struct { + Knob string `json:"knob"` + // Value is the text every surface is given, character for character. + Value string `json:"value"` + // Renders is how a live sandbox spells the value back, or nil for a value + // never compared on a live document (see Live). + Renders *string `json:"renders"` + // Live is absent (true) for a policy whose guest survives long enough to + // be inspected. + Live *bool `json:"live"` + Unreachable map[string]string `json:"unreachable"` +} + +func (c corpusAccepted) live() bool { return c.Live == nil || *c.Live } + +type corpusRejected struct { + Knob string `json:"knob"` + Value string `json:"value"` + // Diagnosis is what the core's sentence must start with, once the + // surface's envelope is peeled and the knob label is folded away. + Diagnosis string `json:"diagnosis"` + SDKOnly bool `json:"sdk_only"` + Unreachable map[string]string `json:"unreachable"` +} + +type grammarCorpus struct { + Knobs map[string]corpusKnob `json:"knobs"` + Accepted []corpusAccepted `json:"accepted"` + Rejected []corpusRejected `json:"rejected"` +} + +const corpusPath = "../tests/grammar-corpus.json" + +func corpusLoad(t *testing.T) grammarCorpus { + t.Helper() + raw, err := os.ReadFile(corpusPath) + if err != nil { + t.Fatalf("reading the shared corpus: %v", err) + } + var c grammarCorpus + if err := json.Unmarshal(raw, &c); err != nil { + t.Fatalf("parsing %s: %v", corpusPath, err) + } + if len(c.Knobs) == 0 || len(c.Accepted) == 0 || len(c.Rejected) == 0 { + t.Fatalf("%s is empty; the parity claim would be vacuous", corpusPath) + } + return c +} + +func corpusKnobFor(t *testing.T, c grammarCorpus, name string) corpusKnob { + t.Helper() + k, ok := c.Knobs[name] + if !ok { + t.Fatalf("corpus entry names knob %q, which %s does not describe", name, corpusPath) + } + return k +} + +// ============================================================ +// Driving the Go SDK +// ============================================================ + +// corpusSandbox returns a Sandbox with one field set, chosen by the name the +// corpus records rather than by a switch written here: a field this SDK +// renamed or retyped has to fail loudly instead of quietly dropping out of the +// comparison. +func corpusSandbox(t *testing.T, knob corpusKnob, value string) *sandlock.Sandbox { + t.Helper() + sb := &sandlock.Sandbox{} + f := reflect.ValueOf(sb).Elem().FieldByName(knob.Go) + if !f.IsValid() { + t.Fatalf("sandlock.Sandbox has no field %q; %s and the SDK disagree", knob.Go, corpusPath) + } + if f.Kind() != reflect.String { + t.Fatalf("sandlock.Sandbox.%s is %s, not a string: the value would have to be parsed to get in, which is what this test exists to rule out", knob.Go, f.Kind()) + } + f.SetString(value) + return sb +} + +// goEnvelope is what the SDK wraps around the core's diagnosis. Asserted +// literally before it is peeled: an envelope that changed is a binding that +// started reporting something other than the core's verdict. +const goEnvelope = "sandlock: invalid sandbox: " + +// goRefusal offers a value to the SDK and returns the refusal, or "" if the +// policy was built. The command never runs: with no grants a built policy +// still produces a guest that cannot exec, and that is a result rather than a +// build error, which is what tells acceptance from refusal here. +func goRefusal(t *testing.T, knob corpusKnob, value string) string { + t.Helper() + sb := corpusSandbox(t, knob, value) + _, err := sb.Run(context.Background(), "/bin/true") + if err == nil { + return "" + } + if !strings.HasPrefix(err.Error(), goEnvelope) { + // Not a verdict on the value: something later went wrong, and + // reporting it as a refusal would let a broken run stand in for a + // grammar the SDK never actually reached. + return "" + } + return err.Error() +} + +var corpusNames atomic.Uint64 + +func corpusName(prefix string) string { + return fmt.Sprintf("%s-%d-%d", prefix, os.Getpid(), corpusNames.Add(1)) +} + +// ============================================================ +// The CLI, as the surface to be compared against +// ============================================================ + +const cliEnvelope = "Error: invalid sandbox: " + +// corpusFindCLI locates the sandlock binary built from this checkout. The Go build +// already resolves libsandlock_ffi.so out of ../target (see cgo_repo.go), so +// the CLI beside it is the same tree; SANDLOCK_CLI overrides that for a run +// against an installed build. +func corpusFindCLI(t *testing.T) string { + t.Helper() + if p := os.Getenv("SANDLOCK_CLI"); p != "" { + return p + } + var best string + var bestTime time.Time + for _, profile := range []string{"debug", "release"} { + p := filepath.Join("..", "target", profile, "sandlock") + info, err := os.Stat(p) + if err != nil { + continue + } + if best == "" || info.ModTime().After(bestTime) { + best, bestTime = p, info.ModTime() + } + } + if best == "" { + t.Skip("no sandlock binary in ../target; build it or set SANDLOCK_CLI to compare the two surfaces") + } + return best +} + +// cliRefusal offers a value to the flag and returns the refusal, or "". +func cliRefusal(t *testing.T, cli string, knob corpusKnob, value string) string { + t.Helper() + // The attached form, as one argument: a value that starts with "-" reads + // as another flag when it stands on its own, and being turned away by the + // argument parser is not the grammar's verdict on it. + cmd := exec.Command(cli, "run", knob.Flag+"="+value, "--", "/bin/true") + var stderr bytes.Buffer + cmd.Stderr = &stderr + _ = cmd.Run() + head := strings.TrimSpace(strings.SplitN(stderr.String(), "\n\nCaused by:", 2)[0]) + if !strings.HasPrefix(head, "Error: ") { + return "" + } + return head +} + +// cliPolicy launches the same value through the flag and returns the effective +// policy of the sandbox it produced, so the two surfaces can be compared on +// what they built rather than on what either of them was told to build. +func cliPolicy(t *testing.T, cli string, knob corpusKnob, value string) map[string]any { + t.Helper() + name := corpusName("cli-parity") + args := []string{"run", knob.Flag + "=" + value, "--name", name} + for _, p := range rootfs { + args = append(args, "--fs-read", p) + } + args = append(args, "--", corpusSleep, "30") + + cmd := exec.Command(cli, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("starting the CLI: %v", err) + } + defer func() { + // `sandlock kill` first: a supervisor asked to stop clears its control + // directory, while one that is only signalled leaves it behind. Here + // the supervisor is the `sandlock run` process, not this test binary, + // so naming the sandbox is safe. The process is reaped either way, so + // no guest outlives the test as an orphan. + _ = exec.Command(cli, "kill", name).Run() + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + return corpusInspect(t, cli, name) +} + +// corpusInspect reads a live sandbox's effective policy through the control plane. +func corpusInspect(t *testing.T, cli, name string) map[string]any { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + var last string + for time.Now().Before(deadline) { + cmd := exec.Command(cli, "inspect", name) + var out, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &stderr + if err := cmd.Run(); err == nil { + var doc map[string]any + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("`sandlock inspect %s` returned something other than JSON: %v", name, err) + } + return doc + } + last = strings.TrimSpace(stderr.String()) + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("`sandlock inspect %s` never answered: %s", name, last) + return nil +} + +// corpusDocString reads one leaf out of an effective policy document. +func corpusDocString(doc map[string]any, path []string) *string { + section, ok := doc[path[0]].(map[string]any) + if !ok { + return nil + } + s, ok := section[path[1]].(string) + if !ok { + return nil + } + return &s +} + +// ============================================================ +// Tests +// ============================================================ + +// TestCorpusMatchesThisSDK is the guard on everything below: every knob the +// corpus describes must still be a plain string field on Sandbox. Without it a +// renamed field would take its whole grammar out of the comparison silently. +func TestCorpusMatchesThisSDK(t *testing.T) { + c := corpusLoad(t) + for name, knob := range c.Knobs { + t.Run(name, func(t *testing.T) { + corpusSandbox(t, knob, "") + }) + } + for _, entry := range c.Accepted { + corpusKnobFor(t, c, entry.Knob) + } + for _, entry := range c.Rejected { + corpusKnobFor(t, c, entry.Knob) + } +} + +// TestARejectedValueIsRefusedWithTheCoresOwnSentence compares the two +// languages against each other rather than each against a copy of the answer. +// The corpus prefix pins the verdict, so a refusal for some unrelated reason +// cannot stand in for the grammar's; the CLI comparison pins the rest of the +// sentence, so a binding that reworded the diagnosis fails even where the +// prefix still matches. +func TestARejectedValueIsRefusedWithTheCoresOwnSentence(t *testing.T) { + c := corpusLoad(t) + cli := corpusFindCLI(t) + for _, entry := range c.Rejected { + t.Run(corpusTestID(entry.Knob, entry.Value), func(t *testing.T) { + knob := corpusKnobFor(t, c, entry.Knob) + if why, unreachable := entry.Unreachable["go"]; unreachable { + // Announced, not silent: the reason is a claim about this SDK + // and is pinned by TestAnEmptyStringIsThisSDKsSpellingOfUnset. + t.Skip(why) + } + + raw := goRefusal(t, knob, entry.Value) + if raw == "" { + t.Fatalf("the SDK accepted %q, which no other surface does", entry.Value) + } + got := corpusFold(strings.TrimPrefix(raw, goEnvelope), knob.label("go")) + + rawCLI := cliRefusal(t, cli, knob, entry.Value) + if rawCLI == "" { + t.Fatalf("the CLI accepted %q, so there is nothing to compare against", entry.Value) + } + if !strings.HasPrefix(rawCLI, cliEnvelope) { + t.Fatalf("the CLI reported something other than the core's verdict: %q", rawCLI) + } + want := corpusFold(strings.TrimPrefix(rawCLI, cliEnvelope), knob.label("flag")) + + if got != want { + t.Fatalf("the two surfaces disagree on %q:\n Go SDK: %q\n CLI: %q", entry.Value, got, want) + } + if !strings.HasPrefix(got, entry.Diagnosis) { + t.Fatalf("expected the grammar's verdict %q, got %q", entry.Diagnosis, got) + } + if entry.SDKOnly { + // Narrative only, and the reason this entry is in the corpus: + // the deleted go/internal/policy parser took this value. + t.Logf("%q used to be accepted by the SDK's own parser", entry.Value) + } + }) + } +} + +// TestAnAcceptedValueMeansTheSameThroughTheSDKAsThroughTheFlag launches the +// same text twice and compares the policy, not the text: a setter that ignored +// its argument, or a binding that rounded the value on the way, shows up as +// two different documents. +func TestAnAcceptedValueMeansTheSameThroughTheSDKAsThroughTheFlag(t *testing.T) { + requireLandlock(t) + c := corpusLoad(t) + cli := corpusFindCLI(t) + for _, entry := range c.Accepted { + if !entry.live() { + continue + } + t.Run(corpusTestID(entry.Knob, entry.Value), func(t *testing.T) { + knob := corpusKnobFor(t, c, entry.Knob) + if why, unreachable := entry.Unreachable["go"]; unreachable { + t.Skip(why) + } + + sb := corpusSandbox(t, knob, entry.Value) + sb.FSReadable = rootfs + name := corpusName("go-parity") + sb.Name = sandlock.Ptr(name) + + proc, err := sb.Spawn(corpusSleep, "30") + if err != nil { + t.Fatalf("the SDK refused %q, which the corpus says every surface takes: %v", entry.Value, err) + } + // Close kills the guest and releases the handle. `sandlock kill` + // is deliberately not used: for a CLI-launched sandbox the + // supervisor is the `sandlock run` process, but for an + // SDK-launched one it is this test binary. + defer proc.Close() + + fromSDK := corpusInspect(t, cli, name) + got := corpusDocString(fromSDK, knob.Profile) + // Pin the value first. Without this the two documents could agree + // by both dropping it, which is what a setter that ignored its + // argument would look like. + if !corpusSameString(got, entry.Renders) { + t.Fatalf("the SDK resolved %q to %s, expected %s", entry.Value, corpusShow(got), corpusShow(entry.Renders)) + } + + // Then compare the whole policy against the one the flag produces + // from the same text. The document is serialized by one core + // routine whichever surface configured the sandbox, so this + // compares the policy rather than the words that asked for it, and + // it covers the fields the corpus does not name. + fromFlag := cliPolicy(t, cli, knob, entry.Value) + if !reflect.DeepEqual(fromSDK, fromFlag) { + t.Fatalf("the SDK and the flag built different policies from %q:\n SDK: %s\n flag: %s", + entry.Value, corpusJSON(fromSDK), corpusJSON(fromFlag)) + } + }) + } +} + +// TestAnAcceptedValueWhoseGuestCannotRunIsStillTaken keeps the values whose +// policy no guest survives inside the comparison. The document check above +// needs a sandbox that stays up long enough to answer `sandlock inspect`, +// which a one-byte memory ceiling never does; what is left to compare is the +// verdict, and no surface may refuse a value the others take. +func TestAnAcceptedValueWhoseGuestCannotRunIsStillTaken(t *testing.T) { + c := corpusLoad(t) + for _, entry := range c.Accepted { + if entry.live() { + continue + } + t.Run(corpusTestID(entry.Knob, entry.Value), func(t *testing.T) { + knob := corpusKnobFor(t, c, entry.Knob) + if why, unreachable := entry.Unreachable["go"]; unreachable { + t.Skip(why) + } + if refusal := goRefusal(t, knob, entry.Value); refusal != "" { + t.Fatalf("the SDK refused %q, which no other surface does: %s", entry.Value, refusal) + } + }) + } +} + +// TestAnEmptyStringIsThisSDKsSpellingOfUnset pins the one excuse the corpus +// grants this SDK. A Go struct field of type string has no value left over for +// "not configured", so an empty MaxMemory means the knob was never set and +// never reaches the core, which is why those corpus entries are skipped above. +// That is a claim about the binding, so it is tested rather than assumed: a +// blank value, which the core does have an opinion about, still travels. +func TestAnEmptyStringIsThisSDKsSpellingOfUnset(t *testing.T) { + requireLandlock(t) + c := corpusLoad(t) + + excused := map[string]bool{} + for _, entry := range c.Rejected { + if _, ok := entry.Unreachable["go"]; ok { + excused[entry.Knob] = true + } + } + if len(excused) == 0 { + t.Fatal("the corpus excuses this SDK nowhere, so this test guards nothing; delete it or the excuse") + } + + for knobName := range excused { + knob := corpusKnobFor(t, c, knobName) + t.Run(knobName, func(t *testing.T) { + // Empty: the knob is not configured, so the sandbox is the same + // one an untouched field produces and it runs. + sb := corpusSandbox(t, knob, "") + sb.FSReadable = rootfs + res, err := sb.Run(context.Background(), "/bin/true") + if err != nil { + t.Fatalf("an empty %s must mean \"unset\", not an empty value handed to the core: %v", knob.Go, err) + } + if !res.Success { + t.Fatalf("want success, got exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + + // Blank: a value the caller did set, and one the core refuses. + // Nothing between here and the core may trim it into the case + // above, which is what "unset" would have to become to leak. + if refusal := goRefusal(t, knob, " "); refusal == "" { + t.Fatalf("a blank %s must reach the core and be refused there", knob.Go) + } + }) + } +} + +// ============================================================ +// Small helpers +// ============================================================ + +// corpusSleep keeps a guest alive long enough to be inspected. +var corpusSleep = corpusFirstExisting("/bin/sleep", "/usr/bin/sleep") + +func corpusFirstExisting(candidates ...string) string { + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "/bin/sleep" +} + +// corpusFold removes the knob name a surface supplied, so what is compared +// afterwards is the core's own sentence. +func corpusFold(sentence, label string) string { + if label == "" { + return sentence + } + return strings.Replace(sentence, label, "", 1) +} + +// corpusTestID turns a corpus value into a subtest name `go test -run` can +// select. +// Spaces become underscores rather than disappearing: whitespace is part of +// what is being tested, and two entries differing only in it must not collapse. +func corpusTestID(knob, value string) string { + if value == "" { + return knob + "/empty" + } + return knob + "/" + strings.ReplaceAll(value, " ", "_") +} + +func corpusSameString(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +// corpusJSON renders a policy document for a failure message. +func corpusJSON(doc map[string]any) string { + out, err := json.Marshal(doc) + if err != nil { + return fmt.Sprintf("%v", doc) + } + return string(out) +} + +func corpusShow(s *string) string { + if s == nil { + return "nothing" + } + return fmt.Sprintf("%q", *s) +} diff --git a/go/internal/policy/spec.go b/go/internal/policy/spec.go deleted file mode 100644 index dfd5e61c..00000000 --- a/go/internal/policy/spec.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package policy holds pure, platform-independent parsing helpers shared by -// the sandlock Go SDK. It deliberately has no cgo dependency so the logic can -// be unit-tested on any OS, separate from the Linux-only FFI bindings. -package policy - -import ( - "fmt" - "regexp" - "strconv" - "strings" - "time" -) - -var sizeRe = regexp.MustCompile(`^\s*(\d+(?:\.\d+)?)\s*([KMGTkmgt])?\s*$`) - -var sizeUnits = map[byte]uint64{ - 'K': 1 << 10, - 'M': 1 << 20, - 'G': 1 << 30, - 'T': 1 << 40, -} - -// ParseMemory parses a human-friendly size string into bytes. It accepts a -// plain integer (bytes) or a value suffixed with K, M, G, or T (case -// insensitive), e.g. "512M", "1G", "100K". Mirrors the Python SDK's -// parse_memory_size so the two SDKs agree byte-for-byte. -func ParseMemory(s string) (uint64, error) { - m := sizeRe.FindStringSubmatch(s) - if m == nil { - return 0, fmt.Errorf("invalid memory size: %q", s) - } - value, err := strconv.ParseFloat(m[1], 64) - if err != nil { - return 0, fmt.Errorf("invalid memory size: %q", s) - } - if m[2] != "" { - unit := sizeUnits[strings.ToUpper(m[2])[0]] - value *= float64(unit) - } - return uint64(value), nil -} - -// ParseTimeStart resolves a time-virtualization start point to whole seconds -// since the Unix epoch. It accepts an RFC 3339 / ISO 8601 timestamp -// (e.g. "2000-01-01T00:00:00Z") or a plain integer/float number of seconds. -func ParseTimeStart(s string) (uint64, error) { - s = strings.TrimSpace(s) - if f, err := strconv.ParseFloat(s, 64); err == nil { - if f < 0 { - return 0, fmt.Errorf("invalid time_start: %q", s) - } - return uint64(f), nil - } - t, err := time.Parse(time.RFC3339, s) - if err != nil { - return 0, fmt.Errorf("invalid time_start: %q (want RFC3339 or unix seconds)", s) - } - return uint64(t.Unix()), nil -} diff --git a/go/internal/policy/spec_test.go b/go/internal/policy/spec_test.go deleted file mode 100644 index 3bba5caf..00000000 --- a/go/internal/policy/spec_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package policy - -import ( - "testing" -) - -func TestParseMemory(t *testing.T) { - cases := []struct { - in string - want uint64 - wantErr bool - }{ - {"1024", 1024, false}, - {"512M", 512 << 20, false}, - {"1G", 1 << 30, false}, - {"100K", 100 << 10, false}, - {"2T", 2 << 40, false}, - {"1g", 1 << 30, false}, - {" 256M ", 256 << 20, false}, - {"1.5G", uint64(1.5 * float64(1<<30)), false}, - {"", 0, true}, - {"abc", 0, true}, - {"10X", 0, true}, - } - for _, c := range cases { - got, err := ParseMemory(c.in) - if c.wantErr { - if err == nil { - t.Errorf("ParseMemory(%q): expected error, got %d", c.in, got) - } - continue - } - if err != nil { - t.Errorf("ParseMemory(%q): unexpected error: %v", c.in, err) - continue - } - if got != c.want { - t.Errorf("ParseMemory(%q) = %d, want %d", c.in, got, c.want) - } - } -} - -func TestParseTimeStart(t *testing.T) { - cases := []struct { - in string - want uint64 - wantErr bool - }{ - {"0", 0, false}, - {"946684800", 946684800, false}, - {"2000-01-01T00:00:00Z", 946684800, false}, - {"", 0, true}, - {"not-a-time", 0, true}, - {"-5", 0, true}, - } - for _, c := range cases { - got, err := ParseTimeStart(c.in) - if c.wantErr { - if err == nil { - t.Errorf("ParseTimeStart(%q): expected error, got %d", c.in, got) - } - continue - } - if err != nil { - t.Errorf("ParseTimeStart(%q): unexpected error: %v", c.in, err) - continue - } - if got != c.want { - t.Errorf("ParseTimeStart(%q) = %d, want %d", c.in, got, c.want) - } - } -} diff --git a/go/sandbox.go b/go/sandbox.go index cc3a5c3a..ab6957c0 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -37,20 +37,28 @@ import ( "unsafe" ) +// Ptr returns a pointer to v. The Sandbox fields whose "unset" state is not +// expressible in the value itself are pointers, and Go has no address-of for +// a literal, so this is the spelling for setting one: +// +// sb := &sandlock.Sandbox{MaxCPU: sandlock.Ptr[uint8](50)} +func Ptr[T any](v T) *T { return &v } + // BranchAction is the action taken on a copy-on-write working-directory -// branch when the sandbox exits. The zero value, BranchActionDefault, leaves -// the choice to sandlock's own defaults (commit on success, abort on error). +// branch when the sandbox exits. The values are the stable ABI discriminants +// shared with the C/Rust core, and are passed through unchanged: a value +// outside this set is rejected by the core, naming the number the caller +// wrote. The Sandbox fields are pointers, so "leave it to sandlock's own +// default" is nil rather than a sentinel member of this type. type BranchAction uint8 const ( - // BranchActionDefault defers to sandlock's built-in default. - BranchActionDefault BranchAction = iota // BranchActionCommit merges the branch's writes into the parent on exit. - BranchActionCommit + BranchActionCommit BranchAction = 0 // BranchActionAbort discards all of the branch's writes on exit. - BranchActionAbort + BranchActionAbort BranchAction = 1 // BranchActionKeep leaves the branch in place for the caller to handle. - BranchActionKeep + BranchActionKeep BranchAction = 2 ) // SyscallCategory is the high-level category of an intercepted syscall event. @@ -166,6 +174,18 @@ const ( ProtectionAbstractUnixSocketScope Protection = 5 // abstract UNIX socket scoping (ABI v6) ) +// RunAs is the identity the sandboxed process runs as, applied through a +// single-entry user-namespace map. Both ids are always present because that is +// what the core models: an unprivileged user namespace can map exactly one uid +// and one gid, so "a uid without a gid" is not a state it can represent, and +// this type does not let a caller spell it. The ids are uint32 for the same +// reason: that is the width the kernel and the C ABI carry, so a value outside +// it cannot be silently truncated on the way down. +type RunAs struct { + UID uint32 + GID uint32 +} + // Sandbox holds the policy configuration for confining a process. Every field // is optional; an unset field means "no restriction" unless documented // otherwise. sandlock's default syscall blocklist is always applied. @@ -231,48 +251,71 @@ type Sandbox struct { // HTTP ACL (method + host + path rules via a transparent proxy). HTTPAllow []string // allow rules, "METHOD host/path" HTTPDeny []string // deny rules, checked before allow rules - HTTPPorts []int // ports to intercept (defaults to 80, plus 443 with a CA) + HTTPPorts []uint16 // ports to intercept (defaults to 80, plus 443 with a CA) HTTPCAFile string // PEM CA certificate for HTTPS MITM HTTPKeyFile string // PEM CA private key (required with HTTPCAFile) // Resource limits. - MaxMemory string // e.g. "512M"; empty = unlimited - MaxDisk string // disk quota for COW storage, e.g. "1G" - MaxProcesses uint32 // peak concurrent process cap; 0 = sandlock default - MaxCPU uint8 // CPU throttle, percent of one core (1-100); 0 = unset - MaxOpenFiles uint32 // RLIMIT_NOFILE soft+hard in the child, clamped to sandlock's own limits; 0 = inherit - CPUCores []uint32 // cores to pin to via sched_setaffinity - NumCPUs uint32 // synthetic /proc/cpuinfo processor count; 0 = unset - GPUDevices []uint32 // GPU device indices to expose; nil = none + // + // MaxMemory and MaxDisk are byte-size strings parsed by sandlock itself, + // with the grammar it accepts everywhere else: a decimal integer and an + // optional K/M/G suffix (case-insensitive), a bare number being a count of + // bytes. Fractions and a T suffix are not part of it; a value outside the + // grammar is reported by the core when the sandbox is built. + // + // The numeric caps are pointers because zero is a value the core has an + // opinion about (it rejects a cap of zero processes, CPU percent, + // descriptors, or processors), so it must not double as "unset". Use Ptr + // to set one. + // An empty MaxMemory is the only spelling of "unlimited": "0" is refused, + // because zero is what the supervisor already carries for "no ceiling", + // and setting the field at all is what installs the memory handler. An + // empty CPUCores is refused for the same reason in reverse: it is an + // affinity mask with no bits, not "every core", which is what leaving the + // field nil already means. MaxDisk is the exception: zero is its + // documented spelling of "unlimited". + MaxMemory string // e.g. "512M"; empty = unlimited, "0" is refused + MaxDisk string // disk quota for COW storage, e.g. "1G"; "0" = unlimited + MaxProcesses *uint32 // peak concurrent process cap; nil = sandlock default + MaxCPU *uint8 // CPU throttle, percent of one core (1-100); nil = unset + MaxOpenFiles *uint32 // RLIMIT_NOFILE soft+hard in the child, clamped to sandlock's own limits; nil = inherit + CPUCores []uint32 // cores to pin to via sched_setaffinity; nil = unset, empty is refused + NumCPUs *uint32 // synthetic /proc/cpuinfo processor count; nil = unset + // GPUDevices lists the GPU device indices to expose. nil means none; an + // empty (non-nil) slice means every GPU present on the host. + GPUDevices []uint32 // Syscall filtering (on top of sandlock's default blocklist). ExtraAllowSyscalls []string // syscall groups to allow, e.g. "sysv_ipc" ExtraDenySyscalls []string // extra syscall names to block // Determinism. - RandomSeed *uint64 // seed getrandom() deterministically - TimeStart string // virtual clock start: RFC3339 or unix seconds - NoRandomizeMemory bool // disable ASLR - NoHugePages bool // disable transparent huge pages - DeterministicDirs bool // sort readdir() entries + RandomSeed *uint64 // seed getrandom() deterministically + // TimeStart is the virtual clock start as an RFC3339 timestamp + // ("2026-01-01T00:00:00Z"), parsed by sandlock itself with the same + // grammar as a profile's [determinism].time_start. Empty = unset. + TimeStart string + NoRandomizeMemory bool // disable ASLR + NoHugePages bool // disable transparent huge pages + DeterministicDirs bool // sort readdir() entries // Environment. CleanEnv bool // start from a minimal environment Env map[string]string // variables to set/override in the child // Misc. - UID *int // map to this UID inside a user namespace; nil = unset - GID *int // map to this GID inside the user namespace; must be set together with UID - NoCoredump bool // disable core dumps and restrict /proc/pid access + User *RunAs // identity inside a user namespace; nil = unset + NoCoredump bool // disable core dumps and restrict /proc/pid access // Copy-on-write branch handling. - FSStorage string // storage directory for COW deltas - OnExit BranchAction // branch action on normal exit - OnError BranchAction // branch action on error exit + FSStorage string // storage directory for COW deltas + OnExit *BranchAction // branch action on normal exit; nil = sandlock's default + OnError *BranchAction // branch action on error exit; nil = sandlock's default // Name is the sandbox name and its virtual hostname inside the sandbox. - // Empty auto-generates "sandbox-{pid}". - Name string + // nil auto-generates "sandbox-{pid}"; any other value is passed to + // sandlock verbatim, including the empty string, which it rejects. + Name *string // PolicyFn receives dynamic syscall events and may return an allow/deny // decision or modify live policy through the supplied context. diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index 9769a45c..305b363a 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -24,20 +24,36 @@ import ( "fmt" "os" "runtime" + "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "unsafe" - - "github.com/multikernel/sandlock/go/internal/policy" ) // hasNUL reports whether s contains an interior NUL byte, which cannot survive // the conversion to a C string. func hasNUL(s string) bool { return strings.IndexByte(s, 0) >= 0 } +// checkCommand rejects an empty command before any C entry point is reached. +// +// The core makes the same call (Sandbox::create rejects an empty command), but +// it cannot make it here: the C ABI takes argv as a pointer plus a count and +// treats a null pointer as invalid input, and Go has no pointer to give for an +// empty slice. So the call would come back as a null handle from an entry +// point that carries no error text, and the caller would read "failed to +// create sandbox" for a mistake the core can name exactly. Until the create +// family grows an error out-param the way sandlock_sandbox_build has one, this +// stays, with the core's own wording. +func checkCommand(cmd []string) error { + if len(cmd) == 0 { + return fmt.Errorf("sandlock: empty command") + } + return nil +} + func cbool(v bool) C.bool { return C.bool(v) } var ( @@ -123,9 +139,14 @@ func goPolicyDrop(userData unsafe.Pointer) { } // validateStrings rejects any configuration string carrying a NUL byte before -// a builder is allocated. The FFI has no builder-free entry point, so a failure -// partway through building would leak the builder; validating up front keeps -// buildPolicy infallible with respect to string conversion. +// a builder is allocated. This is the one verdict the Go binding must reach on +// its own: a Go string with an interior NUL cannot become a C string, so the +// value the core would see is a silently truncated prefix of what the caller +// wrote, and no core-side check can recover the difference. Every other +// rejection in this file belongs to the core. +// +// It also keeps buildPolicy infallible: the FFI has no builder-free entry +// point, so a failure partway through building would leak the builder. func (s *Sandbox) validateStrings() error { groups := [][]string{ s.FSReadable, s.FSWritable, s.FSDenied, @@ -133,7 +154,10 @@ func (s *Sandbox) validateStrings() error { s.HTTPAllow, s.HTTPDeny, s.ExtraAllowSyscalls, s.ExtraDenySyscalls, {s.Workdir, s.Cwd, s.Chroot, s.FSStorage, s.MaxMemory, s.MaxDisk, - s.TimeStart, s.HTTPCAFile, s.HTTPKeyFile, s.Name}, + s.TimeStart, s.HTTPCAFile, s.HTTPKeyFile}, + } + if s.Name != nil && hasNUL(*s.Name) { + return ErrInvalidString } for _, g := range groups { for _, v := range g { @@ -229,9 +253,7 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { return C.sandlock_sandbox_builder_net_deny_bind(b, c) }, spec) } - if s.PortRemap { - b = C.sandlock_sandbox_builder_port_remap(b, cbool(true)) - } + b = C.sandlock_sandbox_builder_port_remap(b, cbool(s.PortRemap)) // Protection opt-out (Landlock per-protection posture). for _, p := range s.AllowDegraded { @@ -252,6 +274,10 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { return C.sandlock_sandbox_builder_http_deny(b, c) }, r) } + // []uint16 rather than []int: the C ABI carries a u16, so a wider Go type + // would wrap silently on the way down (70000 arriving as 4464) and the + // core would never see the value the caller wrote. The type carries the + // ABI's range instead of a check doing it. for _, p := range s.HTTPPorts { b = C.sandlock_sandbox_builder_http_port(b, C.uint16_t(p)) } @@ -266,44 +292,47 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { }, s.HTTPKeyFile) } - // Resource limits. + // Resource limits. The byte-size strings go to the core verbatim: it owns + // the grammar (ByteSize::parse in crates/sandlock-core/src/sandbox.rs) and + // latches its own message for a value it cannot accept, which + // sandlock_sandbox_build returns below. if s.MaxMemory != "" { - v, err := policy.ParseMemory(s.MaxMemory) - if err != nil { - freeBuilderViaBuild(b) - return nil, err - } - b = C.sandlock_sandbox_builder_max_memory(b, C.uint64_t(v)) + str(func(b *C.sandlock_builder_t, c *C.char) *C.sandlock_builder_t { + return C.sandlock_sandbox_builder_max_memory(b, c) + }, s.MaxMemory) } if s.MaxDisk != "" { - v, err := policy.ParseMemory(s.MaxDisk) - if err != nil { - freeBuilderViaBuild(b) - return nil, err - } - b = C.sandlock_sandbox_builder_max_disk(b, C.uint64_t(v)) - } - if s.MaxProcesses > 0 { - b = C.sandlock_sandbox_builder_max_processes(b, C.uint32_t(s.MaxProcesses)) - } - if s.MaxCPU > 0 { - b = C.sandlock_sandbox_builder_max_cpu(b, C.uint8_t(s.MaxCPU)) - } - if s.MaxOpenFiles > 0 { - b = C.sandlock_sandbox_builder_max_open_files(b, C.uint(s.MaxOpenFiles)) - } - if s.NumCPUs > 0 { - b = C.sandlock_sandbox_builder_num_cpus(b, C.uint32_t(s.NumCPUs)) - } - if len(s.CPUCores) > 0 { - b = C.sandlock_sandbox_builder_cpu_cores(b, (*C.uint32_t)(unsafe.Pointer(&s.CPUCores[0])), C.uint32_t(len(s.CPUCores))) + str(func(b *C.sandlock_builder_t, c *C.char) *C.sandlock_builder_t { + return C.sandlock_sandbox_builder_max_disk(b, c) + }, s.MaxDisk) + } + // Every cap below is forwarded whenever it is set, zero included: the core + // rejects a zero cap for each of them (crates/sandlock-core/src/sandbox/ + // builder.rs, the max_processes / max_cpu / max_open_files / num_cpus + // checks in build_unchecked), and filtering zero here would replace that + // verdict with a silently ignored field. + if s.MaxProcesses != nil { + b = C.sandlock_sandbox_builder_max_processes(b, C.uint32_t(*s.MaxProcesses)) + } + if s.MaxCPU != nil { + b = C.sandlock_sandbox_builder_max_cpu(b, C.uint8_t(*s.MaxCPU)) + } + if s.MaxOpenFiles != nil { + b = C.sandlock_sandbox_builder_max_open_files(b, C.uint(*s.MaxOpenFiles)) + } + if s.NumCPUs != nil { + b = C.sandlock_sandbox_builder_num_cpus(b, C.uint32_t(*s.NumCPUs)) + } + // nil and empty are distinct for both slices, so both use the same rule: + // nil is "unset", and an empty slice is forwarded as an empty list. The + // core reads an empty GPU list as "every GPU present" + // (crates/sandlock-core/src/landlock.rs), which is a grant Go must be able + // to express. + if s.CPUCores != nil { + b = C.sandlock_sandbox_builder_cpu_cores(b, sliceHead(s.CPUCores), C.uint32_t(len(s.CPUCores))) } if s.GPUDevices != nil { - var ptr *C.uint32_t - if len(s.GPUDevices) > 0 { - ptr = (*C.uint32_t)(unsafe.Pointer(&s.GPUDevices[0])) - } - b = C.sandlock_sandbox_builder_gpu_devices(b, ptr, C.uint32_t(len(s.GPUDevices))) + b = C.sandlock_sandbox_builder_gpu_devices(b, sliceHead(s.GPUDevices), C.uint32_t(len(s.GPUDevices))) } // Syscall filtering. @@ -318,32 +347,26 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { }, strings.Join(s.ExtraAllowSyscalls, ",")) } - // Determinism. + // Determinism. The timestamp goes to the core verbatim, which parses it + // with the same grammar it uses for a profile's [determinism].time_start + // (parse_time_start in crates/sandlock-core/src/profile.rs). if s.RandomSeed != nil { b = C.sandlock_sandbox_builder_random_seed(b, C.uint64_t(*s.RandomSeed)) } if s.TimeStart != "" { - secs, err := policy.ParseTimeStart(s.TimeStart) - if err != nil { - freeBuilderViaBuild(b) - return nil, err - } - b = C.sandlock_sandbox_builder_time_start(b, C.uint64_t(secs)) - } - if s.NoRandomizeMemory { - b = C.sandlock_sandbox_builder_no_randomize_memory(b, cbool(true)) - } - if s.NoHugePages { - b = C.sandlock_sandbox_builder_no_huge_pages(b, cbool(true)) - } - if s.DeterministicDirs { - b = C.sandlock_sandbox_builder_deterministic_dirs(b, cbool(true)) + str(func(b *C.sandlock_builder_t, c *C.char) *C.sandlock_builder_t { + return C.sandlock_sandbox_builder_time_start(b, c) + }, s.TimeStart) } + // The bool setters are called unconditionally. Sending them only when true + // would leave the false side inexpressible from Go and pin the binding to + // today's core defaults; the core owns what an unset flag means. + b = C.sandlock_sandbox_builder_no_randomize_memory(b, cbool(s.NoRandomizeMemory)) + b = C.sandlock_sandbox_builder_no_huge_pages(b, cbool(s.NoHugePages)) + b = C.sandlock_sandbox_builder_deterministic_dirs(b, cbool(s.DeterministicDirs)) // Environment. - if s.CleanEnv { - b = C.sandlock_sandbox_builder_clean_env(b, cbool(true)) - } + b = C.sandlock_sandbox_builder_clean_env(b, cbool(s.CleanEnv)) for k, v := range s.Env { ck, cv := C.CString(k), C.CString(v) b = C.sandlock_sandbox_builder_env_var(b, ck, cv) @@ -351,17 +374,14 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { C.free(unsafe.Pointer(cv)) } - // Misc. - if s.UID != nil || s.GID != nil { - if s.UID == nil || s.GID == nil { - freeBuilderViaBuild(b) - return nil, fmt.Errorf("UID and GID must both be set (or both unset)") - } - b = C.sandlock_sandbox_builder_user(b, C.uint32_t(*s.UID), C.uint32_t(*s.GID)) - } - if s.NoCoredump { - b = C.sandlock_sandbox_builder_no_coredump(b, cbool(true)) + // Misc. RunAs carries both ids, so there is no "uid without gid" state for + // this binding to check for: the core models the same thing as a single + // Option (crates/sandlock-core/src/sandbox/builder.rs), and the + // type now matches it. + if s.User != nil { + b = C.sandlock_sandbox_builder_user(b, C.uint32_t(s.User.UID), C.uint32_t(s.User.GID)) } + b = C.sandlock_sandbox_builder_no_coredump(b, cbool(s.NoCoredump)) // Copy-on-write branch handling. if s.FSStorage != "" { @@ -369,11 +389,15 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { return C.sandlock_sandbox_builder_fs_storage(b, c) }, s.FSStorage) } - if s.OnExit != BranchActionDefault { - b = C.sandlock_sandbox_builder_on_exit(b, C.uint8_t(s.OnExit-1)) + // The discriminants are the ABI's, so they travel unshifted: a value with + // no variant is reported by the core naming the number the caller wrote + // (the on_exit / on_error setters in crates/sandlock-ffi/src/lib.rs latch + // "unrecognized branch action N"). + if s.OnExit != nil { + b = C.sandlock_sandbox_builder_on_exit(b, C.uint8_t(*s.OnExit)) } - if s.OnError != BranchActionDefault { - b = C.sandlock_sandbox_builder_on_error(b, C.uint8_t(s.OnError-1)) + if s.OnError != nil { + b = C.sandlock_sandbox_builder_on_error(b, C.uint8_t(*s.OnError)) } if s.PolicyFn != nil { b = C.sandlock_sandbox_builder_policy_fn( @@ -398,21 +422,15 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { return policyPtr, nil } -// freeBuilderViaBuild consumes a builder that will not be used, so it is not -// leaked. The FFI exposes no builder-free entry point; build() is the only -// consumer, so we build and immediately free the resulting policy (or discard -// a build error). Reached only on the rare numeric-parse error paths after the -// builder already exists. -func freeBuilderViaBuild(b *C.sandlock_builder_t) { - var errCode C.int - var errMsg *C.char - p := C.sandlock_sandbox_build(b, &errCode, &errMsg) - if errMsg != nil { - C.sandlock_string_free(errMsg) - } - if p != nil { - C.sandlock_sandbox_free(p) +// sliceHead returns a C pointer to the first element of vals, or nil for an +// empty slice (Go cannot address element zero of one). The length travels +// separately, so nil-with-zero-length and a real pointer describe the same +// empty list to the core. +func sliceHead(vals []uint32) *C.uint32_t { + if len(vals) == 0 { + return nil } + return (*C.uint32_t)(unsafe.Pointer(&vals[0])) } // cArgv converts a command into a C argv array. Each element and the array @@ -542,13 +560,16 @@ func argvPtr(argv []*C.char) (**C.char, C.uint) { return (**C.char)(unsafe.Pointer(&argv[0])), C.uint(len(argv)) } -// cName converts the sandbox name to a C string, returning nil for the empty -// name (which tells the FFI to auto-generate one). +// cName converts the sandbox name to a C string. Only an unset Name (nil) +// becomes the NULL that tells the FFI to auto-generate one; a name the caller +// did set is forwarded as written, empty string included, so the core's own +// verdict on it reaches the caller instead of being turned into a random name +// here. func (s *Sandbox) cName() *C.char { - if s.Name == "" { + if s.Name == nil { return nil } - return C.CString(s.Name) + return C.CString(*s.Name) } func freeName(c *C.char) { @@ -610,8 +631,8 @@ func (s *Sandbox) Run(ctx context.Context, cmd ...string) (*Result, error) { if err := ctx.Err(); err != nil { return nil, err } - if len(cmd) == 0 { - return nil, fmt.Errorf("sandlock: empty command") + if err := checkCommand(cmd); err != nil { + return nil, err } policyPtr, err := s.buildPolicy() if err != nil { @@ -654,8 +675,8 @@ func (s *Sandbox) RunInteractive(ctx context.Context, cmd ...string) (int, error if err := ctx.Err(); err != nil { return -1, err } - if len(cmd) == 0 { - return -1, fmt.Errorf("sandlock: empty command") + if err := checkCommand(cmd); err != nil { + return -1, err } policyPtr, err := s.buildPolicy() if err != nil { @@ -672,6 +693,12 @@ func (s *Sandbox) RunInteractive(ctx context.Context, cmd ...string) (int, error name := s.cName() defer freeName(name) + // sandlock_run_interactive returns the child's exit code, and also -1 for + // every failure to get that far (null policy, bad name, no runtime, run + // failed), with no way to tell the two apart and no message. The code is + // handed back as the core reported it rather than guessing a verdict here; + // the fix is an error out-param on that C entry point, not a rule invented + // in this binding. code := int(C.sandlock_run_interactive(policyPtr, name, ap, ac)) return code, nil } @@ -683,8 +710,8 @@ func (s *Sandbox) DryRun(ctx context.Context, cmd ...string) (*DryRunResult, err if err := ctx.Err(); err != nil { return nil, err } - if len(cmd) == 0 { - return nil, fmt.Errorf("sandlock: empty command") + if err := checkCommand(cmd); err != nil { + return nil, err } policyPtr, err := s.buildPolicy() if err != nil { @@ -703,7 +730,11 @@ func (s *Sandbox) DryRun(ctx context.Context, cmd ...string) (*DryRunResult, err r := C.sandlock_dry_run(policyPtr, name, ap, ac) if r == nil { - return nil, fmt.Errorf("sandlock: dry run failed (Workdir is required; check that readable paths exist)") + // sandlock_dry_run signals every failure the same way, with a null + // pointer and no message, so this says only what the binding actually + // knows. Naming a probable cause here would attribute a missing + // runtime or a failed fork to whichever field this text guessed at. + return nil, fmt.Errorf("sandlock: dry run failed") } defer C.sandlock_dry_run_result_free(r) @@ -806,8 +837,8 @@ type Process struct { // Spawn forks the sandboxed child, installs the policy, and releases it to // exec cmd without waiting. Use the returned Process to manage its lifecycle. func (s *Sandbox) Spawn(cmd ...string) (*Process, error) { - if len(cmd) == 0 { - return nil, fmt.Errorf("sandlock: empty command") + if err := checkCommand(cmd); err != nil { + return nil, err } policyPtr, err := s.buildPolicy() if err != nil { @@ -863,13 +894,8 @@ func fdFile(fd C.int, name string) *os.File { // goroutine interrupts a blocked Wait; Close reaps the child and closes the // streams. Wait closes a still-open piped Stdin for you to deliver EOF. func (s *Sandbox) Popen(stdio Stdio, cmd ...string) (*Process, error) { - if len(cmd) == 0 { - return nil, fmt.Errorf("sandlock: empty command") - } - for _, m := range []StdioMode{stdio.Stdin, stdio.Stdout, stdio.Stderr} { - if m > StdioNull { - return nil, fmt.Errorf("sandlock: invalid StdioMode %d", uint32(m)) - } + if err := checkCommand(cmd); err != nil { + return nil, err } policyPtr, err := s.buildPolicy() if err != nil { @@ -886,6 +912,28 @@ func (s *Sandbox) Popen(stdio Stdio, cmd ...string) (*Process, error) { name := s.cName() defer freeName(name) + // A StdioMode with no variant is a representation problem, not a policy + // one: sandlock_popen does diagnose it, and names the stream, but it has + // no error out-param, so it writes that diagnosis to this process's + // stderr and returns null. The caller would get "popen failed" and the + // only actionable text would land somewhere a server or a test harness + // cannot capture. Until that entry point grows the err/err_msg pair + // sandlock_sandbox_build has, name it here, the way the Python SDK does + // with StdioMode(...) for the same input. + for _, stream := range []struct { + field string + mode StdioMode + }{ + {"Stdin", stdio.Stdin}, {"Stdout", stdio.Stdout}, {"Stderr", stdio.Stderr}, + } { + if stream.mode > StdioNull { + return nil, fmt.Errorf( + "sandlock: %s: invalid StdioMode %d (valid: %d=inherit, %d=piped, %d=null)", + stream.field, uint32(stream.mode), StdioInherit, StdioPiped, StdioNull, + ) + } + } + fdIn, fdOut, fdErr := C.int(-1), C.int(-1), C.int(-1) h := C.sandlock_popen( policyPtr, name, ap, ac, @@ -1048,10 +1096,14 @@ func (p *Process) Ports() (map[int]int, error) { } out := make(map[int]int, len(m)) for k, v := range m { - var vp int - if _, err := fmt.Sscanf(k, "%d", &vp); err == nil { - out[vp] = v + // A key the core emitted that does not parse is malformed data, the + // same as a malformed document; report it rather than dropping the + // mapping and handing back a table that quietly lost an entry. + vp, err := strconv.Atoi(k) + if err != nil { + return nil, fmt.Errorf("sandlock: parsing port mappings: virtual port %q: %w", k, err) } + out[vp] = v } return out, nil } diff --git a/go/sandlock_linux_test.go b/go/sandlock_linux_test.go index 74d81bc1..f5620a5e 100644 --- a/go/sandlock_linux_test.go +++ b/go/sandlock_linux_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/exec" "runtime" "strings" "testing" @@ -454,11 +455,33 @@ func TestPopenKillAfterWaitIsNil(t *testing.T) { } func TestPopenInvalidStdioMode(t *testing.T) { - // An out-of-range discriminant is rejected before the child is spawned, so - // this needs no Landlock. + // sandlock_popen does diagnose an out-of-range discriminant, but it has no + // error out-param: it writes the text to this process's stderr and returns + // null, so a caller that only has the returned error got "popen failed" + // and nothing to act on. The returned error has to name the stream and the + // number, which is also what the Python SDK raises for the same input. + // Rejection happens before the child is spawned, so this needs no Landlock. sb := &sandlock.Sandbox{FSReadable: rootfs} - if _, err := sb.Popen(sandlock.Stdio{Stdout: sandlock.StdioMode(99)}, "echo", "x"); err == nil { - t.Fatal("an out-of-range StdioMode must be rejected") + for _, tc := range []struct { + field string + stdio sandlock.Stdio + }{ + {"Stdin", sandlock.Stdio{Stdin: sandlock.StdioMode(99)}}, + {"Stdout", sandlock.Stdio{Stdout: sandlock.StdioMode(3)}}, + {"Stderr", sandlock.Stdio{Stderr: sandlock.StdioMode(^uint32(0))}}, + } { + t.Run(tc.field, func(t *testing.T) { + _, err := sb.Popen(tc.stdio, "echo", "x") + if err == nil { + t.Fatal("an out-of-range StdioMode must be rejected") + } + if !strings.Contains(err.Error(), tc.field) { + t.Fatalf("error = %q, want it to name the %s stream", err, tc.field) + } + if !strings.Contains(err.Error(), "invalid StdioMode") { + t.Fatalf("error = %q, want it to name the offending mode", err) + } + }) } } @@ -605,6 +628,54 @@ func TestSyscallNr(t *testing.T) { } } +// confineHelperEnv marks the re-executed copy of this test binary that +// actually performs a confinement. Confinement is irreversible, so it cannot +// run in the process that is running the rest of the suite. +const confineHelperEnv = "SANDLOCK_GO_CONFINE_HELPER" + +func TestConfineHelperProcess(t *testing.T) { + spec := os.Getenv(confineHelperEnv) + if spec == "" { + t.Skip("helper for TestConfineAcceptsADefaultSandbox") + } + sb := &sandlock.Sandbox{FSReadable: rootfs, FSWritable: []string{"/tmp"}} + switch spec { + case "default": + // Nothing said about the COW branch, which is the shape every caller + // who does not care about it produces. + case "abort": + sb.OnExit = sandlock.Ptr(sandlock.BranchActionAbort) + sb.OnError = sandlock.Ptr(sandlock.BranchActionAbort) + case "keep": + sb.OnExit = sandlock.Ptr(sandlock.BranchActionKeep) + sb.OnError = sandlock.Ptr(sandlock.BranchActionKeep) + default: + t.Fatalf("unknown helper spec %q", spec) + } + if err := sandlock.Confine(sb); err != nil { + t.Fatalf("Confine: %v", err) + } +} + +func TestConfineAcceptsADefaultSandbox(t *testing.T) { + // A confinement has no COW branch, so no branch action can change what it + // does. The core used to demand `on_error == Abort` all the same, which no + // default-built sandbox has, so this rejected the policy in the SDK + // quickstarts. Run in a re-executed copy of this binary: confinement is + // irreversible. + requireLandlock(t) + for _, spec := range []string{"default", "abort", "keep"} { + t.Run(spec, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestConfineHelperProcess", "-test.v") + cmd.Env = append(os.Environ(), confineHelperEnv+"="+spec) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helper failed: %v\n%s", err, out) + } + }) + } +} + func TestConfineRejectsSupervisorConfig(t *testing.T) { // Confine only honors Landlock fields; a field requiring a supervisor // must be rejected rather than silently ignored. Asserting the rejection diff --git a/python/README.md b/python/README.md index 3e9d6988..53c30182 100644 --- a/python/README.md +++ b/python/README.md @@ -13,10 +13,21 @@ pip install sandlock ## Quick start ```python +import os from sandlock import Sandbox +# Every readable path is forwarded as written; the SDK no longer drops one +# that is not there, because whether a missing path is a portability detail or +# a typo is the caller's call. A missing path fails when the child installs +# the rule, without naming it. `/lib64` is absent on arm64 and on musl, so +# this example decides for itself and filters. +system_readable = [ + p for p in ("/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev") + if os.path.isdir(p) +] + sandbox = Sandbox( - fs_readable=["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"], + fs_readable=system_readable, fs_writable=["/tmp"], ) result = sandbox.run(["echo", "hello"], timeout=10) @@ -103,7 +114,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,21 +165,26 @@ 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", @@ -181,8 +197,8 @@ sandbox = Sandbox( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `max_memory` | `str \| int \| None` | `None` | Memory limit, e.g. `"512M"` or int bytes | -| `max_processes` | `int` | `64` | Peak concurrent process limit | +| `max_memory` | `str \| int \| None` | `None` | Memory limit: a size string (`"512M"`) or a count of bytes. Resolved by the core, the same parser the CLI and profiles use | +| `max_processes` | `int \| None` | `None` | Peak concurrent process limit; `None` uses the core's default | | `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) | | `cpu_cores` | `list[int] \| None` | `None` | CPU cores to pin sandbox to | @@ -202,7 +218,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` | `str \| int \| float \| None` | `None` | Start timestamp: an RFC 3339 stamp (`"2026-01-01T00:00:00Z"`) or 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 | @@ -224,8 +240,7 @@ Sandlock always applies its default syscall blocklist. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `uid` | `int \| None` | `None` | Map to given UID inside a user namespace (e.g. `0` for fake root). Set together with `gid` | -| `gid` | `int \| None` | `None` | Map to given GID inside the user namespace. Must be set together with `uid` (both or neither) | +| `user` | `User \| None` | `None` | Identity inside a user namespace: `User(uid, gid)`, e.g. `User(0, 0)` for fake root. One value carrying both ids, mirroring the core, so a uid without a gid cannot be written | | `no_coredump` | `bool` | `False` | Disable core dumps | #### COW filesystem isolation @@ -233,9 +248,9 @@ 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"`) | -| `on_exit` | `BranchAction` | `COMMIT` | `COMMIT`, `ABORT`, or `KEEP` | -| `on_error` | `BranchAction` | `ABORT` | `COMMIT`, `ABORT`, or `KEEP` | +| `max_disk` | `str \| int \| None` | `None` | Disk quota for COW storage: a size string (`"1G"`) or a count of bytes | +| `on_exit` | `BranchAction \| None` | `None` | `COMMIT`, `ABORT`, or `KEEP`; `None` uses the core's default (commit) | +| `on_error` | `BranchAction \| None` | `None` | `COMMIT`, `ABORT`, or `KEEP`; `None` uses the core's default (commit) | #### Protection opt-out @@ -623,6 +638,18 @@ 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. + +The same fields also take the spellings a profile is written in +(`max_memory="512M"`, `time_start="2026-01-01T00:00:00Z"`), and those are not +parsed here either: they go to the core's setters as written, so a profile and +a hand-built `Sandbox` reach the same policy through the same parser. + ### Exceptions ``` @@ -712,7 +739,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` | `"256M"` | Memory limit: a size string or a count of bytes | Any `Sandbox` field name is accepted as a capability key. diff --git a/python/examples/basic.py b/python/examples/basic.py index 657033a2..8f942de0 100644 --- a/python/examples/basic.py +++ b/python/examples/basic.py @@ -2,10 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 """Basic Sandlock sandbox examples.""" +import os + from sandlock import Sandbox # Minimum filesystem readable to exec common binaries. -_BASE_READ = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"] +# Present system paths only: /lib64 is absent on arm64 and /sbin is a +# symlink into /usr on merged-usr hosts. A grant on a path that is not +# there fails when the child installs its Landlock rules, and the failure +# does not name the path, so filter here rather than debug it there. +_BASE_READ = [ + p for p in ("/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev") + if os.path.exists(p) +] def example_run_command(): diff --git a/python/examples/nested.py b/python/examples/nested.py index 69d6e744..2cead266 100644 --- a/python/examples/nested.py +++ b/python/examples/nested.py @@ -2,20 +2,32 @@ # SPDX-License-Identifier: Apache-2.0 """Nested sandbox example.""" +import os + from sandlock import Sandbox +def _present(*paths: str) -> list[str]: + """Keep the system paths this host actually has. + + /lib64 is absent on arm64 and /sbin is a symlink into /usr on merged-usr + hosts. A grant on a path that is not there fails when the child installs + its Landlock rules, and the failure does not name the path, so filter here + rather than debug it there. + """ + return [p for p in paths if os.path.exists(p)] + def example_nested(): """Create nested sandboxes with progressively restrictive policies.""" print("=== Nested sandboxes ===") outer = Sandbox( - fs_readable=["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"], + fs_readable=_present("/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"), fs_writable=["/tmp"], ) inner = Sandbox( - fs_readable=["/usr", "/lib", "/lib64", "/bin"], + fs_readable=_present("/usr", "/lib", "/lib64", "/bin"), fs_writable=[], ) diff --git a/python/examples/prompt_injection_defense.py b/python/examples/prompt_injection_defense.py index 547d7fda..9e2f51ee 100644 --- a/python/examples/prompt_injection_defense.py +++ b/python/examples/prompt_injection_defense.py @@ -230,11 +230,19 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): # # Data flows: planner stdout ──pipe──▶ executor stdin + # Present system paths only: /lib64 is absent on arm64 and /sbin is a + # symlink into /usr on merged-usr hosts. A grant on a path that is not + # there fails when the child installs its Landlock rules, and the failure + # does not name the path, so filter here rather than debug it there. + system_paths = [ + p for p in ("/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", "/dev") + if os.path.exists(p) + ] + planner = Sandbox( - fs_readable=list(dict.fromkeys([ - "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", - "/dev", python_prefix, - ] + python_paths)), + fs_readable=list(dict.fromkeys( + system_paths + [python_prefix] + python_paths + )), net_allow=["api.openai.com:443"], # only OpenAI HTTPS clean_env=True, env={"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]}, @@ -242,10 +250,12 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): ) executor = Sandbox( - fs_readable=list(dict.fromkeys([ - workspace, "/usr", "/lib", "/lib64", "/etc", - "/bin", "/sbin", python_prefix, - ] + python_paths)), + fs_readable=list(dict.fromkeys( + [workspace] + + [p for p in system_paths if p != "/dev"] + + [python_prefix] + + python_paths + )), net_allow=[], # No network at all clean_env=True, env={"DATA_FILE": csv_path}, diff --git a/python/examples/s3_handlers.py b/python/examples/s3_handlers.py index ccd39740..5bedd2a5 100644 --- a/python/examples/s3_handlers.py +++ b/python/examples/s3_handlers.py @@ -346,7 +346,13 @@ async def handle(self, ctx: HandlerCtx) -> NotifAction: # A real client implementing head()/get() drops in here unchanged. ns = Namespace(backend) - sandbox = Sandbox(fs_readable=["/usr", "/lib", "/lib64", "/etc", "/bin"]) + # Present system paths only: /lib64 is absent on arm64. A grant on a path + # that is not there fails when the child installs its Landlock rules, and + # the failure does not name the path. + system_paths = [ + p for p in ("/usr", "/lib", "/lib64", "/etc", "/bin") if os.path.exists(p) + ] + sandbox = Sandbox(fs_readable=system_paths) result = sandbox.run_with_handlers( cmd, [ diff --git a/python/examples/supply_chain_defense.py b/python/examples/supply_chain_defense.py index 84fc4da5..7986643c 100644 --- a/python/examples/supply_chain_defense.py +++ b/python/examples/supply_chain_defense.py @@ -73,8 +73,15 @@ def serve(): """) python_paths = [p for p in sys.path if p and os.path.isdir(p)] - fs_readable = ["/usr", "/lib", "/lib64", "/bin", - "/etc", "/dev", "/tmp", workspace] + python_paths + # Present system paths only: /lib64 is absent on arm64 and /sbin is a + # symlink into /usr on merged-usr hosts. A grant on a path that is not + # there fails when the child installs its Landlock rules, and the failure + # does not name the path, so filter here rather than debug it there. + system_paths = [ + p for p in ("/usr", "/lib", "/lib64", "/bin", "/etc", "/dev", "/tmp") + if os.path.exists(p) + ] + fs_readable = system_paths + [workspace] + python_paths fs_writable = [workspace, "/tmp"] # --- Run 1: no defense --- diff --git a/python/examples/web_search_injection_defense.py b/python/examples/web_search_injection_defense.py index b29eb2a6..34d8ad7e 100644 --- a/python/examples/web_search_injection_defense.py +++ b/python/examples/web_search_injection_defense.py @@ -264,10 +264,17 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): )) python_paths = [p for p in sys.path if p and os.path.isdir(p)] - base_readable = list(dict.fromkeys([ - "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", - "/dev", python_prefix, - ] + python_paths)) + # Present system paths only: /lib64 is absent on arm64 and /sbin is a + # symlink into /usr on merged-usr hosts. A grant on a path that is not + # there fails when the child installs its Landlock rules, and the failure + # does not name the path, so filter here rather than debug it there. + system_paths = [ + p for p in ("/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", "/dev") + if os.path.exists(p) + ] + base_readable = list(dict.fromkeys( + system_paths + [python_prefix] + python_paths + )) # Planner sandbox (shared by planner1 + planner2): reach OpenAI, # NO filesystem access to the data file. 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..3f179acc 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, User, parse_ports, Change, DryRunResult, StdioMode, Process, ) from ._profile import load_profile, list_profiles from .exceptions import ( @@ -50,6 +50,8 @@ "GatherPipeline", "inputs", "BranchAction", + "Mount", + "User", "parse_ports", "Change", "DryRunResult", diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index da6b919a..1fb48de9 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -1,116 +1,119 @@ # 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, user (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, User _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 +# the field-for-field loop does not carry: program identity, which is not +# policy, and the two ids that are joined into ``user`` afterwards. +# +# 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", + # Two canonical keys, one Sandbox field: joined by `_user` below. + "uid": None, + "gid": None, + "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 +136,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 +148,157 @@ 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 + + user = _user(canonical["program"]) + if user is not None: + kwargs["user"] = user + + return Sandbox(**kwargs) + + +def _user(program: dict) -> User | None: + """Join the canonical ``uid``/``gid`` pair into one :class:`User`. + + The core refuses a profile that sets one without the other, and the + canonical form is produced by that same builder, so a half-set pair here + means the two sides disagree about the contract rather than that the + profile was wrong. That is reported as such, the way a missing key is. + """ + uid, gid = program["uid"], program["gid"] + if uid is None and gid is None: + return None + if uid is None or gid is None: + raise PolicyError( + "[program]: canonical profile carries uid without gid or the " + f"other way round (uid={uid!r}, gid={gid!r}); core and SDK are " + "out of sync" + ) + return User(uid=uid, gid=gid) + + +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) -> str | int: + """Take the rendered stamp, not the pair, whenever there is a remainder. + + ``seconds + nanos / 1e9`` is a double, and a double has about 238ns of + spacing at 2026 epoch values, so it cannot hold the pair the core just + resolved. ``"...T00:00:00.9999999Z"`` rounded up to the next whole second + through here, and the core floors ``time_start`` to whole seconds, so the + same profile ran one second later through this SDK than through the CLI: + the drift the canonical form exists to remove, re-created one line after + it arrives. The core renders the instant for us for exactly this reason, + the way it renders net and HTTP rules back into spec strings. + + A whole second still comes back as an ``int``: it is exact, and it keeps + the numeric door (``sandlock_sandbox_builder_time_start_epoch``) exercised + by the common case. + """ + _check_keys(value, ("seconds", "nanoseconds", "rfc3339"), "time_start") + if value["nanoseconds"] == 0: + return value["seconds"] + return value["rfc3339"] 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..539e986c 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -4,6 +4,8 @@ import ctypes import ctypes.util +import json +import math import os import signal import sys @@ -80,10 +82,11 @@ 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) -_b_max_disk = _builder_fn("sandlock_sandbox_builder_max_disk", ctypes.c_uint64) +_b_max_memory = _builder_fn("sandlock_sandbox_builder_max_memory", ctypes.c_char_p) +_b_max_disk = _builder_fn("sandlock_sandbox_builder_max_disk", ctypes.c_char_p) _b_max_processes = _builder_fn("sandlock_sandbox_builder_max_processes", ctypes.c_uint32) _b_max_cpu = _builder_fn("sandlock_sandbox_builder_max_cpu", ctypes.c_uint8) _b_num_cpus = _builder_fn("sandlock_sandbox_builder_num_cpus", ctypes.c_uint32) @@ -103,7 +106,10 @@ def _builder_fn(name, *extra_args): _b_random_seed = _builder_fn("sandlock_sandbox_builder_random_seed", ctypes.c_uint64) _b_clean_env = _builder_fn("sandlock_sandbox_builder_clean_env", ctypes.c_bool) _b_env_var = _builder_fn("sandlock_sandbox_builder_env_var", ctypes.c_char_p, ctypes.c_char_p) -_b_time_start = _builder_fn("sandlock_sandbox_builder_time_start", ctypes.c_uint64) +_b_time_start = _builder_fn("sandlock_sandbox_builder_time_start", ctypes.c_char_p) +_b_time_start_epoch = _builder_fn( + "sandlock_sandbox_builder_time_start_epoch", ctypes.c_int64, ctypes.c_uint32 +) _b_extra_deny_syscalls = _builder_fn("sandlock_sandbox_builder_extra_deny_syscalls", ctypes.c_char_p) _b_extra_allow_syscalls = _builder_fn("sandlock_sandbox_builder_extra_allow_syscalls", ctypes.c_char_p) _b_max_open_files = _builder_fn("sandlock_sandbox_builder_max_open_files", ctypes.c_uint32) @@ -147,22 +153,6 @@ class Protection(IntEnum): ) -def _validate_protection(p: int, *, field: str) -> int: - """Coerce a caller-supplied protection value to a known discriminant - or raise :class:`ValueError`. Centralises the range check so the FFI - is never invoked with an unknown integer (the Rust setters silently - no-op on bad input, which is the wrong UX for the Python caller — - we want a loud failure at the SDK boundary instead). - """ - try: - return int(Protection(int(p))) - except (ValueError, TypeError) as e: - valid = ", ".join(f"{m.name}={int(m)}" for m in Protection) - raise ValueError( - f"{field}: {p!r} is not a known Protection discriminant " - f"(valid: {valid})" - ) from e - # Policy callback (policy_fn). # Path strings absent (issue #27 — path-based control belongs in Landlock). # argv is populated for execve only; TOCTOU-safe via sibling freeze. @@ -283,6 +273,74 @@ def confine(policy: "PolicyDataclass") -> None: _lib.sandlock_string_free.restype = None _lib.sandlock_string_free.argtypes = [ctypes.c_char_p] + +def _free_builder(b) -> None: + """Release a builder that will never be built. + + The C ABI has no `sandlock_sandbox_builder_free`; `sandlock_sandbox_build` + is the only entry point that consumes a builder, so an abandoned one is + released by building it and throwing the result away. Whatever verdict the + build reaches is irrelevant here: the caller is already unwinding with the + reason it stopped. The Go SDK carried the same helper for the same reason. + """ + if not b: + return + err = ctypes.c_int(0) + err_msg = ctypes.c_char_p() + ptr = _lib.sandlock_sandbox_build(b, ctypes.byref(err), ctypes.byref(err_msg)) + if err_msg.value: + _lib.sandlock_string_free(err_msg) + if ptr: + _lib.sandlock_sandbox_free(ptr) + +# 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] @@ -676,7 +734,10 @@ class SyscallEvent: cost. """ syscall: str - category: str # "file", "network", "process", "memory" + category: str | int + """``"file"``, ``"network"``, ``"process"`` or ``"memory"``; a category + this SDK has no name for is the raw discriminant the core sent, rather + than one of the four names it is not.""" pid: int parent_pid: int = 0 host: str | None = None @@ -740,6 +801,103 @@ def _encode(s: str) -> bytes: raise ValueError(f"NUL byte in string argument: {result!r}") return result + +def _fits(value, field: str, *, bits: int, signed: bool = False) -> int: + """Check that an integer survives the C ABI parameter that carries it. + + A representation check, and the one kind the core cannot make for us: the + setter's parameter is a fixed-width integer, Python's is not, and ctypes + converts by masking rather than by failing. Without this, ``max_cpu=300`` + arrives as 44 and is accepted as a perfectly ordinary throttle, and + ``uid=-1`` arrives as 4294967295. The value the core would have judged is + gone before it gets there, so refusing here is what keeps its verdict + reachable, not a second opinion on the policy. + + ``bool`` is rejected outright: it is an ``int`` subclass, so ``True`` + would otherwise pass silently as 1. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{field} must be an integer, got {value!r}") + lo, hi = (-(1 << (bits - 1)), (1 << (bits - 1)) - 1) if signed else (0, (1 << bits) - 1) + if not lo <= value <= hi: + raise ValueError( + f"{field}={value} does not fit the {'int' if signed else 'uint'}{bits} " + f"parameter of its C ABI setter (permitted: {lo}..{hi})" + ) + return value + + +def _branch_action(value, field: str) -> int: + """Render a branch action as the discriminant its C ABI setter takes. + + A :class:`~sandlock.BranchAction`, or one of the three spellings it is + built from, becomes its own discriminant. A number is passed through + untouched, so an action this SDK does not know still reaches the core and + is refused there by value, where it used to be replaced by Commit or Abort + through a ``.get(value, default)``: a typo became a decision about the + caller's writes. + + A word the enum does not know cannot be forwarded at all, because the + setter's parameter is a ``uint8_t``. That is a limit of the ABI, not a + second opinion on the policy, and it is the only case answered here. + """ + from .sandbox import BranchAction + + if isinstance(value, BranchAction): + return value.abi + if isinstance(value, int) and not isinstance(value, bool): + return _fits(value, field, bits=8) + try: + return BranchAction(value).abi + except ValueError: + raise ValueError( + f"{field}: {value!r} is not a branch action; " + f"the C ABI setter carries a discriminant, so a word it does not " + f"name ({', '.join(a.value for a in BranchAction)}) cannot be " + f"forwarded to the core to be judged there" + ) from None + + +def _epoch_split(value: float) -> tuple[int, int]: + """Split epoch seconds into the ``(seconds, nanoseconds)`` pair the ABI takes. + + Not a grammar: the unit is fixed on both sides, so this only moves the + sub-second part into its own field, flooring the way + ``sandlock_profile_parse`` does so the remainder is never negative. A + string is never routed through here; it goes to the core verbatim. + """ + seconds = math.floor(value) + nanoseconds = round((value - seconds) * 1_000_000_000) + if nanoseconds == 1_000_000_000: # the remainder rounded up to a full second + seconds += 1 + nanoseconds = 0 + return seconds, nanoseconds + + +def _b_time_start_from(b, value): + """Send ``time_start`` through whichever of its two setters fits the value. + + Text goes to ``sandlock_sandbox_builder_time_start`` untouched, so the RFC + 3339 grammar is read once, by the core, exactly as it is for a profile key + and a command-line flag. A number is an instant that has already been + resolved (it is what ``sandlock_profile_parse`` reports) and goes to + ``..._time_start_epoch``, which takes the resolved form directly; rendering + it back into a stamp here would be this SDK writing the grammar it just + stopped reading. + """ + if isinstance(value, str): + return _b_time_start(b, _encode(value)) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + "time_start must be an RFC 3339 string or epoch seconds, got " + f"{value!r}" + ) + seconds, nanoseconds = _epoch_split(value) + return _b_time_start_epoch( + b, _fits(seconds, "time_start seconds", bits=64, signed=True), nanoseconds + ) + + def _make_argv(cmd: Sequence[str]): """Create a (c_char_p array, argc) pair from a list of strings.""" argc = len(cmd) @@ -1027,7 +1185,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", + "user", "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,158 +1201,188 @@ 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 []): - if str(p) == "/lib64" and not os.path.exists("/lib64"): - continue - b = _b_fs_read(b, _encode(str(p))) - for p in (policy.fs_writable or []): - b = _b_fs_write(b, _encode(str(p))) - for p in (policy.fs_denied or []): - b = _b_fs_deny(b, _encode(str(p))) - - if policy.fs_storage: - b = _b_fs_storage(b, _encode(str(policy.fs_storage))) - - if policy.gpu_devices is not None: - arr = (ctypes.c_uint32 * len(policy.gpu_devices))(*policy.gpu_devices) - b = _b_gpu_devices(b, arr, len(policy.gpu_devices)) - - if policy.workdir: - b = _b_workdir(b, _encode(str(policy.workdir))) - if policy.cwd: - 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))) - - # COW branch actions (0=Commit, 1=Abort, 2=Keep) - _action_map = {"commit": 0, "abort": 1, "keep": 2} - on_exit_val = policy.on_exit.value if hasattr(policy.on_exit, 'value') else str(policy.on_exit) - on_error_val = policy.on_error.value if hasattr(policy.on_error, 'value') else str(policy.on_error) - b = _b_on_exit(b, _action_map.get(on_exit_val, 0)) - 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) - - 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) - - if policy.max_processes != 64: - b = _b_max_processes(b, policy.max_processes) - if policy.max_cpu is not None: - b = _b_max_cpu(b, policy.max_cpu) - if policy.num_cpus is not None: - b = _b_num_cpus(b, policy.num_cpus) - if policy.cpu_cores is not None: - arr = (ctypes.c_uint32 * len(policy.cpu_cores))(*policy.cpu_cores) - b = _b_cpu_cores(b, arr, len(policy.cpu_cores)) - - # net_allow: list of endpoint specs. Bare `host:port` means TCP - # and UDP; `tcp://`/`udp://`/`icmp://` schemes pin one protocol. - # Empty = deny all outbound. net_deny is the inverse (default-allow - # denylist of IP/CIDR/port specs); the two are mutually exclusive. - # Validation of each spec happens in the native build(). - for spec in (policy.net_allow or []): - b = _b_net_allow(b, _encode(str(spec))) - for spec in (policy.net_deny or []): - b = _b_net_deny(b, _encode(str(spec))) - for spec in (policy.net_allow_bind or []): - b = _b_net_allow_bind(b, _encode(str(spec))) - for spec in (policy.net_deny_bind or []): - b = _b_net_deny_bind(b, _encode(str(spec))) - - for rule in (policy.http_allow or []): - b = _b_http_allow(b, _encode(str(rule))) - for rule in (policy.http_deny or []): - b = _b_http_deny(b, _encode(str(rule))) - for port in (policy.http_ports or []): - b = _b_http_port(b, int(port)) - if policy.http_ca: - b = _b_http_ca(b, _encode(str(policy.http_ca))) - if policy.http_key: - b = _b_http_key(b, _encode(str(policy.http_key))) - for path in (policy.http_inject_ca or []): - b = _b_http_inject_ca(b, _encode(str(path))) - if policy.http_ca_out: - b = _b_http_ca_out(b, _encode(str(policy.http_ca_out))) - - if policy.port_remap: - b = _b_port_remap(b, True) - - if policy.uid is not None or policy.gid is not None: - if policy.uid is None or policy.gid is None: - raise ValueError("uid and gid must both be set (or both unset)") - b = _b_user(b, policy.uid, policy.gid) - - 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) - if policy.clean_env: - b = _b_clean_env(b, True) - for k, v in (policy.env or {}).items(): - b = _b_env_var(b, _encode(k), _encode(v)) - - if policy.extra_deny_syscalls: - b = _b_extra_deny_syscalls(b, _encode(",".join(policy.extra_deny_syscalls or []))) - if policy.extra_allow_syscalls: - b = _b_extra_allow_syscalls(b, _encode(",".join(policy.extra_allow_syscalls or []))) - if policy.max_open_files is not None: - b = _b_max_open_files(b, policy.max_open_files) - - if policy.no_randomize_memory: - b = _b_no_randomize_memory(b, True) - if policy.no_huge_pages: - b = _b_no_huge_pages(b, True) - if policy.no_coredump: - b = _b_no_coredump(b, True) - if policy.deterministic_dirs: - b = _b_deterministic_dirs(b, True) - - # Landlock protection opt-out. The C ABI setters use move-semantics - # and return the (possibly relocated) builder pointer — mirror that - # by rebinding `b` on each call. Idempotent / last-wins: if the - # same Protection appears in both lists, the later call wins - # (matching the underlying `ProtectionPolicy::set` semantics). - for p in (policy.allow_degraded or ()): - b = _b_allow_degraded(b, _validate_protection(p, field="allow_degraded")) - for p in (policy.disable or ()): - b = _b_disable(b, _validate_protection(p, field="disable")) - - # Guard: warn if any dataclass field was set to a non-default value - # but is not in _HANDLED_FIELDS (i.e. silently dropped). - import dataclasses as _dc - import warnings as _w - from .sandbox import Sandbox as _Sandbox - _defaults = _Sandbox() - for f in _dc.fields(policy): - if f.name in _NativePolicy._HANDLED_FIELDS: - continue - val = getattr(policy, f.name) - default_val = getattr(_defaults, f.name) - if val != default_val: - _w.warn( - f"Policy field {f.name!r} is set but not wired through " - f"FFI — it will have no effect (value: {val!r})", - stacklevel=3, + try: + + # Every requested grant is forwarded, including one whose path is + # not there. This used to drop `/lib64` and only `/lib64`, which + # answered a portability question for the caller, silently, for one + # hardcoded path. It is the caller who knows whether a missing + # /lib64 is a portability detail or a typo. + # + # What the caller gets instead is late and thin: `build()` does not + # check existence, so the policy builds, and the rule is installed + # in the child, where Landlock has to open the path. A missing one + # comes back as `Result(success=False, error='sandlock_create + # failed')` with the path nowhere in it, and under `chroot` it is + # skipped without a word. Filter system paths on the way in if the + # set varies by host, the way `sandlock.mcp` does for its default + # policy. + for p in (policy.fs_readable or []): + b = _b_fs_read(b, _encode(str(p))) + for p in (policy.fs_writable or []): + b = _b_fs_write(b, _encode(str(p))) + for p in (policy.fs_denied or []): + b = _b_fs_deny(b, _encode(str(p))) + + if policy.fs_storage: + b = _b_fs_storage(b, _encode(str(policy.fs_storage))) + + if policy.gpu_devices is not None: + devices = [_fits(d, "gpu_devices entry", bits=32) for d in policy.gpu_devices] + arr = (ctypes.c_uint32 * len(devices))(*devices) + b = _b_gpu_devices(b, arr, len(devices)) + + if policy.workdir: + b = _b_workdir(b, _encode(str(policy.workdir))) + if policy.cwd: + b = _b_cwd(b, _encode(str(policy.cwd))) + if policy.chroot: + b = _b_chroot(b, _encode(str(policy.chroot))) + 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. An unknown value is forwarded as the number the + # ABI carries, so the core answers it by name ("unrecognized branch + # action 7"). It used to be mapped to Commit or Abort by `.get(v, 0)`, + # which turned a typo into a decision about the caller's writes. + if policy.on_exit is not None: + b = _b_on_exit(b, _branch_action(policy.on_exit, "on_exit")) + if policy.on_error is not None: + b = _b_on_error(b, _branch_action(policy.on_error, "on_error")) + + # Byte sizes go to the core as text, whichever way they were written. + # `'512M'` is the grammar's own spelling and a bare number is the same + # grammar's count of bytes, which is what a loaded profile resolves to, + # so both spellings meet at the one parser instead of being judged here. + if policy.max_memory is not None: + b = _b_max_memory(b, _encode(policy.max_memory)) + + if policy.max_disk is not None: + b = _b_max_disk(b, _encode(policy.max_disk)) + + if policy.max_processes is not None: + b = _b_max_processes(b, _fits(policy.max_processes, "max_processes", bits=32)) + if policy.max_cpu is not None: + b = _b_max_cpu(b, _fits(policy.max_cpu, "max_cpu", bits=8)) + if policy.num_cpus is not None: + b = _b_num_cpus(b, _fits(policy.num_cpus, "num_cpus", bits=32)) + if policy.cpu_cores is not None: + cores = [_fits(c, "cpu_cores", bits=32) for c in policy.cpu_cores] + arr = (ctypes.c_uint32 * len(cores))(*cores) + b = _b_cpu_cores(b, arr, len(cores)) + + # net_allow: list of endpoint specs. Bare `host:port` means TCP + # and UDP; `tcp://`/`udp://`/`icmp://` schemes pin one protocol. + # Empty = deny all outbound. net_deny is the inverse (default-allow + # denylist of IP/CIDR/port specs); the two are mutually exclusive. + # Validation of each spec happens in the native build(). + for spec in (policy.net_allow or []): + b = _b_net_allow(b, _encode(str(spec))) + for spec in (policy.net_deny or []): + b = _b_net_deny(b, _encode(str(spec))) + for spec in (policy.net_allow_bind or []): + b = _b_net_allow_bind(b, _encode(str(spec))) + for spec in (policy.net_deny_bind or []): + b = _b_net_deny_bind(b, _encode(str(spec))) + + for rule in (policy.http_allow or []): + b = _b_http_allow(b, _encode(str(rule))) + for rule in (policy.http_deny or []): + b = _b_http_deny(b, _encode(str(rule))) + for port in (policy.http_ports or []): + b = _b_http_port(b, _fits(port, "http_ports entry", bits=16)) + if policy.http_ca: + b = _b_http_ca(b, _encode(str(policy.http_ca))) + if policy.http_key: + b = _b_http_key(b, _encode(str(policy.http_key))) + for path in (policy.http_inject_ca or []): + b = _b_http_inject_ca(b, _encode(str(path))) + if policy.http_ca_out: + b = _b_http_ca_out(b, _encode(str(policy.http_ca_out))) + + if policy.port_remap: + b = _b_port_remap(b, True) + + # One setter call, one value: `User` carries both ids, so there is no + # half-set state left for this SDK to have an opinion about. + if policy.user is not None: + b = _b_user( + b, + _fits(policy.user.uid, "user.uid", bits=32), + _fits(policy.user.gid, "user.gid", bits=32), ) - return b + if policy.random_seed is not None: + b = _b_random_seed(b, _fits(policy.random_seed, "random_seed", bits=64)) + if policy.time_start is not None: + b = _b_time_start_from(b, policy.time_start) + if policy.clean_env: + b = _b_clean_env(b, True) + for k, v in (policy.env or {}).items(): + b = _b_env_var(b, _encode(k), _encode(v)) + + if policy.extra_deny_syscalls: + b = _b_extra_deny_syscalls(b, _encode(",".join(policy.extra_deny_syscalls or []))) + if policy.extra_allow_syscalls: + b = _b_extra_allow_syscalls(b, _encode(",".join(policy.extra_allow_syscalls or []))) + if policy.max_open_files is not None: + b = _b_max_open_files(b, _fits(policy.max_open_files, "max_open_files", bits=32)) + + if policy.no_randomize_memory: + b = _b_no_randomize_memory(b, True) + if policy.no_huge_pages: + b = _b_no_huge_pages(b, True) + if policy.no_coredump: + b = _b_no_coredump(b, True) + if policy.deterministic_dirs: + b = _b_deterministic_dirs(b, True) + + # Landlock protection opt-out. The C ABI setters use move-semantics + # and return the (possibly relocated) builder pointer, so mirror that + # by rebinding `b` on each call. Idempotent / last-wins: if the + # same Protection appears in both lists, the later call wins + # (matching the underlying `ProtectionPolicy::set` semantics). + # A discriminant this SDK does not know is the core's to refuse, + # by value, exactly as an unknown branch action is. Only the width + # is checked here: ctypes converts to uint32 by masking, so a value + # that does not fit would arrive as a different, plausible one and + # the core would never see what the caller wrote. + for p in (policy.allow_degraded or ()): + b = _b_allow_degraded(b, _fits(p, "allow_degraded", bits=32)) + for p in (policy.disable or ()): + b = _b_disable(b, _fits(p, "disable", bits=32)) + + # Guard: warn if any dataclass field was set to a non-default value + # but is not in _HANDLED_FIELDS (i.e. silently dropped). + import dataclasses as _dc + import warnings as _w + from .sandbox import Sandbox as _Sandbox + _defaults = _Sandbox() + for f in _dc.fields(policy): + if f.name in _NativePolicy._HANDLED_FIELDS: + continue + val = getattr(policy, f.name) + default_val = getattr(_defaults, f.name) + if val != default_val: + _w.warn( + f"Policy field {f.name!r} is set but not wired through " + f"FFI, so it will have no effect (value: {val!r})", + stacklevel=3, + ) + + return b + except BaseException: + # Every setter consumes the pointer it is given and returns a new + # one, so the only builder that still exists is the one `b` holds + # right now. There is no `sandlock_sandbox_builder_free` in the C + # ABI, and `sandlock_sandbox_build` is the only entry point that + # consumes a builder, so the release runs through it. Without + # this, a caller that validates policies it did not write leaks a + # fully loaded builder for every one it rejects. + _free_builder(b) + raise @classmethod def from_dataclass(cls, policy: PolicyDataclass, policy_fn=None) -> _NativePolicy: @@ -1212,10 +1401,14 @@ def _c_callback(event_p, ctx_p, _user_data): for i in range(ev.argc) if ev.argv[i] ) + # A category this SDK does not know is reported as the number + # the core sent. Naming it "file" instead would hand the + # callback a category the syscall does not belong to, and a + # policy that keys on "file" would then act on it. _CATEGORIES = {0: "file", 1: "network", 2: "process", 3: "memory"} py_event = SyscallEvent( syscall=ev.syscall.decode("utf-8") if ev.syscall else "", - category=_CATEGORIES.get(ev.category, "file"), + category=_CATEGORIES.get(ev.category, ev.category), pid=ev.pid, parent_pid=ev.parent_pid, host=ev.host.decode("utf-8") if ev.host else None, diff --git a/python/src/sandlock/mcp/_policy.py b/python/src/sandlock/mcp/_policy.py index 73a27927..3704199e 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: "256M"`` (or a count of bytes) Returns: A frozen :class:`Sandbox` instance. @@ -91,10 +91,22 @@ def policy_for_tool( # Fields that users cannot override — always enforced. _ENFORCED = {"clean_env"} + # The system paths are filtered to the ones this host actually has: a + # readable path that does not exist fails when the child installs its + # Landlock rules, and it fails as a bare "sandlock_create failed" without + # naming the path. The set differs between distributions and architectures + # (/lib64 is absent on arm64, /sbin is a symlink into /usr on merged-usr + # systems), so this policy would break by host. This is a choice about + # *this* default policy, so it is made here; the paths a caller names in + # `extra_readable` are theirs and are forwarded whether they exist or not. + system_readable = [ + p for p in ("/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin") + if os.path.isdir(p) + ] kwargs: dict[str, Any] = { "fs_writable": [], "fs_readable": list(dict.fromkeys([ - workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", + workspace, *system_readable, _PYTHON_PREFIX, *_INTERP_READABLE, *extra_readable, ])), "net_allow_bind": [], 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..3a46c0a3 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,51 +29,23 @@ 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+))?$") def parse_ports(specs: Sequence[int | str]) -> list[int]: - """Parse port specifications into a sorted list of unique port numbers. + """Expand port specifications into a sorted list of unique port numbers. Each spec is an int (single port) or a string holding a comma-separated list of single ports / inclusive ``"lo-hi"`` ranges, e.g. ``"80"``, - ``"8000-9000"``, or ``"8080,9000-9005"`` (matching the CLI's - ``--net-allow-bind`` grammar). Raises ValueError on out-of-range or bad - format. + ``"8000-9000"``, or ``"8080,9000-9005"``. Raises ValueError on + out-of-range or bad format. + + This is a convenience for a caller that wants the expansion in Python. It + is **not** the ``--net-allow-bind`` grammar and must not be used to + pre-expand a list before assigning it to :attr:`Sandbox.net_allow_bind`: + it does not accept the ``"*"`` wildcard, and it does not accept space + around the dash (``"80 - 90"``), both of which the core takes. Bind specs + are forwarded to the core as written and parsed once, there. """ ports: set[int] = set() for spec in specs: @@ -95,12 +68,34 @@ def parse_ports(specs: Sequence[int | str]) -> list[int]: class BranchAction(Enum): - """Action to take on the COW branch when sandbox exits.""" + """Action to take on the COW branch when sandbox exits. + + The member values are the profile spellings; :attr:`abi` is the + discriminant the C ABI setters carry. + """ COMMIT = "commit" # Merge writes into parent branch ABORT = "abort" # Discard all writes KEEP = "keep" # Leave branch as-is (caller decides) + @property + def abi(self) -> int: + """The discriminant ``sandlock_sandbox_builder_on_exit`` takes. + + Shared with ``sandlock_core::BranchAction`` and the ``uint8_t`` + documented in the C header. Kept here rather than as a lookup table at + the call site so there is one place to change if the ABI ever grows a + fourth action. + """ + return _BRANCH_ACTION_ABI[self] + + +_BRANCH_ACTION_ABI = { + BranchAction.COMMIT: 0, + BranchAction.ABORT: 1, + BranchAction.KEEP: 2, +} + class StdioMode(IntEnum): """Per-stream stdio wiring for :meth:`Sandbox.popen`. @@ -116,6 +111,43 @@ 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 User: + """The identity :attr:`Sandbox.user` maps the child to. + + One value carrying both ids, mirroring the core's own ``RunAs``: an + unprivileged user namespace maps a single pair, so a uid without a gid is + not a policy the core has any way to apply. Two separate optional fields + would let a caller write that state and oblige this SDK to answer for it; + one required pair means there is nothing left to answer for. + """ + + uid: int + """UID inside the user namespace. ``0`` is fake root.""" + + gid: int + """GID inside the user namespace. Supplementary groups are not available.""" + + @dataclass(frozen=True) class Change: """A single filesystem change detected by dry-run.""" @@ -265,12 +297,22 @@ class Sandbox: # Resource limits max_memory: str | int | None = None - """Memory limit. String like '512M' or int bytes.""" - - max_processes: int = 64 + """Memory limit: a size string (``'512M'``) or a count of bytes + (``536870912``). Both spellings go to the core exactly as written and are + resolved by the one parser that also reads ``[limits].memory`` in a profile + and ``--max-memory`` on the command line, so ``'512M'`` means here what it + means there and a value it refuses is refused here with its message. + A bare number is that grammar's spelling for a count of bytes, which is + what a loaded profile resolves to. ``None`` is the only spelling of + "unlimited": a ceiling of zero is refused, because zero is what the + supervisor already carries for "no ceiling".""" + + max_processes: int | None = None """Maximum total forks allowed in the sandbox (lifetime count, not concurrent). Enforced by the seccomp notif supervisor. - Also enables fork interception needed for checkpoint freeze.""" + Also enables fork interception needed for checkpoint freeze. + ``None`` leaves the limit to the core, which is the only place its + default is written down.""" max_open_files: int | None = None """Maximum number of open file descriptors. Enforced via @@ -291,7 +333,9 @@ class Sandbox: cpu_cores: Sequence[int] | None = None """CPU cores to pin the sandbox to. When set, sched_setaffinity() is called in the child to restrict it to the specified cores. - None = inherit parent affinity (unrestricted).""" + None = inherit parent affinity (unrestricted). Unlike + :attr:`gpu_devices`, an empty list is not "all cores" but an affinity + mask with no bits, so the core refuses it: omit the field instead.""" num_cpus: int | None = None """Visible CPU count in /proc/cpuinfo. When set, the sandbox sees @@ -310,11 +354,22 @@ 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: str | int | float | None = None + """Start timestamp for time virtualization: an RFC 3339 stamp + (``'2026-01-01T00:00:00Z'``) or Unix epoch seconds (``1767225600``). + When set, clock_gettime() and gettimeofday() return shifted time + starting from this epoch. Time ticks at real speed from the given + start point. + + A string is handed to the core verbatim and parsed by the same routine + that reads ``[determinism].time_start`` in a profile and ``--time-start`` + on the command line, so an offset (``'...-05:00'``), sub-second precision + and a pre-1970 instant all mean here what they mean there. A number is an + already-resolved instant, the form a loaded profile carries, and reaches + the core through the epoch setter without being rendered back into text. + For a :class:`datetime.datetime`, either spelling works: ``dt.isoformat()`` + for an aware datetime, or ``dt.timestamp()``. A naive datetime 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 +400,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 @@ -359,16 +417,11 @@ class Sandbox: """Variables to set or override in the child. Applied after clean_env.""" - uid: int | None = None - """Map to the given UID inside a user namespace. For example, - ``uid=0`` gives fake root, ``uid=1000`` maps to UID 1000. - The child has no real host privileges regardless of the mapped UID. - Only effective when user namespaces are available.""" - - gid: int | None = None - """Map to the given GID inside the user namespace. Must be set together - with ``uid`` (both or neither). An unprivileged user namespace maps a - single id, so supplementary groups are not available.""" + user: User | None = None + """Identity to run as inside a user namespace, or ``None`` to keep the + caller's. ``User(0, 0)`` gives fake root, ``User(1000, 1000)`` maps to + that pair. The child has no real host privileges regardless of the mapped + ids. Only effective when user namespaces are available.""" # Seccomp user notification (filesystem virtualization) notif_policy: NotifPolicy | None = None @@ -389,15 +442,25 @@ 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: str | int | None = None + """Disk quota for COW storage: a size string (``'1G'``) or a count of + bytes (``1073741824``), resolved by the core exactly as ``max_memory`` is. Enforced by the COW layer (returns ENOSPC).""" - on_exit: BranchAction = BranchAction.COMMIT - """Branch action on normal sandbox exit.""" + on_exit: BranchAction | str | int | None = None + """Branch action on normal sandbox exit. ``None`` leaves it to the core. + + This used to default to ``COMMIT`` here as well, which agreed with the + core by coincidence rather than by construction.""" - on_error: BranchAction = BranchAction.ABORT - """Branch action on sandbox error/exception.""" + on_error: BranchAction | str | int | None = None + """Branch action on sandbox error/exception. ``None`` leaves it to the + core, which commits. + + It used to default to ``ABORT`` here, a second default that disagreed with + the one the core applies to the very same field, so a policy that said + nothing about the error path discarded the guest's writes through this SDK + and kept them through the CLI, a profile, or the Go SDK.""" # Landlock protection opt-out — relax strict enforcement for the # named protections. See ``sandlock.Protection`` (the IntEnum mirror @@ -444,6 +507,34 @@ def __post_init__(self): raise ValueError("sandbox name must not contain '/'") if self.name in (".", ".."): raise ValueError("sandbox name must not be '.' or '..'") + # max_memory, max_disk and time_start are deliberately not checked + # here. Their grammars live in the core, which now takes them as text + # through its own setters, so `'512M'`, `'1.5G'` and + # `'2026-01-01T00:00:00Z'` are all questions for the parser that also + # answers them for a profile and for the command line. Judging them + # here would put a second opinion in front of the only one that counts. + 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() @@ -476,39 +567,6 @@ def _ensure_native(self): self._native = _NativePolicy.from_dataclass(self, policy_fn=self.policy_fn) return self._native - # ------------------------------------------------------------------ - # 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: - return None - return max(1, min(100, self.max_cpu)) - # ------------------------------------------------------------------ # Context manager # ------------------------------------------------------------------ diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index fe7aad20..ee7f9cb4 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import os import platform import shutil import subprocess @@ -16,10 +17,18 @@ from sandlock._sdk import _encode, _lib, _make_argv -_PYTHON_READABLE = list(dict.fromkeys([ - "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", - sys.prefix, -])) +# The SDK forwards every readable path as written; nothing checks that one +# exists. A missing path fails late and anonymously, when the child installs +# the rule (`sandlock_create failed`, path not named), and under `chroot` the +# rule is skipped in silence instead. So the list filters rather than naming +# `/lib64` on a host (arm64, musl) that has none. +_PYTHON_READABLE = [ + p for p in dict.fromkeys([ + "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", + sys.prefix, + ]) + if os.path.isdir(p) +] def _policy(**overrides): diff --git a/python/tests/test_cli_parity.py b/python/tests/test_cli_parity.py new file mode 100644 index 00000000..5a352859 --- /dev/null +++ b/python/tests/test_cli_parity.py @@ -0,0 +1,764 @@ +# 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, Sandbox + +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_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] + disk = "0" + """, + expect={"max_disk": 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"), + ("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'), + ("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.""" + return _live_policy(cli, policy_from_toml(text)) + + +def _live_policy(cli: str, policy) -> dict: + """Spawn a Sandbox and read its effective policy back over the control plane.""" + policy = dataclasses.replace(policy, 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_a_timestamp_the_cli_runs_can_also_be_applied_by_the_sdk(cli, tmp_path): + """The parity gap the epoch-seconds parameter used to open. + + `sandlock_sandbox_builder_time_start` took a `uint64` of seconds, so a + sub-second or pre-epoch stamp could not be handed to it at all: the core + kept the full instant and the CLI ran both, while this SDK loaded the same + profile and then refused to apply it. (Before that it was worse, and a + pre-epoch stamp wrapped through the unsigned parameter into year + 584942417355.) The setter takes the stamp itself now, with the epoch pair + as its numeric counterpart, so loading and applying no longer disagree. + """ + 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_from_toml(text)._ensure_native() + + +#: One policy, written the two ways a Sandbox can come to hold it: resolved by +#: the profile parser, or spelled out by a caller. `[limits].memory` resolves to +#: an integer count of bytes and `[determinism].time_start` to an epoch pair, +#: while a caller writes the size and the stamp the way the grammar spells them. +#: Both have to end up at the same sandbox, or a profile and the documentation +#: for these fields describe different things. +CONVERGENT = ( + """ + [filesystem] + read = {base_read} + [limits] + memory = "512M" + disk = "1G" + [determinism] + time_start = "2026-01-01T00:00:00Z" + """, + dict(max_memory="512M", max_disk="1G", time_start="2026-01-01T00:00:00Z"), +) + + +def test_the_two_ways_a_sandbox_is_configured_meet_at_the_same_policy(cli, tmp_path): + """The profile path and the literal path converge, in both directions. + + The profile hands the SDK numbers (536870912 bytes, and seconds plus + nanoseconds since the epoch); a caller hands it "512M" and an RFC 3339 + stamp. Neither is parsed here: the numbers go to the epoch and byte-count + doors of the ABI, the text goes to the grammar door, and the core resolves + both. What that has to be worth is one effective policy, which is what this + compares. + """ + text, literal = CONVERGENT + rendered = _render(text, tmp_path) + + from_profile = policy_from_toml(rendered) + # The resolved forms are what the profile path actually carries, and they + # are what makes this comparison non-trivial: if the SDK had parsed the + # strings itself, both sides would hold the same value already and the + # documents would match for the wrong reason. + assert from_profile.max_memory == 512 * 1024 * 1024 + assert from_profile.time_start == 1767225600 + assert literal["max_memory"] == "512M" + + by_hand = Sandbox(fs_readable=list(BASE_READ), **literal) + + assert _live_policy(cli, from_profile) == _live_policy(cli, by_hand) + + +def test_a_grant_is_forwarded_whether_or_not_its_path_is_there(cli, monkeypatch): + """`/lib64` used to be dropped from `fs_readable` when it was missing. + + One hardcoded path, filtered by the SDK on the caller's behalf. sandlock + refuses a readable path that does not exist, so the effect was to turn that + refusal into silence for exactly one path, and only for callers of this + SDK: the CLI, a profile and the Go SDK all still failed. Whether a missing + `/lib64` is a portability detail or a typo is the caller's to decide, and + the list they wrote is what goes to the core. + + The absence is simulated rather than waited for, because the filter only + ever fired on a host without `/lib64` (arm64, musl) and this one has it. + """ + import sandlock._sdk as sdk + + real_exists = sdk.os.path.exists + monkeypatch.setattr( + sdk.os.path, "exists", lambda p: False if p == "/lib64" else real_exists(p) + ) + + policy = _live_policy(cli, Sandbox(fs_readable=list(BASE_READ))) + assert policy["filesystem"]["read"] == list(BASE_READ) + + +def test_a_grant_on_a_path_that_is_not_there_reaches_the_core(cli): + """The verdict the filter above was hiding, on any host.""" + missing = "/sandlock-parity-no-such-directory" + assert not os.path.exists(missing) + result = Sandbox(fs_readable=[*BASE_READ, missing]).run(["/bin/true"]) + assert not result.success 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_handler_smoke.py b/python/tests/test_handler_smoke.py index d76f7f54..2990f6f4 100644 --- a/python/tests/test_handler_smoke.py +++ b/python/tests/test_handler_smoke.py @@ -253,8 +253,13 @@ def test_handler_ctx_is_frozen(): # Standard readable paths for a sandboxed python3 child, mirroring -# tests/test_sandbox.py's _PYTHON_READABLE helper. -_PYTHON_READABLE = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"] +# tests/test_sandbox.py's _PYTHON_READABLE helper, including its filter: the +# core refuses a readable path that is not there, and `/lib64` is not there on +# arm64 or on musl. +_PYTHON_READABLE = [ + p for p in ("/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev") + if os.path.isdir(p) +] # Use a system interpreter that lives inside the readable tree above. # sys.executable may point at a venv outside the sandbox (e.g. under diff --git a/python/tests/test_lifecycle.py b/python/tests/test_lifecycle.py index 423c0aa7..127edb5c 100644 --- a/python/tests/test_lifecycle.py +++ b/python/tests/test_lifecycle.py @@ -12,7 +12,13 @@ from sandlock._sdk import _lib -_BIN_READABLE = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc"] +# The SDK forwards every readable path to the core, which refuses one that +# does not exist, so the list filters rather than naming `/lib64` on a host +# (arm64, musl) that has none. +_BIN_READABLE = [ + p for p in ("/usr", "/lib", "/lib64", "/bin", "/etc", "/proc") + if os.path.isdir(p) +] def _policy(**overrides): 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_pipeline.py b/python/tests/test_pipeline.py index 018f4d89..17f08e77 100644 --- a/python/tests/test_pipeline.py +++ b/python/tests/test_pipeline.py @@ -18,13 +18,23 @@ _PYTHON_PREFIX = sys.prefix + +def _readable(*paths): + """Deduplicate readable paths and drop the ones this host does not have. + + The SDK forwards every readable path to the core, which refuses one that + does not exist, so a fixture naming `/lib64` on a host without it (arm64, + musl) would fail the build rather than the behaviour under test. + """ + return [p for p in dict.fromkeys(paths) if os.path.isdir(p)] + def _policy(**overrides): """Minimal policy for testing.""" defaults = { - "fs_readable": list(dict.fromkeys([ + "fs_readable": _readable( "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", _PYTHON_PREFIX, - ])), + ), "clean_env": True, } defaults.update(overrides) @@ -92,9 +102,9 @@ def test_disjoint_policies(self): f.write("sensitive data") # Stage 1: can read the file - reader_policy = _policy(fs_readable=[ + reader_policy = _policy(fs_readable=_readable( tmp, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", - ]) + )) # Stage 2: cannot read the file (no tmp in readable) processor_policy = _policy() @@ -190,10 +200,10 @@ def test_xoa_data_flow(self): # Executor: can read workspace, no network executor_policy = _policy( - fs_readable=list(dict.fromkeys([ + fs_readable=_readable( workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", _PYTHON_PREFIX, - ])), + ), net_allow=[], ) @@ -301,10 +311,10 @@ def test_gather_with_python_inputs(self): # the .so picked up by _find_lib() rather than assuming a # fixed layout. lib_dir = str(Path(_find_lib()).parent) - policy = _policy(fs_readable=list(dict.fromkeys([ + policy = _policy(fs_readable=_readable( "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", - lib_dir, _PYTHON_PREFIX, - ] + python_paths))) + lib_dir, _PYTHON_PREFIX, *python_paths, + )) result = ( policy.cmd( @@ -332,10 +342,10 @@ def test_gather_disjoint_policies(self): with open(secret, "w") as f: f.write("sensitive data") - data_policy = _policy(fs_readable=list(dict.fromkeys([ + data_policy = _policy(fs_readable=_readable( tmp, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", _PYTHON_PREFIX, - ]))) + )) code_policy = _policy() consumer_policy = _policy() diff --git a/python/tests/test_policy_fn.py b/python/tests/test_policy_fn.py index 6b53272b..16748c94 100644 --- a/python/tests/test_policy_fn.py +++ b/python/tests/test_policy_fn.py @@ -14,10 +14,16 @@ from sandlock import Sandbox, SyscallEvent, PolicyContext -_PYTHON_READABLE = list(dict.fromkeys([ - "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", - sys.prefix, -])) +# The SDK forwards every readable path to the core, which refuses one that +# does not exist, so the list filters rather than naming `/lib64` on a host +# (arm64, musl) that has none. +_PYTHON_READABLE = [ + p for p in dict.fromkeys([ + "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", + sys.prefix, + ]) + if os.path.isdir(p) +] def _policy(**overrides): @@ -210,7 +216,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 +224,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_popen.py b/python/tests/test_popen.py index 0283d87e..992f3e8a 100644 --- a/python/tests/test_popen.py +++ b/python/tests/test_popen.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import sys import threading import time @@ -16,9 +17,15 @@ from sandlock import Sandbox, StdioMode, Process -_READABLE = list(dict.fromkeys([ - "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", sys.prefix, -])) +# The SDK forwards every readable path to the core, which refuses one that +# does not exist, so the list filters rather than naming `/lib64` on a host +# (arm64, musl) that has none. +_READABLE = [ + p for p in dict.fromkeys([ + "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", sys.prefix, + ]) + if os.path.isdir(p) +] def _policy(**overrides): diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index 1994bb9b..19da3440 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -1,274 +1,529 @@ # 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, User -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_exit=BranchAction.COMMIT, 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 + # Two canonical keys, one field: the core models the identity as a + # pair, and so does this SDK, which is why there is no half-set state + # left for the profile mapping to answer for. + assert p.user == User(uid=1000, 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( + user=User(1000, 1000), + on_exit=BranchAction.COMMIT, + 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_stay_absent(self): + # A null leaf means the profile did not set the key, and it stays + # unset here. The cap it would have taken is the core's default, and + # 64 is not written down on this side any more: repeating it here is + # how a profile that says nothing could have come to mean something + # different from what the CLI makes of the same silence. + p = policy_from_toml("[limits]\ncpu = 50\n") + assert p.max_processes is None + 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)], + ) + 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_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('[limits]\nmemory = "0"\n') + assert "max_memory must be greater than 0" in str(excinfo.value) + + @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": [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(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): + """A remainder comes back as the core's own text, not as a double. + + ``seconds + nanos / 1e9`` cannot hold the pair the core resolved: a + double has about 238ns of spacing at 2026 epoch values. The stamp is + carried instead, so nothing is re-derived on this side. + """ + p = policy_from_toml( + '[determinism]\ntime_start = "2026-01-01T00:00:00.25Z"\n' + ) + assert p.time_start == "2026-01-01T00:00:00.25Z" + + def test_a_remainder_finer_than_a_double_survives(self): + """The case that changed what the profile meant, not just its precision. + + Through the float the remainder rounded up to a whole second, and the + core floors ``time_start`` to whole seconds, so this profile ran one + second later through the SDK than through the CLI. + """ + p = policy_from_toml( + '[determinism]\ntime_start = "2026-01-01T00:00:00.9999999Z"\n' + ) + assert p.time_start == "2026-01-01T00:00:00.9999999Z" + # The whole second the core reads out of it, which is the one the CLI + # reads out of the same file. + from sandlock._sdk import profile_parse + + canonical = profile_parse( + '[determinism]\ntime_start = "2026-01-01T00:00:00.9999999Z"\n' + ) + assert canonical["determinism"]["time_start"]["seconds"] == 1767225600 + + 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 == "1969-12-31T23:59:59.5Z" + + def test_a_whole_second_still_comes_back_as_a_number(self): + """The numeric door stays exercised by the case that is exact.""" + p = policy_from_toml('[determinism]\ntime_start = "1969-07-20T20:17:00Z"\n') + assert p.time_start == -14182980 + + 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_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 +533,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 +545,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 +574,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 +583,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,10 +602,10 @@ def test_list_profiles_no_dir(self, tmp_path, monkeypatch): class TestMergeCliOverrides: def test_scalar_override(self): - base = Sandbox(max_memory="256M", uid=0) + base = Sandbox(max_memory="256M", user=User(0, 0)) result = merge_cli_overrides(base, {"max_memory": "1G"}) assert result.max_memory == "1G" - assert result.uid == 0 # unchanged + assert result.user == User(0, 0) # unchanged def test_list_append(self): base = Sandbox(fs_readable=["/usr", "/lib"]) @@ -353,6 +617,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..34c95c71 --- /dev/null +++ b/python/tests/test_profile_abi_edge_cases.py @@ -0,0 +1,307 @@ +# 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 + # 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 + + 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_protection.py b/python/tests/test_protection.py index 4937d3b1..d69d13f3 100644 --- a/python/tests/test_protection.py +++ b/python/tests/test_protection.py @@ -117,14 +117,22 @@ def test_sandbox_build_with_idempotent_protection_kwargs(): def test_sandbox_build_rejects_out_of_range_protection_int(): - """An integer outside the known `Protection` enum range raises - `ValueError` at build time — before reaching the FFI.""" + """A discriminant this SDK does not know gets the core's verdict. + + It used to be caught here by a local range check whose own docstring + explained why: the Rust setter dropped an unrecognized value and the + build succeeded, so a caller was told nothing. The setter now latches it + and the check is the core's, which is where it can also serve the Go + caller and any other binding. + """ import pytest from sandlock._sdk import _NativePolicy sb = Sandbox(fs_readable=["/usr"], allow_degraded=[99]) - with pytest.raises(ValueError, match="allow_degraded"): + with pytest.raises( + RuntimeError, match="allow_degraded: unrecognized protection 99" + ): _NativePolicy.from_dataclass(sb) @@ -135,26 +143,29 @@ def test_sandbox_build_rejects_out_of_range_in_disable(): from sandlock._sdk import _NativePolicy sb = Sandbox(fs_readable=["/usr"], disable=[100, 200]) - with pytest.raises(ValueError, match="disable"): + with pytest.raises(RuntimeError, match="disable: unrecognized protection 100"): _NativePolicy.from_dataclass(sb) def test_sandbox_build_rejects_negative_protection_int(): - """Negative ints are not valid Protection discriminants — must - raise rather than wrap to a large unsigned value at the FFI.""" + """Negative ints must raise rather than wrap to a large unsigned value. + + This one stays on this side: ctypes converts to uint32 by masking, so -1 + would arrive as 4294967295 and the core would answer for a value the + caller never wrote. + """ import pytest from sandlock._sdk import _NativePolicy sb = Sandbox(fs_readable=["/usr"], allow_degraded=[-1]) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="uint32 parameter"): _NativePolicy.from_dataclass(sb) def test_sandbox_build_accepts_plain_int_in_valid_range(): """Callers using plain `int` (not the `Protection` IntEnum) for - values in the valid range must still succeed — the validator - coerces through `Protection(int)`.""" + values in the valid range must still succeed.""" from sandlock._sdk import _NativePolicy sb = Sandbox(fs_readable=["/usr"], allow_degraded=[4]) # 4 == SIGNAL_SCOPE diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index e78ea1b0..76b01c49 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -17,10 +17,18 @@ from sandlock import Sandbox, Change, DryRunResult -_PYTHON_READABLE = list(dict.fromkeys([ - "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", - sys.prefix, -])) +# The SDK forwards every readable path as written; nothing checks that one +# exists. A missing path fails late and anonymously, when the child installs +# the rule (`sandlock_create failed`, path not named), and under `chroot` the +# rule is skipped in silence instead. So the fixture filters rather than +# naming `/lib64` on a host (arm64, musl) that has none. +_PYTHON_READABLE = [ + p for p in dict.fromkeys([ + "/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev", + sys.prefix, + ]) + if os.path.isdir(p) +] def _policy(**overrides): """Minimal policy with standard readable paths.""" @@ -795,8 +803,10 @@ 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 takes epoch seconds or an RFC + # 3339 stamp, and the SDK reads neither grammar: an aware datetime + # converts to the former here, and the core resolves the latter. + t = datetime(2000, 6, 15, tzinfo=timezone.utc).timestamp() p = _policy(time_start=t) result = p.run(["date", "+%Y"]) assert result.success @@ -1073,3 +1083,86 @@ def test_fs_storage_with_quota(self, tmp_path): ["sh", "-c", f"echo x >> {workdir}/big.bin"] ) assert not result.success + + +class TestAnUnsetCapIsNeverSent: + """The other half of the zero-cap rule, observed from inside the guest. + + ``None`` must reach no setter at all, so the child sees the host's own + value rather than anything this SDK chose for it. Both knobs here are + readable from inside the sandbox, so the assertion is on what the child + actually got, not on the build succeeding. + """ + + def _read(self, script: str, **overrides) -> str: + result = _policy(**overrides).run(["sh", "-c", script]) + assert result.success, f"stderr={result.stderr!r}" + return result.stdout.decode().strip() + + def test_an_unset_num_cpus_leaves_the_host_count_visible(self): + assert self._read("nproc") == str(os.cpu_count()) + assert self._read("nproc", num_cpus=2) == "2" + + def test_an_unset_max_open_files_inherits_the_host_limit(self): + import resource as _resource + + soft, _hard = _resource.getrlimit(_resource.RLIMIT_NOFILE) + assert self._read("ulimit -n") == str(soft) + assert self._read("ulimit -n", max_open_files=32) == "32" + + +class TestConfine: + """``confine()`` applies Landlock to the calling process, irreversibly. + + Every case runs in a forked child, because there is no way back out. + """ + + @staticmethod + def _in_child(body: str) -> str: + """Run ``body`` in a fresh interpreter and return what it reported. + + A separate process rather than a fork: pytest is multi-threaded, and + forking one is its own hazard, quite apart from the confinement. + """ + import subprocess + + script = ( + "from sandlock import BranchAction, Sandbox, confine\n" + "try:\n" + f" {body}\n" + " print('OK')\n" + "except BaseException as exc:\n" + " print(f'{type(exc).__name__}: {exc}')\n" + ) + done = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, text=True, timeout=60, + ) + assert done.returncode == 0, f"child died: {done.stderr}" + return done.stdout.strip() + + def test_a_default_sandbox_confines(self): + """The policy in this package's README, verbatim. + + Nothing here says anything about the COW branch, and a confinement has + no branch to say anything about. It used to be refused all the same, + because the core demanded ``on_error == ABORT`` while ``build()`` + resolved an unset ``on_error`` to ``COMMIT``. + """ + assert self._in_child( + "confine(Sandbox(fs_readable=['/usr', '/lib'], fs_writable=['/tmp']))" + ) == "OK" + + @pytest.mark.parametrize("action", ["COMMIT", "ABORT", "KEEP"]) + def test_any_branch_action_still_confines(self, action): + assert self._in_child( + "confine(Sandbox(fs_readable=['/usr'], " + f"on_exit=BranchAction.{action}, on_error=BranchAction.{action}))" + ) == "OK" + + def test_a_field_confinement_cannot_honor_is_still_refused(self): + """The guard rail: the unsupported list itself must not have loosened.""" + out = self._in_child( + "confine(Sandbox(fs_readable=['/usr'], cwd='/tmp'))" + ) + assert out.startswith("ConfinementError"), out diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index f8f44eef..7410b27c 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -3,47 +3,125 @@ from __future__ import annotations +import re + import pytest +import sandlock.sandbox as sandbox_module +from sandlock._sdk import _branch_action, _epoch_split, _fits from sandlock.sandbox import ( + BranchAction, + Mount, Sandbox, - parse_memory_size, + User, parse_ports, ) -class TestParseMemorySize: - def test_plain_bytes(self): - assert parse_memory_size("1024") == 1024 - - def test_kilobytes(self): - assert parse_memory_size("100K") == 100 * 1024 - - def test_megabytes(self): - assert parse_memory_size("512M") == 512 * 1024 ** 2 - - def test_gigabytes(self): - assert parse_memory_size("1G") == 1024 ** 3 - - def test_terabytes(self): - assert parse_memory_size("2T") == 2 * 1024 ** 4 - - def test_case_insensitive(self): - assert parse_memory_size("512m") == 512 * 1024 ** 2 - - 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 TestNoSecondGrammar: + """The profile grammars live in the core parser, and only there. - def test_invalid(self): - with pytest.raises(ValueError): - parse_memory_size("not_a_size") + 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_empty(self): - with pytest.raises(ValueError): - parse_memory_size("") + def test_size_grammar_is_gone(self): + assert not hasattr(sandbox_module, "parse_memory_size") + assert not hasattr(Sandbox, "memory_bytes") + + def test_timestamp_grammar_is_gone(self): + assert not hasattr(Sandbox, "time_start_timestamp") + + @pytest.mark.parametrize("field", ["max_memory", "max_disk"]) + def test_a_size_string_is_carried_to_the_core_unchanged(self, field): + """The field holds what the caller wrote, and the core resolves it. + + Not merely "the constructor accepts it": the value has to survive + round-tripping through the dataclass, because an SDK that parsed it + would store the resolved number here instead. + """ + assert getattr(Sandbox(**{field: "512M"}), field) == "512M" + Sandbox(**{field: "512M"})._ensure_native() + + @pytest.mark.parametrize("field", ["max_memory", "max_disk"]) + @pytest.mark.parametrize( + "value,message", + [ + # The two spellings the SDK parsers invented between them. Both + # built a sandbox through this SDK while the same text in a + # profile was refused. + ("1.5G", "invalid byte size: 1.5G"), + ("1T", "unknown byte size suffix: T"), + ("", "empty byte size string"), + ], + ) + def test_a_size_the_core_refuses_reports_the_core_message( + self, field, value, message + ): + with pytest.raises(RuntimeError, match=re.escape(message)): + Sandbox(**{field: value})._ensure_native() + + def test_an_rfc3339_stamp_is_carried_to_the_core_unchanged(self): + sb = Sandbox(time_start="2026-01-01T00:00:00Z") + assert sb.time_start == "2026-01-01T00:00:00Z" + sb._ensure_native() + + @pytest.mark.parametrize( + "value", + [ + # The grammar's own edges, none of which the epoch-seconds + # parameter this setter used to take could carry. + "2026-01-01T00:00:00.5Z", + "2025-12-31T19:00:00-05:00", + "1969-07-20T20:17:00Z", + ], + ) + def test_the_core_grammar_is_reachable_in_full(self, value): + Sandbox(time_start=value)._ensure_native() + + @pytest.mark.parametrize( + "value,message", + [ + ("nope", 'invalid time_start "nope"'), + # A bare epoch count is what the old ABI parameter took and what + # the SDK parsers accepted. It is not the grammar. + ("1700000000", 'invalid time_start "1700000000"'), + ("2026-01-01T00:00:00", 'invalid time_start "2026-01-01T00:00:00"'), + ], + ) + def test_a_stamp_the_core_refuses_reports_the_core_message(self, value, message): + with pytest.raises(RuntimeError, match=re.escape(message)): + Sandbox(time_start=value)._ensure_native() + + def test_a_resolved_instant_reaches_the_core_without_being_re_rendered(self): + """A number is the form a loaded profile carries, and it is accepted. + + Both doors have to stay open: the string one for what a caller writes, + the numeric one for what ``sandlock_profile_parse`` hands back. A + pre-epoch instant and a sub-second remainder are the two the old + ``uint64`` parameter could not express at all. + """ + for value in (1767225600, 1767225600.5, -14182980, -0.5, 0): + Sandbox(time_start=value)._ensure_native() + + +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_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_non_mount_entries_are_refused(self): + with pytest.raises(TypeError, match="must be Mount"): + Sandbox(fs_mount=[("/work", "/host")]) class TestEnsureNative: @@ -79,38 +157,125 @@ def test_defaults(self): assert p.net_allow_bind == [] assert p.net_allow == [] assert p.max_memory is None - assert p.max_processes == 64 + # Not 64: the cap's default is the core's to state, and repeating it + # here is what let the two drift apart in the first place. + assert p.max_processes is None assert p.max_cpu is None 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" + p = Sandbox(max_memory=512 * 1024 ** 2) + p.max_memory = 1024 ** 3 + assert p.max_memory == 1024 ** 3 - def test_memory_bytes_string(self): - p = Sandbox(max_memory="512M") - assert p.memory_bytes() == 512 * 1024 ** 2 + def test_the_clamping_accessor_is_gone(self): + """``cpu_pct()`` answered a question the core answers by refusing. - def test_memory_bytes_int(self): - p = Sandbox(max_memory=1024) - assert p.memory_bytes() == 1024 + It squeezed the percentage into 1..100, so ``max_cpu=0`` read back as + 1 and ``max_cpu=200`` as 100: two policies the core rejects outright, + rewritten into two it accepts. + """ + assert not hasattr(Sandbox, "cpu_pct") - def test_memory_bytes_none(self): - p = Sandbox() - assert p.memory_bytes() is None + @pytest.mark.parametrize("value", [0, 101, 200]) + def test_a_percentage_outside_the_range_gets_the_core_verdict(self, value): + with pytest.raises(RuntimeError, match=f"max_cpu must be 1-100, got {value}"): + Sandbox(max_cpu=value)._ensure_native() - def test_cpu_pct(self): - p = Sandbox(max_cpu=50) - assert p.cpu_pct() == 50 - def test_cpu_pct_none(self): - p = Sandbox() - assert p.cpu_pct() is None +class TestZeroIsNotUnset: + """``None`` and ``0`` are two different policies, and the core says so. - def test_cpu_pct_clamped(self): - assert Sandbox(max_cpu=0).cpu_pct() == 1 - assert Sandbox(max_cpu=200).cpu_pct() == 100 + Every field here is ``None`` when unset, so a zero the caller wrote is + forwarded rather than filtered, and the core answers it by name. That is + the half this SDK cannot get wrong quietly: a filter here would turn + "refuse this cap" into "ignore this cap", which is what a + ``max_processes != 64`` guard and a clamping ``cpu_pct()`` used to do. + """ + + @pytest.mark.parametrize( + "field, message", + [ + ("max_processes", "max_processes must be greater than 0"), + ("num_cpus", "num_cpus must be greater than 0"), + ("max_open_files", "max_open_files must be greater than 0"), + ("max_cpu", "max_cpu must be 1-100, got 0"), + ], + ) + def test_a_zero_cap_gets_the_core_verdict(self, field, message): + with pytest.raises(RuntimeError, match=re.escape(message)): + Sandbox(**{field: 0})._ensure_native() + + @pytest.mark.parametrize("field", ["max_processes", "num_cpus", "max_open_files", "max_cpu"]) + def test_the_same_cap_unset_is_not_refused(self, field): + # The other half of the pair: `None` must reach no setter at all, so + # the core never sees the field and its own default stands. + assert getattr(Sandbox(), field) is None + Sandbox()._ensure_native() + + @pytest.mark.parametrize("value", [0, "0"]) + def test_a_zero_memory_ceiling_gets_the_core_verdict(self, value): + # Zero is what the supervisor carries internally for "no ceiling", + # but the memory handler is installed on the field being set at all, + # so an explicit zero used to install a handler enforcing a ceiling + # of zero: the guest was SIGKILLed on the loader's first anonymous + # mmap, with no exit status and nothing naming the setting. + with pytest.raises(RuntimeError, match="max_memory must be greater than 0"): + Sandbox(max_memory=value)._ensure_native() + + def test_a_zero_disk_quota_is_left_alone(self): + # max_disk is deliberately not the same knob: zero is its documented + # spelling of "unlimited". An SDK that generalised the memory rule + # would take a working policy away. + Sandbox(max_disk=0)._ensure_native() + + def test_an_empty_core_set_gets_the_core_verdict(self): + # An empty list asks for an affinity mask with no bits, which the + # kernel refuses; the child setup used to skip the call for it, so + # the pinning the caller asked for silently did not happen. + with pytest.raises(RuntimeError, match="cpu_cores must name at least one core"): + Sandbox(cpu_cores=[])._ensure_native() + + def test_an_empty_device_list_is_left_alone(self): + # gpu_devices shares the shape and not the rule: an empty list there + # is the spelling of "every GPU present". + Sandbox(gpu_devices=[])._ensure_native() + + def test_a_zero_seed_is_a_seed(self): + # random_seed has no reserved value, so zero is an ordinary seed and + # must travel: `if policy.random_seed:` would have dropped it. + Sandbox(random_seed=0)._ensure_native() + + +class TestOnlyAUidIsNotExpressible: + """The half-set identity is gone from the type, not from a check. + + ``User`` carries both ids because the core's ``Option`` does: an + unprivileged user namespace maps exactly one pair. There is no + ``uid``-without-``gid`` value for this SDK to have an opinion about, so + the "both or neither" check it used to make has nothing left to check. + """ + + def test_the_pair_cannot_be_split(self): + with pytest.raises(TypeError): + User(uid=1000) # type: ignore[call-arg] + with pytest.raises(TypeError): + User(gid=1000) # type: ignore[call-arg] + + def test_the_sandbox_carries_one_identity_field(self): + import dataclasses + + names = {f.name for f in dataclasses.fields(Sandbox)} + assert "user" in names + assert not names & {"uid", "gid"}, ( + "separate uid/gid fields put the half-set state back" + ) + + def test_a_zero_id_is_fake_root_and_not_unset(self): + # uid 0 is a policy ("map me to fake root"), so it must not be + # filtered the way a zero cap once was. + assert Sandbox(user=User(uid=0, gid=0)).user == User(uid=0, gid=0) + Sandbox(user=User(uid=0, gid=0))._ensure_native() class TestDiskQuotaPolicy: @@ -118,20 +283,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: @@ -148,11 +308,24 @@ def test_mixed(self): assert parse_ports([80, "443", "8000-8002"]) == [80, 443, 8000, 8001, 8002] def test_comma_in_string(self): - # A string element may hold a comma list / ranges, matching the CLI's - # --net-allow-bind grammar. + # A string element may hold a comma list / ranges. assert parse_ports(["8080,9090"]) == [8080, 9090] assert parse_ports(["8080,9000-9002", 443]) == [443, 8080, 9000, 9001, 9002] + @pytest.mark.parametrize("spec", ["*", "80 - 90"]) + def test_it_is_not_the_bind_grammar_and_must_not_be_used_as_one(self, spec): + """Both of these are accepted by the core and refused by this helper. + + ``*`` is the any-port wildcard and ``80 - 90`` is a range the core + trims around. A caller who pre-expands a bind list through here loses + the wildcard entirely and gets a Python-side ValueError for a spec the + CLI takes. The pass-through path has to keep taking both, which is + what makes the pre-expansion unnecessary in the first place. + """ + with pytest.raises(ValueError): + parse_ports([spec]) + Sandbox(fs_readable=["/usr"], net_allow_bind=[spec])._ensure_native() + def test_comma_empty_part_rejected(self): with pytest.raises(ValueError): parse_ports(["8080,"]) @@ -256,3 +429,200 @@ 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. + + The setters take fixed-width integers and ctypes converts by masking, so + a value that does not fit arrives as a different, perfectly plausible one: + ``max_cpu=300`` becomes 44, a throttle the core has no reason to question. + Refusing here is what keeps the core's verdict reachable, which is why + these are the one class of check this SDK still makes for itself. + """ + + @pytest.mark.parametrize( + "field,value,setter", + [ + ("max_cpu", 300, "uint8"), + ("max_cpu", -1, "uint8"), + ("http_ports", [70000], "uint16"), + ("http_ports", [-1], "uint16"), + ("num_cpus", 2 ** 32, "uint32"), + ("max_open_files", -1, "uint32"), + ("random_seed", 2 ** 64, "uint64"), + ], + ) + def test_a_value_the_setter_cannot_carry_is_refused_not_masked( + self, field, value, setter + ): + with pytest.raises(ValueError, match=f"{setter} parameter"): + Sandbox(**{field: value})._ensure_native() + + def test_a_wrapped_uid_is_refused(self): + """``User(-1, -1)`` used to arrive as uid 4294967295.""" + with pytest.raises(ValueError, match="uint32 parameter"): + Sandbox(user=User(-1, 0))._ensure_native() + + def test_a_bool_is_not_an_integer_here(self): + # bool is an int subclass, so True would otherwise pass as 1. + with pytest.raises(TypeError, match="must be an integer"): + _fits(True, "num_cpus", bits=32) + + @pytest.mark.parametrize( + "value,expected", + [ + (0, (0, 0)), + (1767225600, (1767225600, 0)), + (1767225600.5, (1767225600, 500_000_000)), + # Floors rather than truncating towards zero, the way the + # canonical profile form splits: the remainder is never negative. + (-0.5, (-1, 500_000_000)), + (-14182980, (-14182980, 0)), + ], + ) + def test_epoch_seconds_split_the_way_the_canonical_form_splits( + self, value, expected + ): + assert _epoch_split(value) == expected + + def test_a_remainder_that_rounds_up_to_a_whole_second_carries(self): + # Without the carry this would emit nanoseconds == 1e9, which the core + # refuses as unnormalized. + assert _epoch_split(0.9999999999) == (1, 0) + + def test_time_start_must_be_a_stamp_or_a_number(self): + with pytest.raises(TypeError, match="RFC 3339 string or epoch seconds"): + Sandbox(time_start=object())._ensure_native() + + @pytest.mark.parametrize( + "kwargs", + [ + {"max_cpu": 300}, # _fits + {"on_exit": "keepp"}, # _branch_action + {"time_start": object()}, # _b_time_start_from + ], + ) + def test_a_refused_policy_does_not_leak_its_builder(self, monkeypatch, kwargs): + """Refusing a value must not abandon the native builder. + + Every setter consumes the pointer it is given and hands back a new + one, and `sandlock_sandbox_build` is the only entry point in the C ABI + that consumes a builder without producing another. So a raise between + `sandlock_sandbox_builder_new` and the build strands one, with all the + paths, env and rule strings already loaded into it. A long-lived + process that validates policies it did not write leaks one per + rejection. + + Counted rather than measured: the two calls have to balance, which is + exact, where a resident-set reading is a threshold. + """ + from sandlock import _sdk + + counts = {"new": 0, "build": 0} + real_new = _sdk._lib.sandlock_sandbox_builder_new + real_build = _sdk._lib.sandlock_sandbox_build + + def counting_new(*a): + counts["new"] += 1 + return real_new(*a) + + def counting_build(*a): + counts["build"] += 1 + return real_build(*a) + + monkeypatch.setattr(_sdk._lib, "sandlock_sandbox_builder_new", counting_new) + monkeypatch.setattr(_sdk._lib, "sandlock_sandbox_build", counting_build) + + # A policy big enough that the leak would be worth noticing, and whose + # setters have all run by the time the bad value is reached. + policy = Sandbox( + fs_readable=["/usr", "/lib", "/etc"], + env={"A": "B", "C": "D"}, + **kwargs, + ) + with pytest.raises((ValueError, TypeError, RuntimeError)): + policy._ensure_native() + + assert counts["new"] == 1, "the test did not reach the builder" + assert counts["build"] == counts["new"], ( + "the builder was allocated and never consumed" + ) + + +class TestBranchAction: + """An action the SDK does not know is the core's to refuse, by value.""" + + def test_the_discriminants_are_the_abi_ones(self): + assert [a.abi for a in BranchAction] == [0, 1, 2] + assert _branch_action(BranchAction.KEEP, "on_exit") == 2 + + @pytest.mark.parametrize("field,default_it_used_to_take", [("on_exit", 0), ("on_error", 1)]) + def test_an_unknown_action_is_not_quietly_replaced_by_a_default( + self, field, default_it_used_to_take + ): + """``.get(value, 0)`` turned a typo into a decision about the writes. + + ``on_exit`` fell back to Commit and ``on_error`` to Abort, so a + misspelled action silently merged or discarded a COW branch. The + number now goes through and the core names it. + """ + with pytest.raises(RuntimeError, match=f"{field}: unrecognized branch action 7"): + Sandbox(**{field: 7})._ensure_native() + assert default_it_used_to_take in (0, 1) + + def test_neither_default_is_written_down_on_this_side(self): + """The SDK's own ``on_error`` default disagreed with the core's. + + ``BranchAction.ABORT`` here against Commit there, for the same field, + so a policy that said nothing about the error path discarded the + guest's writes through this SDK and kept them through the CLI, a + profile and the Go SDK. Unset now means unset, and the setter is not + called at all. What the core then does with it is compared against a + profile, on a live sandbox, in ``test_cli_parity.py``. + """ + assert Sandbox().on_exit is None + assert Sandbox().on_error is None + + def test_the_three_spellings_still_work(self): + for action in BranchAction: + assert _branch_action(action.value, "on_exit") == action.abi + + @pytest.mark.parametrize("field", ["on_exit", "on_error"]) + def test_the_reference_table_agrees_with_the_dataclass(self, field): + """docs/sandbox-reference.md is the table a reader consults. + + Its ``on_error`` row was rewritten when the default moved into the + core; the ``on_exit`` row directly above it was not, so the same + document said ``None`` in its synopsis and ``BranchAction.COMMIT`` in + its table for the same field. + """ + import dataclasses + import pathlib + + doc = pathlib.Path(__file__).resolve().parents[2] / "docs" / "sandbox-reference.md" + rows = [ + line for line in doc.read_text().splitlines() + if line.startswith(f"| `{field}`") + ] + assert len(rows) == 1, f"expected one {field} row, got {rows}" + # `\|` inside a cell is an escaped pipe, not a column separator. + cells = [c.strip() for c in re.split(r"(? str: + return f"{prefix}-{os.getpid()}-{next(_names)}" + + +# ============================================================ +# The four surfaces +# ============================================================ + +FLAG = "flag" +PROFILE = "profile" +SETTER = "setter" +PYSDK = "python" +SURFACES = (FLAG, PROFILE, SETTER, PYSDK) + +#: What each surface wraps around the core's diagnosis. Asserted literally +#: before it is peeled: an envelope that changed is a surface that started +#: reporting something other than the core's verdict. +ENVELOPE = { + # anyhow's report header, with the core error as the whole of it. + FLAG: "Error: invalid sandbox: ", + # The profile loader returns SandlockError, which names the layer. + PROFILE: "Error: sandbox error: invalid sandbox: ", + # `sandlock_sandbox_build` hands back the SandboxError it built with. + SETTER: "invalid sandbox: ", + # The SDK raises that same string as a RuntimeError and adds nothing. + PYSDK: "invalid sandbox: ", +} + + +@dataclass(frozen=True) +class Knob: + """One grammar, and how each surface spells the knob that carries it.""" + + id: str + flag: str + setter: str + python: str + #: Where the value lands in a profile, and in the document a profile + #: resolves to. The same pair addresses both, because the canonical + #: document keeps the shape of the file it came from. + doc: tuple[str, str] + #: The knob name each surface writes into a rejection message, when the + #: grammar's diagnosis names one at all. `None` means the core's message + #: is knob-free, and the surfaces must then agree word for word. + labels: dict[str, str] | None = None + + @property + def section(self) -> str: + return f"[{self.doc[0]}]" + + @property + def key(self) -> str: + return self.doc[1] + + +KNOBS = { + name: Knob( + id=name, + flag=spec["flag"], + setter=spec["setter"], + python=spec["python"], + doc=tuple(spec["profile"]), + labels=spec.get("labels"), + ) + for name, spec in CORPUS["knobs"].items() +} + + +# ============================================================ +# Corpus +# ============================================================ + + +@dataclass(frozen=True) +class Accepted: + """A value all four surfaces must take, and what it has to mean.""" + + knob: str + value: str + #: The exact resolution, as the canonical profile document reports it: + #: an integer count of bytes, or `{"seconds", "nanoseconds"}` since the + #: epoch. This is what pins the meaning of the grammar rather than only + #: pinning that the surfaces agree on something. + resolved: object + #: How a live sandbox spells the value back over `sandlock inspect`, or + #: `None` for a value never compared on a live document (see `live`). + renders: str | None + #: False for a policy whose guest cannot reach the point of being + #: inspected. Such a value is still compared on all four surfaces, one + #: step earlier: every surface has to accept it. + live: bool = True + + @property + def id(self) -> str: + return f"{self.knob}-{_slug(self.value)}" + + +@dataclass(frozen=True) +class Rejected: + """A value no surface may take, and the sentence it has to be refused with. + + `diagnosis` is a prefix of the core's message, with the knob label folded + to ``. It is a prefix because the timestamp parser continues its + sentence with wording that belongs to `jiff`, which this repository does + not own; the surfaces are separately required to produce one identical + full sentence, so the prefix pins the verdict and the comparison pins + that nobody reworded the rest of it. + """ + + knob: str + value: str + diagnosis: str + #: True for a value one of the deleted SDK parsers used to accept. + sdk_only: bool = False + + @property + def id(self) -> str: + return f"{self.knob}-{_slug(self.value)}" + + +def _slug(value: str) -> str: + """A pytest id that `-k` can select without quoting. + + Spaces become underscores rather than disappearing: whitespace is part of + what is being tested here, and two entries that differ only in it must not + collapse into one id. + """ + return value.replace(" ", "_") or "empty" + + +ACCEPTED = [ + Accepted( + knob=entry["knob"], + value=entry["value"], + resolved=entry["resolved"], + renders=entry["renders"], + live=entry.get("live", True), + ) + for entry in CORPUS["accepted"] +] + +REJECTED = [ + Rejected( + knob=entry["knob"], + value=entry["value"], + diagnosis=entry["diagnosis"], + sdk_only=entry.get("sdk_only", False), + ) + for entry in CORPUS["rejected"] +] + +#: The corpus lets an entry declare a surface that cannot carry it, for the one +#: reason a surface is allowed to have: the host language has no way to spell +#: the value. Go says "unset" with an empty string, so an empty `MaxMemory` +#: never reaches the core. None of the four surfaces driven here has such a +#: limit, and this is where that stops being an assumption: an entry that +#: excused one of them would otherwise be skipped in silence, which is exactly +#: how a surface stops being tested. +_EXCUSED = { + surface + for entry in CORPUS["accepted"] + CORPUS["rejected"] + for surface in entry.get("unreachable", {}) +} + + +def test_no_surface_driven_here_claims_it_cannot_carry_a_value(): + assert _EXCUSED.isdisjoint(SURFACES), ( + f"{CORPUS_PATH.name} excuses a surface this file drives: " + f"{sorted(_EXCUSED & set(SURFACES))}" + ) + + +# ============================================================ +# Building the artifacts under test +# ============================================================ + + +_BUILT = False + + +def _cargo_build() -> None: + """Bring both artifacts up to date with the tree, once per session. + + Unconditional, rather than only when an artifact is missing. A tree + carrying the artifacts of an earlier commit is the case this file cannot + detect and must not compare against: a setter keeps its symbol name when + its signature changes, so a stale library takes the `char *` this file + passes and reads it as the integer it used to take. That compares the + corpus against an ABI nobody is shipping, and says nothing about why. + Cargo is a no-op on an already-built tree, so the cost of being sure is a + fraction of a second. + """ + global _BUILT + if _BUILT: + return + subprocess.run( + ["cargo", "build", "-p", "sandlock-cli", "-p", "sandlock-ffi"], + cwd=REPO_ROOT, + check=True, + # Both artifacts have to land in /target, where this file looks + # for them, and not in a redirected target directory. + env={k: v for k, v in os.environ.items() if k != "CARGO_TARGET_DIR"}, + ) + _BUILT = True + + +def _artifact(name: str, env_var: str) -> str: + """Locate a build artifact, rebuilding the tree once to be sure of it. + + `env_var` (`SANDLOCK_CLI`, `SANDLOCK_LIB`) short-circuits the search, for + packaging and for running this file against an installed build. Both + artifacts must come from the same tree: pointing one of them somewhere else + compares two different implementations, which is only useful on purpose. + """ + override = os.environ.get(env_var) + if override: + return override + _cargo_build() + candidates = [ + REPO_ROOT / "target" / profile / name for profile in ("debug", "release") + ] + found = [c for c in candidates if c.is_file()] + if not found: + raise AssertionError(f"{name} was not built into {REPO_ROOT / 'target'}") + return str(max(found, key=lambda c: c.stat().st_mtime)) + + +@pytest.fixture(scope="session") +def cli() -> str: + return _artifact("sandlock", "SANDLOCK_CLI") + + +@pytest.fixture(scope="session") +def abi() -> "_Abi": + return _Abi(_artifact("libsandlock_ffi.so", "SANDLOCK_LIB")) + + +class _Abi: + """The exports this file drives, prototyped by hand. + + Everything the ABI hands back as an owned string is copied out and released + here, so a leak in the test cannot mask one in the implementation. + """ + + def __init__(self, path: str) -> None: + lib = ctypes.CDLL(path) + ptr = ctypes.c_void_p + lib.sandlock_sandbox_builder_new.restype = ptr + lib.sandlock_sandbox_builder_new.argtypes = [] + setters = sorted({k.setter for k in KNOBS.values()}) + for name in ["sandlock_sandbox_builder_fs_read", *setters]: + fn = getattr(lib, name) + fn.restype = ptr + # Every one of these takes a string. A setter that still took a + # number would read the pointer as its value here. + fn.argtypes = [ptr, ctypes.c_char_p] + lib.sandlock_sandbox_build.restype = ptr + lib.sandlock_sandbox_build.argtypes = [ + ptr, + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_char_p), + ] + lib.sandlock_sandbox_free.restype = None + lib.sandlock_sandbox_free.argtypes = [ptr] + lib.sandlock_profile_parse.restype = ptr + lib.sandlock_profile_parse.argtypes = [ + ctypes.c_char_p, + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_char_p), + ] + lib.sandlock_create.restype = ptr + lib.sandlock_create.argtypes = [ + ptr, + ctypes.c_char_p, + ctypes.POINTER(ctypes.c_char_p), + ctypes.c_uint, + ] + lib.sandlock_start.restype = ctypes.c_int + lib.sandlock_start.argtypes = [ptr] + lib.sandlock_handle_kill.restype = ctypes.c_int + lib.sandlock_handle_kill.argtypes = [ptr] + lib.sandlock_handle_free.restype = None + lib.sandlock_handle_free.argtypes = [ptr] + lib.sandlock_string_free.restype = None + lib.sandlock_string_free.argtypes = [ctypes.c_char_p] + self.lib = lib + + def _take(self, owned: ctypes.c_char_p) -> str | None: + # `is None` rather than a truth test: an empty string still owns an + # allocation, and treating it as "nothing was written" would leak it + # and report a message that was set as a message that was not. + if owned.value is None: + return None + text = owned.value.decode() + self.lib.sandlock_string_free(owned) + return text + + def build(self, knob: Knob, value: str, grants: bool): + """Configure one knob through its setter and build. + + Returns `(sandbox, err, message)`. The sandbox is owned by the caller + and has to be released with `free_sandbox`. + """ + b = self.lib.sandlock_sandbox_builder_new() + assert b, "sandlock_sandbox_builder_new returned null" + if grants: + for path in GRANTS: + b = self.lib.sandlock_sandbox_builder_fs_read(b, path.encode()) + b = getattr(self.lib, knob.setter)(b, value.encode()) + err = ctypes.c_int(7) # poison: build has to overwrite this + msg = ctypes.c_char_p() + sandbox = self.lib.sandlock_sandbox_build( + b, ctypes.byref(err), ctypes.byref(msg) + ) + return sandbox, err.value, self._take(msg) + + def free_sandbox(self, sandbox) -> None: + self.lib.sandlock_sandbox_free(sandbox) + + def profile_parse(self, toml: str): + """Resolve a profile the way `sandlock_profile_parse` callers do. + + Returns `(document, err, message)`. This export runs the same core + routine the CLI runs for `--profile-file`, which the core asserts in + `profile::canonical`'s parse_error_text_matches_what_the_cli_prints. + It is used here because it reports resolved values exactly, which the + effective policy document does not. + """ + err = ctypes.c_int(7) + msg = ctypes.c_char_p() + raw = self.lib.sandlock_profile_parse( + toml.encode(), ctypes.byref(err), ctypes.byref(msg) + ) + document = None + if raw: + document = json.loads(ctypes.string_at(raw).decode()) + self.lib.sandlock_string_free(ctypes.cast(raw, ctypes.c_char_p)) + return document, err.value, self._take(msg) + + def spawn(self, sandbox, name: str, cmd: list[str]): + argv = (ctypes.c_char_p * len(cmd))(*[a.encode() for a in cmd]) + handle = self.lib.sandlock_create(sandbox, name.encode(), argv, len(cmd)) + assert handle, "sandlock_create returned null" + assert self.lib.sandlock_start(handle) == 0, "sandlock_start failed" + return handle + + def stop(self, handle) -> None: + self.lib.sandlock_handle_kill(handle) + self.lib.sandlock_handle_free(handle) + + +# ============================================================ +# Driving the four surfaces +# ============================================================ + + +def _profile_text(knob: Knob, value: str, grants: bool = True) -> str: + """The profile that carries `value`, and nothing else that could fail.""" + head = "" + if grants: + reads = ", ".join(json.dumps(d) for d in GRANTS) + head = f"[filesystem]\nread = [{reads}]\n\n" + # TOML basic strings and JSON strings quote these values identically. + return f"{head}{knob.section}\n{knob.key} = {json.dumps(value)}\n" + + +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(cli: str, name: str, proc: subprocess.Popen) -> str: + """Shut a sandbox down and return what it reported. + + `sandlock kill` first, and the signal only as a fallback: a supervisor that + is asked to stop clears its control directory, while a supervisor that is + signalled leaves the directory behind. `sandlock ps` filters those out, but + a test that launches a few dozen sandboxes would leave a few dozen of them + in /dev/shm. + + The process is then reaped either way, so a guest cannot outlive the test + as an orphan. + """ + subprocess.run([cli, "kill", name], capture_output=True, text=True) + proc.terminate() + try: + _, err = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: # pragma: no cover - defensive + proc.kill() + _, err = proc.communicate(timeout=10) + return (err or "").strip() + + +def _policy_from_cli(cli: str, argv: list[str], name: str) -> dict: + proc = subprocess.Popen( + [*argv, "--name", name, "--", SLEEP, "30"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + policy = _inspect(cli, name) + except AssertionError as exc: + reported = _stop(cli, name, proc) + raise AssertionError(f"{exc}\nsandlock run said: {reported}") from exc + _stop(cli, name, proc) + return policy + + +def _flag_arg(knob: Knob, value: str) -> str: + """`--knob=value`, as one argument. + + The attached form, not two arguments: a value that starts with `-` reads as + another flag when it stands on its own, and getting turned away by the + argument parser is not the grammar's verdict on it. + """ + return f"{knob.flag}={value}" + + +def _sdk_sandbox(knob: Knob, value: str, **extra) -> sandlock.Sandbox: + """A sandbox configured through the SDK's public field, and nothing else. + + The value is handed over as the string the corpus holds. Whatever the SDK + does with it on the way to the setter is the thing under test, so this + helper must not touch it. + """ + return sandlock.Sandbox(**{knob.python: value}, **extra) + + +def _policy_from_sdk(cli: str, sb: sandlock.Sandbox, name: str) -> dict: + """Launch an SDK-configured sandbox and read its effective policy back.""" + with sb: + sb.spawn([SLEEP, "30"]) + try: + return _inspect(cli, name) + finally: + # `sb.kill()`, never `sandlock kill `: for a CLI-launched + # sandbox the supervisor is the `sandlock run` process, but for an + # SDK-launched one it is this interpreter, so asking the control + # plane to stop the sandbox by name takes the test runner down with + # it. This is the same signal the `setter` surface sends through + # `sandlock_handle_kill`, which is `Sandbox::kill` underneath. + try: + sb.kill() + except RuntimeError: + pass + # Leaving the block frees the handle. + + +def effective_policy(surface, cli, abi, knob: Knob, value: str, tmp_path: Path) -> dict: + """Configure one knob through one surface and read the policy back.""" + if surface == FLAG: + argv = [cli, "run", _flag_arg(knob, value)] + for path in GRANTS: + argv += ["--fs-read", path] + return _policy_from_cli(cli, argv, _unique("parity-flag")) + + if surface == PROFILE: + path = tmp_path / f"{knob.id}.toml" + path.write_text(_profile_text(knob, value), encoding="utf-8") + argv = [cli, "run", "--profile-file", str(path)] + return _policy_from_cli(cli, argv, _unique("parity-profile")) + + if surface == PYSDK: + name = _unique("parity-pysdk") + sb = _sdk_sandbox(knob, value, fs_readable=GRANTS, name=name) + return _policy_from_sdk(cli, sb, name) + + sandbox, err, msg = abi.build(knob, value, grants=True) + assert err == 0 and sandbox, f"the setter refused {value!r}: {msg}" + name = _unique("parity-setter") + handle = abi.spawn(sandbox, name, [SLEEP, "30"]) + try: + return _inspect(cli, name) + finally: + abi.stop(handle) + abi.free_sandbox(sandbox) + + +def rejection(surface, cli, abi, knob: Knob, value: str, tmp_path: Path) -> str | None: + """Offer one surface a value and return its refusal, or None if it took it. + + A rejected value stops each surface before anything is forked, so what + comes back is the parse diagnosis. Anything that goes wrong later is a + different report, which is what tells acceptance from refusal here. + """ + if surface == SETTER: + sandbox, err, msg = abi.build(knob, value, grants=False) + if sandbox: + abi.free_sandbox(sandbox) + return None + assert err == -1, f"a refused build must report -1, got {err}" + return msg + + if surface == PYSDK: + # No grants, so a value the SDK accepts produces a guest that cannot + # exec and a `Result`, never an exception. The same shape as the two + # command-line surfaces below, which also run the whole command and + # tell a refusal from a failure by what came back. + try: + with _sdk_sandbox(knob, value) as sb: + sb.run(["/bin/true"]) + except RuntimeError as exc: + return str(exc) + return None + + if surface == FLAG: + argv = [cli, "run", _flag_arg(knob, value)] + else: + path = tmp_path / f"{knob.id}-reject.toml" + path.write_text(_profile_text(knob, value, grants=False), encoding="utf-8") + argv = [cli, "run", "--profile-file", str(path)] + done = subprocess.run( + [*argv, "--", "/bin/true"], capture_output=True, text=True + ) + head = done.stderr.split("\n\nCaused by:")[0].strip() + return head if head.startswith("Error: ") else None + + +# ============================================================ +# Tests +# ============================================================ + + +LIVE = [c for c in ACCEPTED if c.live] +NOT_LIVE = [c for c in ACCEPTED if not c.live] +SDK_ONLY = [c for c in REJECTED if c.sdk_only] +PLAIN_REJECTED = [c for c in REJECTED if not c.sdk_only] + + +@pytest.mark.parametrize("case", LIVE, ids=[c.id for c in LIVE]) +def test_an_accepted_value_means_the_same_on_all_four_surfaces(cli, abi, tmp_path, case): + """The same text has to produce the same live policy from any surface.""" + knob = KNOBS[case.knob] + policies = { + surface: effective_policy(surface, cli, abi, knob, case.value, tmp_path) + for surface in SURFACES + } + + section, key = knob.doc + for surface, policy in policies.items(): + got = policy.get(section, {}).get(key) + # Without this the four could agree by all dropping the value, which + # is what a setter that ignored its argument would look like. + assert got == case.renders, ( + f"{surface} resolved {case.value!r} to {got!r}, expected {case.renders!r}" + ) + + distinct = [policies[s] for s in SURFACES] + assert all(p == distinct[0] for p in distinct), policies + + +@pytest.mark.parametrize("case", NOT_LIVE, ids=[c.id for c in NOT_LIVE]) +def test_a_value_whose_guest_cannot_run_is_still_taken_by_all_four( + cli, abi, tmp_path, case +): + """Acceptance is the half of the claim a dead guest can still carry. + + The document comparison above needs a sandbox that stays up long enough to + answer `sandlock inspect`, which a policy like a zero memory limit never + does. What is left to compare is the verdict: no surface may refuse a value + the others take. What the value resolves to is pinned by the canonical + profile below, which needs no guest at all. + """ + knob = KNOBS[case.knob] + for surface in SURFACES: + refusal = rejection(surface, cli, abi, knob, case.value, tmp_path) + assert refusal is None, f"{surface} refused {case.value!r}: {refusal}" + + +@pytest.mark.parametrize("case", ACCEPTED, ids=[c.id for c in ACCEPTED]) +def test_an_accepted_value_resolves_to_the_pinned_quantity(abi, case): + """Pin what the grammar means, not only that the surfaces agree on it. + + The canonical profile document reports the resolution exactly: a count of + bytes, or seconds and nanoseconds since the epoch. It is the only view that + survives sub-second precision and a pre-epoch instant, both of which the + effective policy document above rounds off or drops. + """ + knob = KNOBS[case.knob] + toml = _profile_text(knob, case.value, grants=False) + document, err, msg = abi.profile_parse(toml) + assert err == 0, f"the profile refused {case.value!r}: {msg}" + section, key = knob.doc + assert document[section][key] == case.resolved + + +@pytest.mark.parametrize( + "case", PLAIN_REJECTED, ids=[c.id for c in PLAIN_REJECTED] +) +def test_a_rejected_value_is_refused_everywhere_with_one_diagnosis( + cli, abi, tmp_path, case +): + """One parser, so one verdict and one sentence to explain it.""" + _assert_one_diagnosis(cli, abi, tmp_path, case) + + +@pytest.mark.parametrize("case", SDK_ONLY, ids=[c.id for c in SDK_ONLY]) +def test_a_value_only_an_sdk_parser_ever_accepted_is_refused_everywhere( + cli, abi, tmp_path, case +): + """The regression this change exists to close. + + Each of these reached a live policy through a binding while the identical + text in a profile or on the command line was refused, because the binding + parsed it itself. Now the string goes to the core, so all four surfaces + give the same answer, and the answer is no. The sentence is pinned as well + as shared, so a refusal for some unrelated reason (a null argument, a + builder that latched something earlier) cannot stand in for the grammar's + verdict. + """ + _assert_one_diagnosis(cli, abi, tmp_path, case) + + +def _assert_one_diagnosis(cli, abi, tmp_path, case: Rejected) -> None: + """Refuse `case` on all four surfaces and check they said the same thing.""" + knob = KNOBS[case.knob] + raw = { + surface: rejection(surface, cli, abi, knob, case.value, tmp_path) + for surface in SURFACES + } + for surface, text in raw.items(): + assert text is not None, f"{surface} accepted {case.value!r}" + assert text.startswith(ENVELOPE[surface]), ( + f"{surface} reported something other than the core's verdict: {text!r}" + ) + + diagnosis = {s: text[len(ENVELOPE[s]) :] for s, text in raw.items()} + if knob.labels: + # The knob name is the one part a surface supplies: the core is told + # which knob carried the value so it can name it. Everything else in + # the sentence has to match, so the name is checked and then removed. + for surface, text in diagnosis.items(): + assert knob.labels[surface] in text, ( + f"{surface} must name its own knob in {text!r}" + ) + diagnosis = { + s: text.replace(knob.labels[s], "", 1) + for s, text in diagnosis.items() + } + + assert len(set(diagnosis.values())) == 1, diagnosis + shared = diagnosis[SETTER] + assert shared.startswith(case.diagnosis), ( + f"expected the grammar's verdict {case.diagnosis!r}, got {shared!r}" + ) + + +def test_an_epoch_number_is_the_resolved_instant_and_not_a_second_grammar(cli): + """`time_start` takes text or a number, and they are not two spellings. + + `"1700000000"` is refused above on every surface, because a bare count of + seconds is not an RFC 3339 stamp. The same quantity as a Python number is + accepted, and this is why that is not the grammar creeping back in: the + number is the *resolved* instant, the form `sandlock_profile_parse` reports + and the form `sandlock_sandbox_builder_time_start_epoch` takes. Two doors + into one instant, so they have to arrive at the same policy, which is what + is compared here. The SDK reads neither of them. + """ + stamp = "2023-11-14T22:13:20Z" + seconds = 1700000000 + + def policy(value) -> dict: + name = _unique("parity-epoch") + sb = sandlock.Sandbox(time_start=value, fs_readable=GRANTS, name=name) + return _policy_from_sdk(cli, sb, name) + + from_text = policy(stamp) + from_number = policy(seconds) + assert from_text["determinism"]["time_start"] == stamp + assert from_number["determinism"]["time_start"] == stamp + + # Which does not make the count a spelling of the stamp. Written as text it + # is offered to the grammar, and the grammar has no reading for it. + with pytest.raises(RuntimeError, match='invalid time_start "1700000000"'): + with sandlock.Sandbox(time_start=str(seconds)) as sb: + sb.run(["/bin/true"]) diff --git a/tests/grammar-corpus.json b/tests/grammar-corpus.json new file mode 100644 index 00000000..b4ed0a62 --- /dev/null +++ b/tests/grammar-corpus.json @@ -0,0 +1,222 @@ +{ + "about": [ + "One corpus of values, four surfaces.", + "", + "sandlock owns two small grammars: a byte size (limits.memory, limits.disk)", + "and an RFC 3339 instant (determinism.time_start). Both are reachable four", + "ways: a command-line flag, a profile key, the Python SDK and the Go SDK.", + "Until the C ABI setters started taking strings, the last two parsed the", + "value themselves. They agreed with each other and both disagreed with the", + "core they were feeding, accepting 1.5G and 1T that no flag and no profile", + "has ever accepted. The entries marked sdk_only below are exactly those.", + "", + "This file is the one list those surfaces are measured against. It lives", + "above all of them on purpose: a corpus pasted into each language would be", + "the very failure being fixed, one table per surface, free to drift.", + "", + "Read by:", + " python/tests/test_setter_grammar_parity.py (the flag, the profile, the", + " C ABI setter called through ctypes, and the Python SDK)", + " go/grammar_parity_linux_test.go (the Go SDK)", + "", + "knobs: one grammar carrier, and how each surface spells it.", + " flag the command-line flag", + " profile [section, key] in a profile, and where the value lands in the", + " canonical document sandlock_profile_parse returns", + " setter the C ABI setter", + " python the Python SDK field", + " go the Go SDK field", + " labels the knob name each surface passes to the core so the core can", + " name it in a diagnosis. Absent when the grammar's message names", + " no knob, in which case every surface must say the same words.", + "", + "accepted: values every surface must take.", + " resolved what the value means, as the canonical profile document reports", + " it: a count of bytes, or {seconds, nanoseconds} since the epoch.", + " This pins the grammar, not merely that the surfaces agree.", + " renders how a live sandbox spells the value back over `sandlock", + " inspect`, or null when that document cannot carry it.", + " live false for a policy whose guest cannot survive to be inspected.", + " Such a value is still driven through every surface, one step", + " earlier: all of them have to accept it.", + "", + "rejected: values no surface may take.", + " diagnosis what the core's sentence has to start with, once the surface's", + " own envelope is peeled and the knob label is folded to .", + " A prefix rather than the whole sentence because the timestamp", + " parser continues it with wording that belongs to jiff. The", + " surfaces are separately required to produce one identical full", + " sentence, so the prefix pins the verdict and the comparison", + " pins that nobody reworded the rest of it.", + " sdk_only true for a value a deleted SDK parser used to accept. These", + " are the regression this corpus exists to guard.", + "", + "unreachable: a surface that cannot carry the value at all, with the reason.", + " Only ever a limit of the host language, never a policy choice: Go spells", + " an unset string field as \"\", so an empty MaxMemory is Go's way of saying", + " nothing was configured and never reaches the core. The surfaces that can", + " carry the value still have to agree on it, and the claim itself is pinned", + " by a test of its own rather than taken on trust." + ], + + "knobs": { + "max_memory": { + "flag": "--max-memory", + "profile": ["limits", "memory"], + "setter": "sandlock_sandbox_builder_max_memory", + "python": "max_memory", + "go": "MaxMemory" + }, + "max_disk": { + "flag": "--max-disk", + "profile": ["limits", "disk"], + "setter": "sandlock_sandbox_builder_max_disk", + "python": "max_disk", + "go": "MaxDisk" + }, + "time_start": { + "flag": "--time-start", + "profile": ["determinism", "time_start"], + "setter": "sandlock_sandbox_builder_time_start", + "python": "time_start", + "go": "TimeStart", + "labels": { + "flag": "--time-start", + "profile": "[determinism].time_start", + "setter": "time_start", + "python": "time_start", + "go": "time_start" + } + } + }, + + "accepted": [ + { + "comment": "One quantity, six spellings: either case of the suffix, the whitespace the core trims around and inside the value, and the bare count of bytes a caller who already has a number writes. Whitespace inside the value is the detail a hand-written parser gets wrong in one direction or the other.", + "knob": "max_memory", "value": "512M", "resolved": 536870912, "renders": "512M" + }, + { "knob": "max_memory", "value": "512m", "resolved": 536870912, "renders": "512M" }, + { "knob": "max_memory", "value": " 512M ", "resolved": 536870912, "renders": "512M" }, + { "knob": "max_memory", "value": "512 M", "resolved": 536870912, "renders": "512M" }, + { "knob": "max_memory", "value": "536870912", "resolved": 536870912, "renders": "512M" }, + { "knob": "max_memory", "value": "1G", "resolved": 1073741824, "renders": "1G" }, + { + "comment": "A one-byte ceiling is a policy the grammar spells out and no guest survives (the dynamic loader's first anonymous mmap is over it), so this one is compared on acceptance instead of on a live document. It is here to keep the two layers apart: the grammar reads a quantity, and what the sandbox then does with it is not the grammar's business, which is why 0 sits in the rejected list under a message from the builder rather than from the parser.", + "knob": "max_memory", "value": "1", "resolved": 1, "renders": null, "live": false + }, + { + "comment": "The largest quantity the grammar can carry: one more gibibyte overflows u64, which is the rejected 17179869184G below.", + "knob": "max_memory", "value": "17179869183G", "resolved": 18446744072635809792, "renders": "17179869183G" + }, + { + "comment": "The second size knob has a setter of its own and could be wired to the wrong field without anything above noticing.", + "knob": "max_disk", "value": "1K", "resolved": 1024, "renders": "1K" + }, + { "knob": "max_disk", "value": "1G", "resolved": 1073741824, "renders": "1G" }, + { + "comment": "The same text the memory knob refuses. Zero is the documented spelling of an unlimited disk quota, and the grammar reads it identically for both; what differs is the policy each knob can express with it. Two verdicts on one spelling is the case a surface with a parser of its own would be most tempted to smooth over, so it is here, and all four surfaces have to disagree in the same direction.", + "knob": "max_disk", "value": "0", "resolved": 0, "renders": "0" + }, + { + "comment": "Not a whole multiple of any unit, so the document has to spell it in bytes; a size that rendered as 1M would hide a rounding difference.", + "knob": "max_disk", "value": "1048577", "resolved": 1048577, "renders": "1048577" + }, + { + "comment": "The same instant written three ways has to resolve to one value, so the offset is applied rather than ignored.", + "knob": "time_start", "value": "2026-01-01T00:00:00Z", + "resolved": { "seconds": 1767225600, "nanoseconds": 0, "rfc3339": "2026-01-01T00:00:00Z" }, + "renders": "2026-01-01T00:00:00Z" + }, + { + "knob": "time_start", "value": "2026-01-01T00:00:00+00:00", + "resolved": { "seconds": 1767225600, "nanoseconds": 0, "rfc3339": "2026-01-01T00:00:00Z" }, + "renders": "2026-01-01T00:00:00Z" + }, + { + "knob": "time_start", "value": "2025-12-31T19:00:00-05:00", + "resolved": { "seconds": 1767225600, "nanoseconds": 0, "rfc3339": "2026-01-01T00:00:00Z" }, + "renders": "2026-01-01T00:00:00Z" + }, + { + "comment": "The two instants the old uint64 epoch-seconds parameter could not carry at all. Both have to survive the effective policy document as well as the setter: rendering whole seconds, or dropping a pre-epoch stamp, would make what sandlock inspect prints disagree with what is running.", + "knob": "time_start", "value": "2026-01-01T00:00:00.5Z", + "resolved": { "seconds": 1767225600, "nanoseconds": 500000000, "rfc3339": "2026-01-01T00:00:00.5Z" }, + "renders": "2026-01-01T00:00:00.5Z" + }, + { + "knob": "time_start", "value": "1969-07-20T20:17:00Z", + "resolved": { "seconds": -14182980, "nanoseconds": 0, "rfc3339": "1969-07-20T20:17:00Z" }, + "renders": "1969-07-20T20:17:00Z" + } + ], + + "rejected": [ + { + "comment": "An empty value, which Go alone cannot express, and a blank one, which it can.", + "knob": "max_memory", "value": "", "diagnosis": "empty byte size string", + "unreachable": { "go": "an empty MaxMemory is Go's spelling of \"unset\"" } + }, + { "knob": "max_memory", "value": " ", "diagnosis": "empty byte size string" }, + { + "comment": "Zero parses: it is a perfectly good quantity, and the builder refuses it one layer further in, because zero is also the sentinel the supervisor carries for \"no ceiling\". So this entry checks something the parse cases cannot, that a surface forwards the value far enough to collect a verdict from beyond the parser as well.", + "knob": "max_memory", "value": "0", + "diagnosis": "max_memory must be greater than 0; omit it to leave memory unlimited" + }, + { "knob": "max_memory", "value": "not_a_size", "diagnosis": "invalid byte size: not_a_size" }, + { + "comment": "A bare suffix with no quantity, and a negative count.", + "knob": "max_memory", "value": "M", "diagnosis": "invalid byte size: M" + }, + { "knob": "max_memory", "value": "-1", "diagnosis": "invalid byte size: -1" }, + { + "comment": "One gibibyte past the largest accepted value: the multiplication overflows u64.", + "knob": "max_memory", "value": "17179869184G", "diagnosis": "byte size out of range: 17179869184G" + }, + { + "comment": "The fraction both deleted SDK parsers took. Python's parse_memory_size and Go's ParseMemory each ran the quantity through a float, so 1.5G built a sandbox with 1610612736 bytes that no profile carrying the same text could ever have built.", + "knob": "max_memory", "value": "1.5G", "diagnosis": "invalid byte size: 1.5G", "sdk_only": true + }, + { + "comment": "The same fraction below one unit, which the float parsers silently truncated to 512 rather than refusing.", + "knob": "max_memory", "value": "0.5K", "diagnosis": "invalid byte size: 0.5K", "sdk_only": true + }, + { + "comment": "The terabyte suffix the two SDKs invented between them. The core's units stop at G.", + "knob": "max_memory", "value": "1T", "diagnosis": "unknown byte size suffix: T", "sdk_only": true + }, + { + "knob": "max_disk", "value": "", "diagnosis": "empty byte size string", + "unreachable": { "go": "an empty MaxDisk is Go's spelling of \"unset\"" } + }, + { "knob": "max_disk", "value": " ", "diagnosis": "empty byte size string" }, + { + "comment": "The core reports the suffix it looked up, which it upper-cases first, so this is also a check that the surfaces quote the core rather than the input.", + "knob": "max_disk", "value": "12x", "diagnosis": "unknown byte size suffix: X" + }, + { "knob": "max_disk", "value": "1.5G", "diagnosis": "invalid byte size: 1.5G", "sdk_only": true }, + { "knob": "max_disk", "value": "512T", "diagnosis": "unknown byte size suffix: T", "sdk_only": true }, + { + "knob": "time_start", "value": "", "diagnosis": "invalid \"\": failed to parse year in date: ", + "unreachable": { "go": "an empty TimeStart is Go's spelling of \"unset\"" } + }, + { "knob": "time_start", "value": "nope", "diagnosis": "invalid \"nope\": failed to parse year in date: " }, + { + "comment": "A wall-clock reading with no offset is not an instant, so a naive stamp is refused rather than being read in some local zone.", + "knob": "time_start", "value": "2026-01-01T00:00:00", + "diagnosis": "invalid \"2026-01-01T00:00:00\": failed to find offset component" + }, + { + "knob": "time_start", "value": "2026-13-01T00:00:00Z", + "diagnosis": "invalid \"2026-13-01T00:00:00Z\": failed to parse month in date: " + }, + { + "comment": "A bare epoch count, which is exactly what the timestamp setter used to be handed: both SDKs resolved the text to seconds themselves and passed a number, so this reached a live policy through a binding while the identical text in a profile was refused. The number still has a door of its own (the Python SDK forwards an int to sandlock_sandbox_builder_time_start_epoch), which is the resolved instant rather than a second grammar; as text it is not an RFC 3339 stamp and is refused everywhere.", + "knob": "time_start", "value": "1700000000", + "diagnosis": "invalid \"1700000000\": ", "sdk_only": true + }, + { + "knob": "time_start", "value": "1700000000.5", + "diagnosis": "invalid \"1700000000.5\": ", "sdk_only": true + } + ] +}