From 48f336b16443f5da39674808ee25ab5cd36d34d9 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:21:10 -0700 Subject: [PATCH 1/7] time: keep the sign of a start time before 1970 calculate_time_offset converted both instants with duration_since(UNIX_EPOCH).unwrap_or_default(), which turns any instant before the epoch into the epoch itself. The timestamp grammar accepts such instants ("1969-07-20T20:17:00Z" parses), so the sandbox ran a clock months away from the one requested, with no error. Floor to signed seconds instead. Signed-off-by: Cong Wang --- crates/sandlock-core/src/time.rs | 36 +++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/crates/sandlock-core/src/time.rs b/crates/sandlock-core/src/time.rs index 8a24a6bb..3dd3058c 100644 --- a/crates/sandlock-core/src/time.rs +++ b/crates/sandlock-core/src/time.rs @@ -19,16 +19,19 @@ const CLOCK_BOOTTIME: u32 = 7; /// 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_secs(time_start) - epoch_secs(SystemTime::now()) +} + +/// Whole seconds since the epoch, floored, so an instant before 1970 keeps +/// its sign instead of collapsing onto the epoch. +fn epoch_secs(t: SystemTime) -> i64 { + match t.duration_since(SystemTime::UNIX_EPOCH) { + Ok(d) => d.as_secs() as i64, + Err(e) => { + let d = e.duration(); + -(d.as_secs() as i64) - i64::from(d.subsec_nanos() > 0) + } + } } /// Handle clock_nanosleep/timerfd_settime/timer_settime with TIMER_ABSTIME. @@ -128,6 +131,19 @@ mod tests { assert!(offset.abs() <= 2, "offset for 'now' should be near zero, got {}", offset); } + #[test] + fn test_calculate_time_offset_before_epoch() { + let moon = SystemTime::UNIX_EPOCH - Duration::from_secs(14_182_980); + let expected = -14_182_980 - epoch_secs(SystemTime::now()); + assert!((calculate_time_offset(moon) - expected).abs() <= 2); + } + + #[test] + fn test_epoch_secs_floors_before_epoch() { + let t = SystemTime::UNIX_EPOCH - Duration::from_millis(1500); + assert_eq!(epoch_secs(t), -2); + } + #[test] fn test_adjust_arithmetic() { // Monotonic clock: vDSO adds offset, so absolute deadline is shifted. From 46f0096c711152588b1f67bb38a9f6fb2525743e Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:23:43 -0700 Subject: [PATCH 2/7] core: own the one time_start grammar The CLI and the profile loader each carried a private jiff parser for time_start, and the profile renderer converted back through whole non-negative seconds, so a profile naming a pre-1970 instant came back with no time_start at all and a sub-second one came back rounded down. Expose parse_timestamp and format_timestamp next to ByteSize::parse so the core holds both directions of the grammar in one place, route the CLI and profile through them, and drop the CLI's jiff dependency. A binding that needs to read a timestamp now has a core routine to reach instead of a reason to write its own. Signed-off-by: Cong Wang --- Cargo.lock | 1 - crates/sandlock-cli/Cargo.toml | 1 - crates/sandlock-cli/src/main.rs | 12 ++------- crates/sandlock-core/src/profile.rs | 39 +++++++++++++---------------- crates/sandlock-core/src/sandbox.rs | 15 +++++++++++ 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 603a29ce..45591b80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,7 +1414,6 @@ version = "0.8.9" dependencies = [ "anyhow", "clap", - "jiff", "libc", "sandlock-core", "serde", diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 038b40cd..b3e0e2e2 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/main.rs b/crates/sandlock-cli/src/main.rs index 781abbe1..4aff534a 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -1,10 +1,9 @@ use clap::{Parser, Subcommand}; use sandlock_core::Sandbox; -use sandlock_core::sandbox::{BranchAction, ByteSize, SandboxBuilder}; +use sandlock_core::sandbox::{parse_timestamp, BranchAction, ByteSize, SandboxBuilder}; use sandlock_core::profile; use anyhow::{Result, anyhow}; use std::path::PathBuf; -use std::time::SystemTime; mod learn; #[derive(Parser)] @@ -607,8 +606,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)?; - builder = builder.time_start(t); + builder = builder.time_start(parse_timestamp("--time-start", ts)?); } if let Some(ref s) = args.max_disk { builder = builder.max_disk(ByteSize::parse(s)?); } if let Some(ref s) = args.on_exit { @@ -903,12 +901,6 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> /// 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/profile.rs b/crates/sandlock-core/src/profile.rs index 3697643c..18cb96eb 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -3,7 +3,6 @@ use crate::error::SandlockError; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::collections::HashMap; -use std::time::SystemTime; /// Program identity supplied by a profile alongside the policy. /// Not a `Sandbox` field — passed separately to the sandbox runner. @@ -446,7 +445,11 @@ impl ResolvedProfile { if let Some(p) = self.workdir { b = b.workdir(p); } if let Some(s) = self.random_seed { b = b.random_seed(s); } - if let Some(s) = self.time_start.as_deref() { b = b.time_start(parse_time_start(s)?); } + if let Some(s) = self.time_start.as_deref() { + let t = crate::sandbox::parse_timestamp("[determinism].time_start", s) + .map_err(SandlockError::Sandbox)?; + b = b.time_start(t); + } if self.deterministic_dirs { b = b.deterministic_dirs(true); } if self.no_randomize_memory { b = b.no_randomize_memory(true); } @@ -557,17 +560,6 @@ pub fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf, bool), SandlockErr Ok((PathBuf::from(virt), PathBuf::from(host), read_only)) } -/// 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()) -} - // ============================================================ // Reverse serialization: Sandbox -> ProfileInput (and JSON/TOML) // ============================================================ @@ -645,13 +637,6 @@ fn byte_size_str(b: crate::sandbox::ByteSize) -> String { } } -/// Render an RFC3339 timestamp from a `SystemTime` (inverse of `parse_time_start`). -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()) -} - /// Build a `ProfileInput` from a `Sandbox` (the effective policy). /// /// This is the reverse of `parse_input`: it flattens the `Sandbox` dataclass @@ -710,7 +695,7 @@ pub fn sandbox_to_profile(s: &Sandbox, extra_denied: &[String]) -> ProfileInput }, determinism: DeterminismSection { random_seed: s.random_seed, - time_start: s.time_start.and_then(time_start_str), + time_start: s.time_start.and_then(crate::sandbox::format_timestamp), deterministic_dirs: s.deterministic_dirs, no_randomize_memory: s.no_randomize_memory, }, @@ -1203,6 +1188,18 @@ mod tests { assert!(msg.contains("time_start"), "got: {msg}"); } + #[test] + fn profile_time_start_round_trips_before_epoch_and_below_a_second() { + for stamp in ["1969-07-20T20:17:00Z", "2026-01-01T00:00:00.9999999Z"] { + let toml = format!( + "[program]\nexec = \"/bin/true\"\n[determinism]\ntime_start = \"{stamp}\"\n" + ); + let (policy, _spec) = parse_profile(&toml).unwrap(); + let rendered = sandbox_to_profile(&policy, &[]); + assert_eq!(rendered.determinism.time_start.as_deref(), Some(stamp)); + } + } + #[test] fn profile_network_deny_parses() { let toml = r#" diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 7b25e98e..dd4649a8 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -77,6 +77,21 @@ impl ByteSize { } } +/// Parse a `time_start` value: an RFC 3339 instant with an explicit offset, +/// the one spelling every surface (flag, profile, C ABI) accepts. `knob` +/// names the surface the value came through, for the error. +pub fn parse_timestamp(knob: &str, s: &str) -> Result { + let ts: jiff::Timestamp = s + .parse() + .map_err(|e| SandboxError::Invalid(format!("{knob}: invalid timestamp {s:?}: {e}")))?; + Ok(ts.into()) +} + +/// Render `t` in the grammar [`parse_timestamp`] reads, losing nothing. +pub fn format_timestamp(t: SystemTime) -> Option { + jiff::Timestamp::try_from(t).ok().map(|ts| ts.to_string()) +} + /// Identity to run the sandboxed process as. /// /// Applied via a single-entry user-namespace map (`unshare(CLONE_NEWUSER)` + From b1bbc2df701dd0bd3b4f8e4a26c584181bb681eb Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:27:33 -0700 Subject: [PATCH 3/7] sandbox: accept size and time limits as text in the builder A binding holding "512M" or an RFC 3339 instant had nowhere in the core to hand it: max_memory, max_disk and time_start take the parsed type, so the binding had to parse first and ended up owning a grammar of its own. Add max_memory_spec, max_disk_spec and time_start_spec, which read the same grammar as the CLI and the profile. Builder setters cannot fail, so a value that does not parse is held and returned by build(), the same point where net and HTTP rule specs are reported. The first rejection wins: a later valid call must not quietly paper over a value the caller got wrong. Signed-off-by: Cong Wang --- crates/sandlock-core/src/sandbox/builder.rs | 84 +++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 6ebfea67..d344a104 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -235,6 +235,11 @@ pub struct SandboxBuilder { // COW fork work function: runs in each COW clone. #[cfg_attr(feature = "cli", clap(skip))] pub(crate) work_fn: Option>, + + // Setters are infallible by design, so a `*_spec` value that does not + // parse is held here and `build()` reports it. + #[cfg_attr(feature = "cli", clap(skip))] + rejected: Option, } impl std::fmt::Debug for SandboxBuilder { @@ -305,6 +310,7 @@ impl Default for SandboxBuilder { mode: None, init_fn: None, work_fn: None, + rejected: None, } } } @@ -371,6 +377,7 @@ impl Clone for SandboxBuilder { init_fn: None, // work_fn is Arc-wrapped; clone bumps the reference count. work_fn: self.work_fn.clone(), + rejected: self.rejected.clone(), } } } @@ -561,6 +568,14 @@ impl SandboxBuilder { self } + /// [`max_memory`](Self::max_memory) from its text form, e.g. `"512M"`. + pub fn max_memory_spec(self, spec: &str) -> Self { + match ByteSize::parse(spec) { + Ok(size) => self.max_memory(size), + Err(e) => self.reject(format!("max_memory: {}", detail(e))), + } + } + pub fn max_processes(mut self, n: u32) -> Self { self.max_processes = Some(n); self @@ -625,6 +640,19 @@ impl SandboxBuilder { self } + /// [`time_start`](Self::time_start) from an RFC 3339 instant. + pub fn time_start_spec(self, spec: &str) -> Self { + match super::parse_timestamp("time_start", spec) { + Ok(t) => self.time_start(t), + Err(e) => self.reject(detail(e)), + } + } + + fn reject(mut self, msg: String) -> Self { + self.rejected.get_or_insert(msg); + self + } + pub fn no_randomize_memory(mut self, v: bool) -> Self { self.no_randomize_memory = v; self @@ -665,6 +693,14 @@ impl SandboxBuilder { self } + /// [`max_disk`](Self::max_disk) from its text form, e.g. `"10G"`. + pub fn max_disk_spec(self, spec: &str) -> Self { + match ByteSize::parse(spec) { + Ok(size) => self.max_disk(size), + Err(e) => self.reject(format!("max_disk: {}", detail(e))), + } + } + pub fn on_exit(mut self, action: BranchAction) -> Self { self.on_exit = Some(action); self @@ -815,6 +851,9 @@ impl SandboxBuilder { /// `Sandbox::validate` performs. Use this in tests that deliberately /// construct sandboxes violating cross-section invariants. pub fn build_unchecked(self) -> Result { + if let Some(msg) = self.rejected { + return Err(SandboxError::Invalid(msg)); + } 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)?; @@ -1102,6 +1141,15 @@ impl SandboxBuilder { } } +/// The message inside `e`, without the "invalid sandbox" prefix that +/// `build()` adds back when it reports a rejected spec. +fn detail(e: SandboxError) -> String { + match e { + SandboxError::Invalid(msg) => msg, + other => other.to_string(), + } +} + /// An fs grant that exposes a credential file to the sandboxed child, with the /// path an operator should hand `--fs-deny` to actually close the hole. struct Exposure { @@ -1178,6 +1226,42 @@ mod tests { use super::exposing_grant; use std::path::PathBuf; + #[test] + fn spec_setters_take_the_core_grammar() { + let sb = super::SandboxBuilder::default() + .max_memory_spec("512M") + .max_disk_spec("1024") + .time_start_spec("1969-07-20T20:17:00Z") + .build() + .unwrap(); + assert_eq!(sb.max_memory, Some(super::ByteSize::mib(512))); + assert_eq!(sb.max_disk, Some(super::ByteSize(1024))); + let moon = std::time::UNIX_EPOCH - std::time::Duration::from_secs(14_182_980); + assert_eq!(sb.time_start, Some(moon)); + } + + #[test] + fn spec_setters_fail_the_build_naming_the_knob() { + for (b, knob) in [ + (super::SandboxBuilder::default().max_memory_spec("1.5G"), "max_memory"), + (super::SandboxBuilder::default().max_disk_spec("1T"), "max_disk"), + (super::SandboxBuilder::default().time_start_spec("1767225600"), "time_start"), + ] { + let msg = b.build().expect_err("must not build").to_string(); + assert!(msg.starts_with(&format!("invalid sandbox: {knob}: ")), "got: {msg}"); + } + } + + #[test] + fn first_rejected_spec_wins_over_a_later_valid_one() { + let err = super::SandboxBuilder::default() + .max_memory_spec("lots") + .max_memory_spec("1G") + .build() + .expect_err("a rejected value must not be overwritten silently"); + assert!(err.to_string().contains("lots"), "got: {err}"); + } + #[test] fn max_open_files_zero_is_rejected_at_build() { // Zero cannot be honoured: the child needs descriptors to reach `main`, From d20453cf5b0bdf0c2c2493ca78c054a89602276b Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:30:22 -0700 Subject: [PATCH 4/7] ffi: take sizes and the start time as text max_memory and max_disk took a byte count and time_start whole epoch seconds, so every binding whose users write "512M" or an RFC 3339 instant had to parse it first. Both did, and both drifted from the core: they accepted "1.5G" and "1T", which no flag or profile accepts, and the Go one took bare epoch counts and refused anything before 1970. Take the text and hand it to the builder's *_spec setters, so the core reads the one grammar and a refused value comes back from sandlock_sandbox_build with the core's message. The epoch-seconds form also could not carry a sub-second or pre-1970 start; the text form carries both. A null string frees the builder and returns null, as an out-of-range BranchAction does: skipping it instead would build a sandbox without the limit the caller asked for. Signed-off-by: Cong Wang --- crates/sandlock-ffi/include/sandlock.h | 23 +++++-- crates/sandlock-ffi/src/lib.rs | 43 +++++++++---- crates/sandlock-ffi/tests/size_time_spec.rs | 71 +++++++++++++++++++++ 3 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 crates/sandlock-ffi/tests/size_time_spec.rs diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index e2c6fe93..f43e1027 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -476,16 +476,22 @@ sandlock_builder_t *sandlock_sandbox_builder_on_exit(sandlock_builder_t *b, uint sandlock_builder_t *sandlock_sandbox_builder_on_error(sandlock_builder_t *b, uint8_t action); /** + * `size` uses the CLI and profile grammar, e.g. "512M". A value that does not + * parse fails the build with the reason; a null `size` returns null. + * * # Safety - * `b` must be a valid builder pointer. + * `b` must be a valid builder pointer; `size` a NUL-terminated string or null. */ -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); /** + * `size` uses the CLI and profile grammar, e.g. "10G". A value that does not + * parse fails the build with the reason; a null `size` returns null. + * * # Safety - * `b` must be a valid builder pointer. + * `b` must be a valid builder pointer; `size` a NUL-terminated string or null. */ -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); /** * # Safety @@ -633,10 +639,15 @@ sandlock_builder_t *sandlock_sandbox_builder_env_var(sandlock_builder_t *b, const char *value); /** + * `timestamp` is RFC 3339 with an offset, e.g. "2000-01-01T00:00:00Z". A value + * that does not parse fails the build with the reason; null returns null. + * * # Safety - * `b` must be a valid builder pointer. `epoch_secs` is seconds since UNIX epoch. + * `b` must be a valid builder pointer; `timestamp` a NUL-terminated string + * or null. */ -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); /** * # Safety diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 832bab6b..a5c8a601 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -9,7 +9,7 @@ use std::ptr; use std::time::Duration; use sandlock_core::pipeline::Stage; -use sandlock_core::sandbox::{BranchAction, ByteSize, SandboxBuilder}; +use sandlock_core::sandbox::{BranchAction, SandboxBuilder}; use sandlock_core::{ExitStatus, Protection, RunResult, Sandbox, StdioMode}; pub mod handler; @@ -361,32 +361,46 @@ fn try_branch_action_from_raw(raw: u8) -> Option { // Sandbox Builder — resource limits // ---------------------------------------------------------------- +/// `size` uses the CLI and profile grammar, e.g. "512M". A value that does not +/// parse fails the build with the reason; a null `size` returns null. +/// /// # Safety -/// `b` must be a valid builder pointer. +/// `b` must be a valid builder pointer; `size` a NUL-terminated string or null. #[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)))) + if size.is_null() { + return ptr::null_mut(); + } + let size = CStr::from_ptr(size).to_string_lossy(); + Box::into_raw(Box::new(builder.max_memory_spec(&size))) } +/// `size` uses the CLI and profile grammar, e.g. "10G". A value that does not +/// parse fails the build with the reason; a null `size` returns null. +/// /// # Safety -/// `b` must be a valid builder pointer. +/// `b` must be a valid builder pointer; `size` a NUL-terminated string or null. #[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)))) + if size.is_null() { + return ptr::null_mut(); + } + let size = CStr::from_ptr(size).to_string_lossy(); + Box::into_raw(Box::new(builder.max_disk_spec(&size))) } /// # Safety @@ -724,19 +738,26 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_env_var( Box::into_raw(Box::new(builder.env_var(key, value))) } +/// `timestamp` is RFC 3339 with an offset, e.g. "2000-01-01T00:00:00Z". A value +/// that does not parse fails the build with the reason; null returns null. +/// /// # Safety -/// `b` must be a valid builder pointer. `epoch_secs` is seconds since UNIX epoch. +/// `b` must be a valid builder pointer; `timestamp` a NUL-terminated string +/// or null. #[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))) + if timestamp.is_null() { + return ptr::null_mut(); + } + let timestamp = CStr::from_ptr(timestamp).to_string_lossy(); + Box::into_raw(Box::new(builder.time_start_spec(×tamp))) } /// # Safety diff --git a/crates/sandlock-ffi/tests/size_time_spec.rs b/crates/sandlock-ffi/tests/size_time_spec.rs new file mode 100644 index 00000000..9b080466 --- /dev/null +++ b/crates/sandlock-ffi/tests/size_time_spec.rs @@ -0,0 +1,71 @@ +//! C ABI size and time setters forward text to the core grammar, and a value +//! the core refuses comes back from the build with the core's reason. + +use std::ffi::{CStr, CString}; +use std::ptr; + +use sandlock_core::sandbox::{ByteSize, SandboxBuilder}; +use sandlock_ffi::{ + sandlock_sandbox_build, sandlock_sandbox_builder_max_disk, sandlock_sandbox_builder_max_memory, + sandlock_sandbox_builder_new, sandlock_sandbox_builder_time_start, sandlock_string_free, +}; + +type Setter = unsafe extern "C" fn(*mut SandboxBuilder, *const libc::c_char) -> *mut SandboxBuilder; + +fn build_err(setter: Setter, value: &str) -> String { + let value = CString::new(value).unwrap(); + let b = unsafe { setter(sandlock_sandbox_builder_new(), value.as_ptr()) }; + assert!(!b.is_null()); + let mut err = 0; + let mut msg = ptr::null_mut(); + let policy = unsafe { sandlock_sandbox_build(b, &mut err, &mut msg) }; + assert!(policy.is_null(), "{value:?} built"); + assert_eq!(err, -1); + let text = unsafe { CStr::from_ptr(msg) }.to_string_lossy().into_owned(); + unsafe { sandlock_string_free(msg) }; + text +} + +#[test] +fn setters_take_the_core_spellings() { + let mem = CString::new("512M").unwrap(); + let disk = CString::new("1G").unwrap(); + let start = CString::new("1969-07-20T20:17:00.5Z").unwrap(); + let b = unsafe { + let b = sandlock_sandbox_builder_max_memory(sandlock_sandbox_builder_new(), mem.as_ptr()); + let b = sandlock_sandbox_builder_max_disk(b, disk.as_ptr()); + sandlock_sandbox_builder_time_start(b, start.as_ptr()) + }; + let sb = unsafe { *Box::from_raw(b) }.build().expect("build failed"); + assert_eq!(sb.max_memory, Some(ByteSize::mib(512))); + assert_eq!(sb.max_disk, Some(ByteSize::gib(1))); + let moon = std::time::UNIX_EPOCH - std::time::Duration::from_millis(14_182_979_500); + assert_eq!(sb.time_start, Some(moon)); +} + +#[test] +fn refused_values_report_the_core_reason() { + let cases: [(Setter, &str, &str); 4] = [ + (sandlock_sandbox_builder_max_memory, "1.5G", "max_memory"), + (sandlock_sandbox_builder_max_memory, "1T", "max_memory"), + (sandlock_sandbox_builder_max_disk, "lots", "max_disk"), + (sandlock_sandbox_builder_time_start, "1767225600", "time_start"), + ]; + for (setter, value, knob) in cases { + let msg = build_err(setter, value); + assert!(msg.contains(knob), "{value:?}: {msg}"); + } +} + +#[test] +fn null_value_fails_the_build() { + let setters: [Setter; 3] = [ + sandlock_sandbox_builder_max_memory, + sandlock_sandbox_builder_max_disk, + sandlock_sandbox_builder_time_start, + ]; + for setter in setters { + let b = unsafe { setter(sandlock_sandbox_builder_new(), ptr::null()) }; + assert!(b.is_null()); + } +} From 7252e372faf9ed7a6a9b66e2769027a9d240e31c Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:33:09 -0700 Subject: [PATCH 5/7] go: forward sizes and the start time to the core go/internal/policy parsed MaxMemory, MaxDisk and TimeStart itself, by rules copied from the Python SDK rather than from the core: it took "1.5G" and "1T", which the CLI and profiles refuse, took a bare epoch count, which a profile refuses, and refused a pre-1970 instant, which a profile takes. The same field meant different things depending on which surface carried it. Now that the C ABI takes the text, pass it through unchanged and let the build report anything the core refuses. TimeStart is RFC 3339 only. Signed-off-by: Cong Wang --- go/internal/policy/spec.go | 59 --------------------------- go/internal/policy/spec_test.go | 72 --------------------------------- go/sandbox.go | 2 +- go/sandlock_linux.go | 33 +++++---------- go/sandlock_linux_test.go | 28 +++++++++++++ 5 files changed, 40 insertions(+), 154 deletions(-) delete mode 100644 go/internal/policy/spec.go delete mode 100644 go/internal/policy/spec_test.go 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 58366a89..c7e65732 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -264,7 +264,7 @@ type Sandbox struct { // Determinism. RandomSeed *uint64 // seed getrandom() deterministically - TimeStart string // virtual clock start: RFC3339 or unix seconds + TimeStart string // virtual clock start, RFC 3339, e.g. "2000-01-01T00:00:00Z" NoRandomizeMemory bool // disable ASLR NoHugePages bool // disable transparent huge pages DeterministicDirs bool // sort readdir() entries diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index e3674987..28637fc6 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -30,8 +30,6 @@ import ( "syscall" "time" "unsafe" - - "github.com/multikernel/sandlock/go/internal/policy" ) // hasNUL reports whether s contains an interior NUL byte, which cannot survive @@ -299,20 +297,14 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { // Resource limits. 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)) + 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) } if s.MaxProcesses > 0 { b = C.sandlock_sandbox_builder_max_processes(b, C.uint32_t(s.MaxProcesses)) @@ -354,12 +346,9 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { 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)) + 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) } if s.NoRandomizeMemory { b = C.sandlock_sandbox_builder_no_randomize_memory(b, cbool(true)) @@ -432,8 +421,8 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { // 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. +// a build error). Reached only on a validation error found after the builder +// already exists. func freeBuilderViaBuild(b *C.sandlock_builder_t) { var errCode C.int var errMsg *C.char diff --git a/go/sandlock_linux_test.go b/go/sandlock_linux_test.go index a1462aea..8ded35ef 100644 --- a/go/sandlock_linux_test.go +++ b/go/sandlock_linux_test.go @@ -159,6 +159,34 @@ func TestRunUnknownBranchActionRejected(t *testing.T) { } } +func TestSizeAndTimeRefusedByCore(t *testing.T) { + cases := []struct { + sb *sandlock.Sandbox + knob string + }{ + {&sandlock.Sandbox{MaxMemory: "1.5G"}, "max_memory"}, + {&sandlock.Sandbox{MaxDisk: "1T"}, "max_disk"}, + {&sandlock.Sandbox{TimeStart: "1767225600"}, "time_start"}, + } + for _, c := range cases { + if _, err := c.sb.Run(context.Background(), "true"); err == nil || !strings.Contains(err.Error(), c.knob) { + t.Fatalf("err = %v, want a %s refusal", err, c.knob) + } + } +} + +func TestTimeStartBeforeEpoch(t *testing.T) { + requireLandlock(t) + sb := &sandlock.Sandbox{FSReadable: rootfs, MaxMemory: "64M", TimeStart: "1969-07-20T20:17:00Z"} + res, err := sb.Run(context.Background(), "date", "-u", "+%Y") + if err != nil { + t.Fatalf("Run: %v", err) + } + if got := strings.TrimSpace(string(res.Stdout)); got != "1969" { + t.Fatalf("year = %q, want 1969 (stderr=%q)", got, res.Stderr) + } +} + func TestSyscallEventArgvContains(t *testing.T) { ev := sandlock.SyscallEvent{Argv: []string{"python3", "-c", "print(1)"}} if !ev.ArgvContains("python") { From f307fd3a45f58a2490e33bc1da4da805f10fb6d8 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:35:00 -0700 Subject: [PATCH 6/7] python: forward sizes and the start time to the core The SDK parsed max_memory and max_disk with its own regex, which took "1.5G" and "1T" though no flag or profile does, and time_start never worked as text at all: the builder called int() on it, so any RFC 3339 string raised ValueError. Numbers were truncated to whole seconds, and time_start_timestamp quietly read a naive time as UTC. Forward the values as text and let the build report what the core refuses. time_start takes an RFC 3339 string or a datetime, which is rendered with isoformat(); a naive datetime has no offset and is refused by the core like any offset-less string, rather than being pinned to a zone the caller never named. parse_memory_size, memory_bytes and time_start_timestamp go with the grammar they carried. Signed-off-by: Cong Wang --- docs/sandbox-reference.md | 4 +- python/README.md | 4 +- python/src/sandlock/_sdk.py | 28 ++++------ python/src/sandlock/sandbox.py | 74 ++++---------------------- python/tests/test_sandbox.py | 6 +++ python/tests/test_sandbox_config.py | 82 +++++++++++------------------ 6 files changed, 62 insertions(+), 136 deletions(-) diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 0a6d412d..d454de6c 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -239,7 +239,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` | `datetime \| str \| None`| `None` | Start time as an RFC 3339 string with an offset, or an aware `datetime`. Time advances at real speed from the given instant. | | `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)`. | @@ -399,7 +399,7 @@ prefix redundant; the GPU and CPU placement fields keep their names. | `max_processes` | `processes` | `int \| None` | `None` | Maximum number of **concurrent** processes in the sandbox (peak, not lifetime; threads do not count). Unlimited when unset, so a default sandbox does not contain a fork bomb; set it when running untrusted code. | | `max_open_files` | `open_files` | `int \| None` | `None` | Maximum number of open file descriptors. Enforced via `RLIMIT_NOFILE` (kernel, survives `exec`), set in the child right before it execs. Both the soft and the hard limit are lowered, and descendants inherit the cap. Clamped to **both** limits sandlock itself inherited, so it is an upper bound, never a grant: a request above the inherited soft limit gives the guest the inherited limit, not more; raise the limit on sandlock itself (`prlimit`, systemd `LimitNOFILE=`) if a guest needs a bigger budget. Lowering the hard limit makes the cap one-way only for an *unprivileged* sandlock; a sandbox launched by root (or with `CAP_SYS_RESOURCE`) can raise it back, since sandlock does not drop capabilities; treat it as a resource budget, not as confinement. The limit must also cover process startup (stdio, the dynamic loader's per-library descriptors, and under `chroot` the injected exec fd); too low a value fails the exec and exits 127, reporting `EMFILE` on a plain exec but `EIO` under `chroot`. Past startup the errno likewise depends on who services the `open`: `EMFILE` from the kernel, `EACCES` when the supervisor mediates it (`chroot`, COW, procfs virtualisation). Measured floor for a trivial command: about 4, plain exec or `chroot`; programs linking more libraries need more. | | `max_cpu` | `cpu` | `int \| None` | `None` | CPU throttle as a percentage of one core (1 to 100). Applied to the entire process group via `SIGSTOP`/`SIGCONT` cycling. | -| `max_disk` | `disk` | `str \| None` | `None` | COW storage quota (e.g. `"1G"`). Returned as `ENOSPC` when the upper layer exceeds it. | +| `max_disk` | `disk` | `str \| int \| None` | `None` | COW storage quota (e.g. `"1G"`). Returned as `ENOSPC` when the upper layer exceeds it. | | `gpu_devices` | `gpu_devices` | `Sequence[int] \| None` | `None` | GPU device indices to expose. `None` denies GPU access entirely; `[]` exposes every GPU; a list exposes only those devices. Adds Landlock rules for `/dev/nvidia*` and `/dev/dri/*` and sets `CUDA_VISIBLE_DEVICES` / `ROCR_VISIBLE_DEVICES`. | | `cpu_cores` | `cpu_cores` | `Sequence[int] \| None` | `None` | CPU cores to pin the sandbox to via `sched_setaffinity` in the child. | | `num_cpus` | `num_cpus` | `int \| None` | `None` | Visible CPU count in `/proc/cpuinfo` (renumbered `0..N-1`). Also virtualizes `/proc/meminfo` when `max_memory` is set. | diff --git a/python/README.md b/python/README.md index f34be234..b90566d2 100644 --- a/python/README.md +++ b/python/README.md @@ -252,7 +252,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` | `datetime \| str \| None` | `None` | Start time for time virtualization: RFC 3339 with an offset, or an aware `datetime` | | `no_randomize_memory` | `bool` | `False` | Disable ASLR | | `no_huge_pages` | `bool` | `False` | Disable Transparent Huge Pages | | `deterministic_dirs` | `bool` | `False` | Sort directory entries lexicographically | @@ -283,7 +283,7 @@ Sandlock always applies its default syscall blocklist. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `fs_storage` | `str \| None` | `None` | Storage directory for the seccomp COW upper layer / deltas | -| `max_disk` | `str \| None` | `None` | Disk quota for COW storage (e.g. `"1G"`) | +| `max_disk` | `str \| int \| None` | `None` | Disk quota for COW storage, e.g. `"1G"` or int bytes | | `on_exit` | `BranchAction` | `COMMIT` | `COMMIT`, `ABORT`, `KEEP`, or `DEFER` | | `on_error` | `BranchAction` | `ABORT` | `COMMIT`, `ABORT`, `KEEP`, or `DEFER` | diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 7b59c03b..bed4a7a6 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -4,6 +4,7 @@ import ctypes import ctypes.util +import datetime import json import os import signal @@ -85,8 +86,8 @@ def _builder_fn(name, *extra_args): _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) @@ -106,7 +107,7 @@ 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_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) @@ -1166,8 +1167,6 @@ def __del__(self): @staticmethod def _build_from_policy(policy: PolicyDataclass): """Build a native builder from a Python Sandbox dataclass. Returns builder pointer.""" - from .sandbox import parse_memory_size - b = _lib.sandlock_sandbox_builder_new() for p in (policy.fs_readable or []): @@ -1210,18 +1209,9 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_on_error(b, _action_map[on_error_val]) if policy.max_memory is not None: - if isinstance(policy.max_memory, str): - mem_bytes = parse_memory_size(policy.max_memory) - else: - mem_bytes = int(policy.max_memory) - b = _b_max_memory(b, mem_bytes) - + b = _b_max_memory(b, _encode(policy.max_memory)) if policy.max_disk is not None: - if isinstance(policy.max_disk, str): - disk_bytes = parse_memory_size(policy.max_disk) - else: - disk_bytes = int(policy.max_disk) - b = _b_max_disk(b, disk_bytes) + b = _b_max_disk(b, _encode(policy.max_disk)) if policy.max_processes is not None: b = _b_max_processes(b, policy.max_processes) @@ -1273,8 +1263,10 @@ def _build_from_policy(policy: PolicyDataclass): if policy.random_seed is not None: b = _b_random_seed(b, policy.random_seed) if policy.time_start is not None: - epoch_secs = int(policy.time_start.timestamp()) if hasattr(policy.time_start, 'timestamp') else int(policy.time_start) - b = _b_time_start(b, epoch_secs) + ts = policy.time_start + if isinstance(ts, datetime.datetime): + ts = ts.isoformat() + b = _b_time_start(b, _encode(ts)) if policy.clean_env: b = _b_clean_env(b, True) for k, v in (policy.env or {}).items(): diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 6535e9af..7dd250f4 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -24,41 +24,9 @@ _name_counter = itertools.count(1) if TYPE_CHECKING: - from ._notif_policy import NotifPolicy - - -# --- 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. + from datetime import datetime - 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) + from ._notif_policy import NotifPolicy _PORT_RANGE_RE = re.compile(r"^(\d+)(?:-(\d+))?$") @@ -341,7 +309,8 @@ class Sandbox: # Resource limits max_memory: str | int | None = None - """Memory limit. String like '512M' or int bytes.""" + """Memory limit: a size such as ``'512M'``, or int bytes. The core + parses it, so the spelling is the one ``--max-memory`` takes.""" max_processes: int | None = None """Maximum concurrent processes in the sandbox (threads do not @@ -386,11 +355,11 @@ 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 + time_start: datetime | 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.""" + and gettimeofday() return shifted time starting from this instant. + Accepts an RFC 3339 string with an offset (``'2000-01-01T00:00:00Z'``) + or an aware datetime. Time ticks at real speed from the given point.""" no_randomize_memory: bool = False """Disable Address Space Layout Randomization (ASLR) inside the sandbox. @@ -474,8 +443,8 @@ class Sandbox: fs_storage: str | None = None """Separate storage directory for the seccomp COW upper layer / deltas.""" - max_disk: str | None = None - """Disk quota for COW storage (e.g. ``'1G'``). + max_disk: str | int | None = None + """Disk quota for COW storage (e.g. ``'1G'``, or int bytes). Enforced by the COW layer (returns ENOSPC).""" on_exit: BranchAction = BranchAction.COMMIT @@ -568,29 +537,6 @@ def _ensure_native(self): # Config helper methods # ------------------------------------------------------------------ - def memory_bytes(self) -> int | None: - """Return max_memory as bytes, or None if unset.""" - if self.max_memory is None: - return None - if isinstance(self.max_memory, int): - return self.max_memory - return parse_memory_size(self.max_memory) - - def time_start_timestamp(self) -> float | None: - """Return time_start as a Unix timestamp float, or None if unset.""" - if self.time_start is None: - return None - if isinstance(self.time_start, (int, float)): - return float(self.time_start) - from datetime import datetime, timezone - s = self.time_start - if s.endswith("Z"): - s = s[:-1] + "+00:00" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.timestamp() - def cpu_pct(self) -> int | None: """Return max_cpu as a clamped percentage (1–100), or None.""" if self.max_cpu is None: diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index a59effe2..e2b5624b 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -1027,6 +1027,12 @@ def test_time_start(self): assert result.success assert result.stdout.strip() == b"2000" + def test_time_start_before_epoch(self): + p = _policy(time_start="1969-07-20T20:17:00Z") + result = p.run(["date", "-u", "+%Y"]) + assert result.success + assert result.stdout.strip() == b"1969" + def test_extra_deny_syscalls(self): p = _policy(extra_deny_syscalls=["mount"]) result = p.run(["echo", "ok"]) diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index e7e3c802..fbb42bcd 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -7,43 +7,42 @@ from sandlock.sandbox import ( Sandbox, - parse_memory_size, 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 - - def test_invalid(self): - with pytest.raises(ValueError): - parse_memory_size("not_a_size") - - def test_empty(self): - with pytest.raises(ValueError): - parse_memory_size("") +class TestCoreOwnsSizeAndTimeGrammar: + """Size and timestamp fields are forwarded verbatim; the core decides.""" + + @pytest.mark.parametrize("field,value", [ + ("max_memory", "512M"), + ("max_memory", 1024), + ("max_disk", "1G"), + ("time_start", "1969-07-20T20:17:00Z"), + ("time_start", "2026-01-01T00:00:00.5+08:00"), + ]) + def test_accepted(self, field, value): + Sandbox(**{field: value})._ensure_native() + + @pytest.mark.parametrize("field,value", [ + ("max_memory", "1.5G"), + ("max_memory", "1T"), + ("max_disk", "lots"), + ("time_start", "1767225600"), + ("time_start", 1767225600.5), + ]) + def test_refused_with_core_reason(self, field, value): + with pytest.raises(RuntimeError, match=field): + Sandbox(**{field: value})._ensure_native() + + def test_aware_datetime_is_forwarded(self): + from datetime import datetime, timezone + Sandbox(time_start=datetime(1969, 7, 20, tzinfo=timezone.utc))._ensure_native() + + def test_naive_datetime_is_refused(self): + from datetime import datetime + with pytest.raises(RuntimeError, match="time_start"): + Sandbox(time_start=datetime(2000, 1, 1))._ensure_native() class TestEnsureNative: @@ -88,18 +87,6 @@ def test_mutable_config(self): p.max_memory = "1G" assert p.max_memory == "1G" - def test_memory_bytes_string(self): - p = Sandbox(max_memory="512M") - assert p.memory_bytes() == 512 * 1024 ** 2 - - def test_memory_bytes_int(self): - p = Sandbox(max_memory=1024) - assert p.memory_bytes() == 1024 - - def test_memory_bytes_none(self): - p = Sandbox() - assert p.memory_bytes() is None - def test_cpu_pct(self): p = Sandbox(max_cpu=50) assert p.cpu_pct() == 50 @@ -128,11 +115,6 @@ def test_mutable_config(self): 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 - class TestParsePorts: def test_single_int(self): From 0bb77d2e2a3a51e64411a9dfa3488180e606a82a Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 3 Oct 2026 11:35:28 -0700 Subject: [PATCH 7/7] bindings: stop wrapping an HTTP port that does not fit in 16 bits The C ABI carries an HTTP port as a u16. Go held HTTPPorts as []int and converted with C.uint16_t, and Python passed through ctypes.c_uint16; both truncate silently, so port 70000 intercepted traffic on 4464. Make the Go field []uint16 so the compiler refuses what the ABI cannot carry, and have Python refuse an out-of-range port before it reaches ctypes, which offers no such check of its own. Signed-off-by: Cong Wang --- go/profile_linux.go | 2 +- go/sandbox.go | 2 +- python/src/sandlock/_sdk.py | 3 +++ python/tests/test_sandbox_config.py | 7 +++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/go/profile_linux.go b/go/profile_linux.go index f09febf8..215f163d 100644 --- a/go/profile_linux.go +++ b/go/profile_linux.go @@ -49,7 +49,7 @@ type resolvedProfile struct { NetAllow []string `json:"net_allow"` NetDeny []string `json:"net_deny"` PortRemap bool `json:"port_remap"` - HTTPPorts []int `json:"http_ports"` + HTTPPorts []uint16 `json:"http_ports"` HTTPAllow []string `json:"http_allow"` HTTPDeny []string `json:"http_deny"` ExtraAllowSyscalls []string `json:"extra_allow_syscalls"` diff --git a/go/sandbox.go b/go/sandbox.go index c7e65732..673f4c34 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -244,7 +244,7 @@ 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) diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index bed4a7a6..263c1383 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -1242,6 +1242,9 @@ def _build_from_policy(policy: PolicyDataclass): for rule in (policy.http_deny or []): b = _b_http_deny(b, _encode(str(rule))) for port in (policy.http_ports or []): + # ctypes truncates to the u16 the ABI carries, so 70000 would arrive as 4464. + if not 0 <= int(port) <= 0xFFFF: + raise ValueError(f"http_ports: {port} is not a TCP port") b = _b_http_port(b, int(port)) if policy.http_ca: b = _b_http_ca(b, _encode(str(policy.http_ca))) diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index fbb42bcd..48b3d9e0 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -45,6 +45,13 @@ def test_naive_datetime_is_refused(self): Sandbox(time_start=datetime(2000, 1, 1))._ensure_native() +class TestHttpPorts: + @pytest.mark.parametrize("port", [-1, 70000]) + def test_out_of_range_port_is_refused(self, port): + with pytest.raises(ValueError, match="http_ports"): + Sandbox(http_ports=[port])._ensure_native() + + class TestEnsureNative: """``_ensure_native`` rebuilds on every call so that mutations to config fields between lifecycle invocations are not silently