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)` + 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`, 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. 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()); + } +} 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/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/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 58366a89..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) @@ -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") { 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..263c1383 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) @@ -1252,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))) @@ -1273,8 +1266,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..48b3d9e0 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -7,43 +7,49 @@ from sandlock.sandbox import ( Sandbox, - parse_memory_size, parse_ports, ) -class TestParseMemorySize: - def test_plain_bytes(self): - assert parse_memory_size("1024") == 1024 +class TestCoreOwnsSizeAndTimeGrammar: + """Size and timestamp fields are forwarded verbatim; the core decides.""" - def test_kilobytes(self): - assert parse_memory_size("100K") == 100 * 1024 + @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() - def test_megabytes(self): - assert parse_memory_size("512M") == 512 * 1024 ** 2 + @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_gigabytes(self): - assert parse_memory_size("1G") == 1024 ** 3 + 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_terabytes(self): - assert parse_memory_size("2T") == 2 * 1024 ** 4 + 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() - 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 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: @@ -88,18 +94,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 +122,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):