Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion crates/sandlock-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 2 additions & 10 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -607,8 +606,7 @@ async fn run_command(args: RunArgs) -> Result<i32> {
// 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 {
Expand Down Expand Up @@ -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<SystemTime> {
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<BranchAction> {
match s {
"commit" => Ok(BranchAction::Commit),
Expand Down
39 changes: 18 additions & 21 deletions crates/sandlock-core/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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); }

Expand Down Expand Up @@ -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<SystemTime, SandlockError> {
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)
// ============================================================
Expand Down Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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#"
Expand Down
15 changes: 15 additions & 0 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SystemTime, SandboxError> {
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<String> {
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)` +
Expand Down
84 changes: 84 additions & 0 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<dyn Fn(u32) + Send + Sync + 'static>>,

// 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<String>,
}

impl std::fmt::Debug for SandboxBuilder {
Expand Down Expand Up @@ -305,6 +310,7 @@ impl Default for SandboxBuilder {
mode: None,
init_fn: None,
work_fn: None,
rejected: None,
}
}
}
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Sandbox, SandboxError> {
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)?;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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`,
Expand Down
36 changes: 26 additions & 10 deletions crates/sandlock-core/src/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 17 additions & 6 deletions crates/sandlock-ffi/include/sandlock.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading