From f3877c99c646920e455bdae2f8c2db53245af0bb Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Mon, 12 Oct 2026 13:49:32 -0700 Subject: [PATCH 1/4] core: make checkpoint support an optional feature Checkpoint and restore currently pull bincode into every build, including embedders that only need process isolation. They also compile the C restore stub even when the application never restores a checkpoint. Add a default-enabled checkpoint feature and forward it through the CLI, FFI and OCI crates. Gate the Rust checkpoint engine and the restore stub build with compile-time feature checks. Compile the checkpoint C entry points only when the feature is enabled, so disabled builds omit their exports and opaque handle type. Gate checkpoint-specific tests and OCI commands with the feature. Add GitHub CI jobs for minimal, checkpoint-only and default builds, together with a generated C header comparison. Document how embedders can omit checkpoint support and its bincode dependency. Signed-off-by: Cong Wang --- .github/workflows/features.yml | 43 +++++ README.md | 8 + crates/sandlock-cli/Cargo.toml | 3 +- crates/sandlock-core/Cargo.toml | 5 +- crates/sandlock-core/build.rs | 160 +++++++++--------- crates/sandlock-core/src/lib.rs | 2 + crates/sandlock-core/src/sandbox.rs | 6 + crates/sandlock-core/src/sandbox/builder.rs | 1 + crates/sandlock-core/src/sandbox/tests.rs | 1 + crates/sandlock-core/tests/integration.rs | 2 + .../tests/integration/test_protection.rs | 1 + crates/sandlock-ffi/Cargo.toml | 3 +- crates/sandlock-ffi/src/lib.rs | 13 ++ crates/sandlock-ffi/tests/restore.rs | 2 + crates/sandlock-oci/Cargo.toml | 3 +- crates/sandlock-oci/src/main.rs | 6 + crates/sandlock-oci/src/supervisor.rs | 7 + crates/sandlock-oci/tests/integration.rs | 2 + 18 files changed, 185 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/features.yml diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml new file mode 100644 index 000000000..985ff7658 --- /dev/null +++ b/.github/workflows/features.yml @@ -0,0 +1,43 @@ +name: Optional features + +on: + push: + branches: [optional-core-features] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + feature: + name: Rust features (${{ matrix.feature }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + feature: [minimal, checkpoint, default] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: features-${{ matrix.feature }} + - name: Allow unprivileged user namespaces + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Check minimal dependency graph + if: matrix.feature == 'minimal' + run: | + if cargo tree --workspace --no-default-features -e normal --prefix none \ + | grep -E '^(bincode|hyper|rustls|rcgen|bollard) '; then + exit 1 + fi + - name: Test selected features + timeout-minutes: 20 + env: + FEATURE: ${{ matrix.feature }} + run: | + case "$FEATURE" in + minimal) args=(--no-default-features) ;; + default) args=() ;; + *) args=(--no-default-features --features "$FEATURE") ;; + esac + cargo test --release --workspace "${args[@]}" --no-fail-fast -- --test-threads=1 diff --git a/README.md b/README.md index 16d07e297..4d8f7b385 100644 --- a/README.md +++ b/README.md @@ -216,3 +216,11 @@ cargo test --release # Python tests cd python && pip install -e . && pytest tests/ ``` + +### Optional features + +Checkpoint capture, restore, and persistence are controlled by the `checkpoint` +Cargo feature, enabled by default. Disable default features to omit checkpoint +support and its `bincode` dependency. The CLI, FFI, and OCI crates forward this +feature to `sandlock-core`. FFI checkpoint entry points remain available and +return their documented failure values when checkpoint support is disabled. diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 02e10434a..283a279f1 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -13,7 +13,8 @@ name = "sandlock" path = "src/main.rs" [features] -default = ["http"] +checkpoint = ["sandlock-core/checkpoint"] +default = ["http", "checkpoint"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index d8365692a..e5fe1ee57 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -19,7 +19,7 @@ rand = "0.8" rand_chacha = "0.3" goblin = "0.9" uuid = { version = "1", features = ["v4"] } -bincode = "1" +bincode = { version = "1", optional = true } serde_json = "1" walkdir = "2" ring = "0.17" @@ -42,7 +42,8 @@ bollard = { version = "0.21", optional = true } futures-util = { version = "0.3", optional = true } [features] -default = ["http"] +checkpoint = ["dep:bincode"] +default = ["http", "checkpoint"] cli = ["dep:clap"] http = [ "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-rustls", diff --git a/crates/sandlock-core/build.rs b/crates/sandlock-core/build.rs index 0fbcfad4e..58d4dd992 100644 --- a/crates/sandlock-core/build.rs +++ b/crates/sandlock-core/build.rs @@ -20,94 +20,98 @@ fn main() { ); } - // restore-stub: a core component of the restore engine (the supervisor execs - // it to reconstruct a checkpoint), freestanding, no libc, no PIE. It lives - // next to the checkpoint code that owns it; its binary is built into OUT_DIR - // and its path is handed to the crate via the RESTORE_STUB_PATH env var, - // which embeds it: an installed sandlock has no build tree to exec from. - // - // The fixed load address must match `checkpoint::restore_blob::STUB_BASE`: - // the stub reconstructs the checkpoint's layout around itself, so its own - // text and stack have to sit outside the address range programs occupy. The - // default -no-pie base (0x400000) is exactly where a static ET_EXEC workload - // loads, so the checkpoint's own text would be mapped over the running stub. - // - // Cross-compilation: when TARGET is riscv64gc-unknown-linux-gnu (or any - // riscv64* variant), look for a riscv64 cross-compiler. On the host it - // uses plain `cc` as before. - let stub_src = manifest_dir.join("src/checkpoint/restore-stub.c"); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let stub_bin = out_dir.join("restore-stub"); let host = std::env::var("HOST").unwrap_or_default(); let target = std::env::var("TARGET").unwrap_or_default(); let is_riscv64 = target.starts_with("riscv64"); - // Checkpoint restore is claimed only on x86_64 and riscv64 (see - // `restore_interactive`); on those arches a stub build failure is fatal, not - // a silent skip — a green build with no stub is how regressions slip past CI. - let is_restore_arch = target.starts_with("x86_64") || is_riscv64; - let (ccs, fail_msg) = if is_riscv64 { - if host.starts_with("riscv64") { - ( - &["cc", "riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], - "failed to compile restore-stub for riscv64: no working C compiler \ - (install gcc); checkpoint restore is unavailable", - ) + #[cfg(feature = "checkpoint")] + { + // restore-stub: a core component of the restore engine (the supervisor execs + // it to reconstruct a checkpoint), freestanding, no libc, no PIE. It lives + // next to the checkpoint code that owns it; its binary is built into OUT_DIR + // and its path is handed to the crate via the RESTORE_STUB_PATH env var, + // which embeds it: an installed sandlock has no build tree to exec from. + // + // The fixed load address must match `checkpoint::restore_blob::STUB_BASE`: + // the stub reconstructs the checkpoint's layout around itself, so its own + // text and stack have to sit outside the address range programs occupy. The + // default -no-pie base (0x400000) is exactly where a static ET_EXEC workload + // loads, so the checkpoint's own text would be mapped over the running stub. + // + // Cross-compilation: when TARGET is riscv64gc-unknown-linux-gnu (or any + // riscv64* variant), look for a riscv64 cross-compiler. On the host it + // uses plain `cc` as before. + let stub_src = manifest_dir.join("src/checkpoint/restore-stub.c"); + let stub_bin = out_dir.join("restore-stub"); + // Checkpoint restore is claimed only on x86_64 and riscv64 (see + // `restore_interactive`); on those arches a stub build failure is fatal, not + // a silent skip — a green build with no stub is how regressions slip past CI. + let is_restore_arch = target.starts_with("x86_64") || is_riscv64; + let (ccs, fail_msg) = if is_riscv64 { + if host.starts_with("riscv64") { + ( + &["cc", "riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "failed to compile restore-stub for riscv64: no working C compiler \ + (install gcc); checkpoint restore is unavailable", + ) + } else { + ( + &["riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], + "failed to compile restore-stub for riscv64: no working cross-compiler \ + (install riscv64-linux-gnu-gcc); checkpoint restore is unavailable", + ) + } } else { ( - &["riscv64-linux-gnu-gcc", "riscv64-unknown-linux-gnu-gcc"][..], - "failed to compile restore-stub for riscv64: no working cross-compiler \ - (install riscv64-linux-gnu-gcc); checkpoint restore is unavailable", + &["cc"][..], + "failed to compile restore-stub: no working C compiler \ + (install cc/gcc); checkpoint restore is unavailable", ) + }; + // The link address must match restore_blob::STUB_BASE and must sit below + // the Sv39 user ceiling (256 GiB) on riscv64. x86_64 uses 0x300_0000_0000. + let text_segment = if is_riscv64 { + "-Wl,-Ttext-segment=0x3000000000" + } else { + "-Wl,-Ttext-segment=0x30000000000" + }; + if !build_static( + &stub_src, + &stub_bin, + ccs, + &[ + "-static", + "-nostdlib", + "-no-pie", + // Only PC-relative code can live at the link address below; a + // compiler that defaults to non-PIE (manylinux's gcc-toolset) + // emits 32-bit absolute .bss references that cannot reach it. + "-fPIE", + "-O2", + "-ffreestanding", + "-fno-tree-loop-distribute-patterns", + // A compiler with default SSP (vanilla GCC; Ubuntu's exempts + // -ffreestanding) reads the canary at %fs:0x28, and the stub's + // thread pointer is zero until it restores the checkpoint's. + "-fno-stack-protector", + text_segment, + ], + ) { + if is_restore_arch { + panic!("{fail_msg}"); + } + println!("cargo:warning={fail_msg}"); } - } else { - ( - &["cc"][..], - "failed to compile restore-stub: no working C compiler \ - (install cc/gcc); checkpoint restore is unavailable", - ) - }; - // The link address must match restore_blob::STUB_BASE and must sit below - // the Sv39 user ceiling (256 GiB) on riscv64. x86_64 uses 0x300_0000_0000. - let text_segment = if is_riscv64 { - "-Wl,-Ttext-segment=0x3000000000" - } else { - "-Wl,-Ttext-segment=0x30000000000" - }; - if !build_static( - &stub_src, - &stub_bin, - ccs, - &[ - "-static", - "-nostdlib", - "-no-pie", - // Only PC-relative code can live at the link address below; a - // compiler that defaults to non-PIE (manylinux's gcc-toolset) - // emits 32-bit absolute .bss references that cannot reach it. - "-fPIE", - "-O2", - "-ffreestanding", - "-fno-tree-loop-distribute-patterns", - // A compiler with default SSP (vanilla GCC; Ubuntu's exempts - // -ffreestanding) reads the canary at %fs:0x28, and the stub's - // thread pointer is zero until it restores the checkpoint's. - "-fno-stack-protector", - text_segment, - ], - ) { - if is_restore_arch { - panic!("{fail_msg}"); + // The crate embeds the stub with include_bytes!, so an arch without restore + // still needs a file there; the runtime treats an empty stub as unavailable. + if !stub_bin.exists() { + std::fs::write(&stub_bin, b"").unwrap(); } - println!("cargo:warning={fail_msg}"); - } - // The crate embeds the stub with include_bytes!, so an arch without restore - // still needs a file there; the runtime treats an empty stub as unavailable. - if !stub_bin.exists() { - std::fs::write(&stub_bin, b"").unwrap(); + // Emit the path every run (rustc-env is not cached across build-script runs), + // whether or not the binary was just (re)built. + println!("cargo:rustc-env=RESTORE_STUB_PATH={}", stub_bin.display()); + } - // Emit the path every run (rustc-env is not cached across build-script runs), - // whether or not the binary was just (re)built. - println!("cargo:rustc-env=RESTORE_STUB_PATH={}", stub_bin.display()); // shebang-trampoline: runs a chroot script's #! interpreter (see the // source). Freestanding, so any compiler for the target arch will do. diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 14c1c3bc4..1ed171d63 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -25,6 +25,7 @@ pub(crate) mod random; pub(crate) mod time; pub(crate) mod cow; pub mod recovery; +#[cfg(feature = "checkpoint")] pub(crate) mod checkpoint; pub(crate) mod freeze; pub mod netlink; @@ -46,6 +47,7 @@ mod transparent_proxy; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; +#[cfg(feature = "checkpoint")] pub use checkpoint::{Checkpoint, SkippedFd}; pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus}; pub use sandbox::{ diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 6d39cc503..4bc879862 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -607,6 +607,7 @@ pub struct Sandbox { // Fds the last `restore_interactive` could not transparently recreate. // Runtime state: not serialized, not cloned. #[serde(skip)] + #[cfg(feature = "checkpoint")] restore_skipped: Vec, } @@ -699,6 +700,7 @@ impl Clone for Sandbox { // Runtime is NOT cloned — the clone starts with no runtime. runtime: None, // Restore diagnostics belong to the original's run, not the clone. + #[cfg(feature = "checkpoint")] restore_skipped: Vec::new(), } } @@ -1242,6 +1244,7 @@ impl Sandbox { /// /// On error the child may be left mid-restore; the caller should drop/kill /// the Sandbox (Drop reaps it). + #[cfg(feature = "checkpoint")] pub async fn restore_interactive( &mut self, cp: &crate::checkpoint::Checkpoint, @@ -1305,6 +1308,7 @@ impl Sandbox { /// not transparently recreate (sockets, pipes, memfds, pseudo-filesystem /// paths); the restored process runs without them. Empty if this sandbox /// never restored a checkpoint or every fd was restored. + #[cfg(feature = "checkpoint")] pub fn restore_skipped(&self) -> &[crate::checkpoint::SkippedFd] { &self.restore_skipped } @@ -1417,6 +1421,7 @@ impl Sandbox { } /// Capture a checkpoint of the running sandbox. + #[cfg(feature = "checkpoint")] pub async fn checkpoint(&self) -> Result { use crate::error::{SandboxRuntimeError, SandlockError}; let pid = self.runtime.as_ref() @@ -1429,6 +1434,7 @@ impl Sandbox { /// direct child. The target must be a fork-descendant confined by the same /// policy (e.g. the workload spawned by sandlock-init). `target_pid` must /// be positive. + #[cfg(feature = "checkpoint")] pub async fn checkpoint_pid(&self, target_pid: i32) -> Result { use crate::error::{SandboxRuntimeError, SandlockError}; if target_pid <= 0 { diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 44bd072f7..0ed60723c 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -1147,6 +1147,7 @@ impl SandboxBuilder { init_fn: self.init_fn, work_fn: self.work_fn, runtime: None, + #[cfg(feature = "checkpoint")] restore_skipped: Vec::new(), }) } diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index 6ad536df5..f2ec6fd01 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -387,6 +387,7 @@ fn builder_combined_http_merges_at_resolution_time() { #[cfg(feature = "http")] #[test] +#[cfg(feature = "checkpoint")] fn outbound_mode_survives_policy_bincode_round_trip() { for builder in [ Sandbox::builder() diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index c1ec3165c..94fb67e73 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -10,6 +10,7 @@ mod test_determinism; #[path = "integration/test_cow.rs"] mod test_cow; +#[cfg(feature = "checkpoint")] #[path = "integration/test_checkpoint.rs"] mod test_checkpoint; @@ -79,6 +80,7 @@ mod test_http_inject_ca; #[path = "integration/test_http_strict_tls.rs"] mod test_http_strict_tls; +#[cfg(feature = "checkpoint")] #[path = "integration/test_restore.rs"] mod test_restore; diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs index 0105d946b..d12ed2b98 100644 --- a/crates/sandlock-core/tests/integration/test_protection.rs +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -340,6 +340,7 @@ fn builder_methods_fluent_chain() { // ---------------------------------------------------------------------- #[test] +#[cfg(feature = "checkpoint")] fn protection_policy_survives_bincode_round_trip() { let sb = sandlock_core::Sandbox::builder() .disable(Protection::SignalScope) diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index c00fcad58..026d838d0 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -12,7 +12,8 @@ description = "C ABI for sandlock process sandbox" crate-type = ["cdylib", "staticlib", "rlib"] [features] -default = ["http"] +checkpoint = ["sandlock-core/checkpoint"] +default = ["http", "checkpoint"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index a5c8a6016..920afb4dc 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -2609,6 +2609,7 @@ pub unsafe extern "C" fn sandlock_sandbox_fns_free(sb: *mut Sandbox) { // ---------------------------------------------------------------- /// Opaque handle wrapping a [`Checkpoint`]. +#[cfg(feature = "checkpoint")] #[allow(non_camel_case_types)] pub struct sandlock_checkpoint_t { _private: sandlock_core::Checkpoint, @@ -2620,6 +2621,7 @@ pub struct sandlock_checkpoint_t { /// /// # Safety /// `h` must be a valid handle from `sandlock_create`. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_handle_checkpoint( h: *mut sandlock_handle_t, @@ -2639,6 +2641,7 @@ pub unsafe extern "C" fn sandlock_handle_checkpoint( /// /// # Safety /// `cp` must be a valid checkpoint pointer. `dir` must be a valid C string path. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_save( cp: *const sandlock_checkpoint_t, @@ -2761,6 +2764,7 @@ pub unsafe extern "C" fn sandlock_profile_dir(err_msg: *mut *mut c_char) -> *mut /// /// # Safety /// `dir` must be a valid C string path to a checkpoint directory. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_load( dir: *const c_char, @@ -2779,6 +2783,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_load( /// /// # Safety /// `cp` must be a valid checkpoint pointer. `name` must be a valid C string. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_set_name( cp: *mut sandlock_checkpoint_t, @@ -2794,6 +2799,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_set_name( /// /// # Safety /// `cp` must be a valid checkpoint pointer. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_name(cp: *const sandlock_checkpoint_t) -> *mut c_char { if cp.is_null() { @@ -2809,6 +2815,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_name(cp: *const sandlock_checkpoint /// /// # Safety /// `cp` must be valid. `data` must point to `len` bytes, or be NULL to clear. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_set_app_state( cp: *mut sandlock_checkpoint_t, @@ -2830,6 +2837,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_set_app_state( /// /// # Safety /// `cp` must be a valid checkpoint pointer. `len` must be a valid pointer. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_app_state( cp: *const sandlock_checkpoint_t, @@ -2854,6 +2862,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_app_state( /// /// # Safety /// `cp` must be null or a valid checkpoint pointer. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_checkpoint_free(cp: *mut sandlock_checkpoint_t) { if !cp.is_null() { @@ -2884,6 +2893,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_free(cp: *mut sandlock_checkpoint_t /// `policy` must be a valid policy pointer and `cp` a valid checkpoint /// pointer. `name` may be NULL to auto-generate a sandbox name, or a valid /// NUL-terminated string. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_restore_interactive( policy: *const sandlock_sandbox_t, @@ -2932,6 +2942,7 @@ pub unsafe extern "C" fn sandlock_restore_interactive( /// /// # Safety /// `h` must be null or a valid handle. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_handle_restore_skipped_len( h: *const sandlock_handle_t, @@ -2947,6 +2958,7 @@ pub unsafe extern "C" fn sandlock_handle_restore_skipped_len( /// /// # Safety /// `h` must be null or a valid handle. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_handle_restore_skipped_fd( h: *const sandlock_handle_t, @@ -2967,6 +2979,7 @@ pub unsafe extern "C" fn sandlock_handle_restore_skipped_fd( /// /// # Safety /// `h` must be null or a valid handle. +#[cfg(feature = "checkpoint")] #[no_mangle] pub unsafe extern "C" fn sandlock_handle_restore_skipped_path( h: *const sandlock_handle_t, diff --git a/crates/sandlock-ffi/tests/restore.rs b/crates/sandlock-ffi/tests/restore.rs index aed48fed2..2542174bb 100644 --- a/crates/sandlock-ffi/tests/restore.rs +++ b/crates/sandlock-ffi/tests/restore.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "checkpoint")] + //! Integration test for the C ABI checkpoint-restore `sandlock_restore_interactive`. //! //! Drives the FFI symbols directly (no C compilation step of the bindings; diff --git a/crates/sandlock-oci/Cargo.toml b/crates/sandlock-oci/Cargo.toml index f1802f3ee..f58529dab 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -17,7 +17,8 @@ name = "sandlock-oci" path = "src/main.rs" [features] -default = ["http"] +checkpoint = ["sandlock-core/checkpoint"] +default = ["http", "checkpoint"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-oci/src/main.rs b/crates/sandlock-oci/src/main.rs index 10196a4d7..a7d60a06f 100644 --- a/crates/sandlock-oci/src/main.rs +++ b/crates/sandlock-oci/src/main.rs @@ -186,6 +186,7 @@ enum Command { }, /// Capture a checkpoint of a running sandbox to an image directory. + #[cfg(feature = "checkpoint")] Checkpoint { /// Sandbox/container ID. id: String, @@ -195,6 +196,7 @@ enum Command { }, /// Restore a sandbox from a checkpoint image directory (resumes it running). + #[cfg(feature = "checkpoint")] Restore { /// Sandbox/container ID to create. id: String, @@ -328,9 +330,11 @@ fn run(cli: Cli) -> Result<()> { } println!("Platform: {}", std::env::consts::ARCH); } + #[cfg(feature = "checkpoint")] Command::Checkpoint { id, image_path } => { cmd_checkpoint(&id, &image_path)?; } + #[cfg(feature = "checkpoint")] Command::Restore { id, image_path, bundle: _, no_pivot: _, no_new_keyring: _ } => { cmd_restore(&id, &image_path)?; } @@ -562,6 +566,7 @@ fn cmd_create(id: &str, bundle: &PathBuf, pid_file: Option<&std::path::Path>) -> /// policy and command come from the checkpoint image (not an OCI bundle), and /// there is no separate `start`: `run_supervisor_restore` both creates and /// resumes the child, so the sandbox is `Running` once the handshake succeeds. +#[cfg(feature = "checkpoint")] fn cmd_restore(id: &str, image_path: &str) -> Result<()> { // Reject a re-used ID up front, exactly like `create`. if SandboxState::load(id).is_ok() { @@ -787,6 +792,7 @@ fn cmd_delete(id: &str, force: bool) -> Result<()> { /// `sandlock-oci checkpoint --image-path ` /// /// Asks the Supervisor to snapshot the running sandbox into an image directory. +#[cfg(feature = "checkpoint")] fn cmd_checkpoint(id: &str, image_path: &str) -> Result<()> { match supervisor::send_command(id, supervisor::SupervisorCmd::Checkpoint { dir: image_path.to_string() })? { supervisor::SupervisorReply::Ok => Ok(()), diff --git a/crates/sandlock-oci/src/supervisor.rs b/crates/sandlock-oci/src/supervisor.rs index 10f5b4746..9a14e515f 100644 --- a/crates/sandlock-oci/src/supervisor.rs +++ b/crates/sandlock-oci/src/supervisor.rs @@ -174,6 +174,7 @@ pub enum SupervisorCmd { /// parked child. Shutdown, /// Capture a checkpoint of the running child into `dir`. + #[cfg(feature = "checkpoint")] Checkpoint { dir: String }, /// Run an additional process inside the running container. Carries 3 /// ancillary fds (stdin, stdout, stderr) over SCM_RIGHTS alongside this @@ -506,6 +507,7 @@ async fn supervisor_main( let _ = stream.write_all(b"\n").await; return Ok(None); } + #[cfg(feature = "checkpoint")] SupervisorCmd::Checkpoint { dir } => { let reply = match sandbox.checkpoint().await { Ok(mut cp) => { @@ -632,6 +634,7 @@ async fn serve_one_running_init( handle_exec(stream, link.clone(), args, env, cwd, detach, fds).await; RunningCmd::Continue } + #[cfg(feature = "checkpoint")] SupervisorCmd::Checkpoint { dir } => { // Capture the WORKLOAD, not sandlock-init (which is the sandbox's // direct child and would only snapshot the init process blocked in @@ -821,6 +824,7 @@ async fn serve_one_running( let _ = stream.write_all(b"\n").await; RunningCmd::Continue } + #[cfg(feature = "checkpoint")] SupervisorCmd::Checkpoint { dir } => { let reply = match sandbox.checkpoint().await { Ok(mut cp) => { @@ -935,6 +939,7 @@ async fn reap_and_collapse( /// saved `Sandbox`), not from an `OciPolicy`, and there is no separate `start`: /// `restore_interactive` both creates the child and resumes it, so the sandbox /// is `Running` the moment restore returns. +#[cfg(feature = "checkpoint")] pub fn run_supervisor_restore(id: &str, image_dir: &str, pid_write_fd: i32) -> Result<()> { // Load the checkpoint image. On failure report back through the pid pipe so // the CLI surfaces a clear error rather than a bare EOF. @@ -966,6 +971,7 @@ pub fn run_supervisor_restore(id: &str, image_dir: &str, pid_write_fd: i32) -> R } /// Async body of [`run_supervisor_restore`]. +#[cfg(feature = "checkpoint")] async fn supervisor_restore_main( id: &str, mut sandbox: sandlock_core::Sandbox, @@ -1110,6 +1116,7 @@ mod tests { } #[test] + #[cfg(feature = "checkpoint")] fn supervisor_cmd_checkpoint_serde() { let cmd = SupervisorCmd::Checkpoint { dir: "/tmp/img".into() }; let json = serde_json::to_string(&cmd).unwrap(); diff --git a/crates/sandlock-oci/tests/integration.rs b/crates/sandlock-oci/tests/integration.rs index 032c75d4b..032083018 100644 --- a/crates/sandlock-oci/tests/integration.rs +++ b/crates/sandlock-oci/tests/integration.rs @@ -251,6 +251,7 @@ void _start(void){{ /// the counter past the checkpointed baseline, that `state` reports `running`, /// then `delete --force` cleans up. #[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "checkpoint")] async fn oci_restore_resumes_vdso_free_program() { if cfg!(not(target_arch = "x86_64")) { eprintln!("skipping: this test is x86_64-only (counter program)"); @@ -413,6 +414,7 @@ fn build_counter(bin: &Path, src: &Path, out_path: &str) -> bool { /// image into a second container and proves the restored counter advances, /// exercising a full OCI checkpoint -> restore round-trip of a running program. #[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "checkpoint")] async fn oci_checkpoint_of_running_container() { if cfg!(not(target_arch = "x86_64")) { eprintln!("skipping: this test is x86_64-only (counter program)"); From 63fb772872565706f7c147071989ff6a3024e8ca Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Mon, 12 Oct 2026 13:50:33 -0700 Subject: [PATCH 2/4] core: make image support an optional feature Local image extraction and remote image loading bring archive, compression and Docker client dependencies into embedders that use host filesystem paths directly. These dependencies should follow image support. Add a default-enabled image feature and forward it through the workspace. Keep image extraction and pulling under the same feature, and move the Docker client dependencies out of the HTTP feature. Remote transports continue to require HTTP support as well. Gate the Rust image module and builder method. Compile the image C entry points only when the feature is enabled, and reject CLI image requests when image support is disabled. Extend GitHub CI with an image-only job and document the feature boundary. Signed-off-by: Cong Wang --- .github/workflows/features.yml | 4 ++-- README.md | 5 +++++ crates/sandlock-cli/Cargo.toml | 3 ++- crates/sandlock-cli/src/main.rs | 5 +++++ crates/sandlock-core/Cargo.toml | 13 +++++++------ crates/sandlock-core/src/lib.rs | 1 + crates/sandlock-core/src/sandbox/builder.rs | 1 + crates/sandlock-ffi/Cargo.toml | 3 ++- crates/sandlock-ffi/src/lib.rs | 2 ++ crates/sandlock-ffi/tests/image.rs | 2 ++ crates/sandlock-oci/Cargo.toml | 3 ++- 11 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index 985ff7658..a849552d7 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - feature: [minimal, checkpoint, default] + feature: [minimal, checkpoint, image, default] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -27,7 +27,7 @@ jobs: if: matrix.feature == 'minimal' run: | if cargo tree --workspace --no-default-features -e normal --prefix none \ - | grep -E '^(bincode|hyper|rustls|rcgen|bollard) '; then + | grep -E '^(bincode|tar|flate2|ruzstd|base64|hyper|rustls|rcgen|bollard) '; then exit 1 fi - name: Test selected features diff --git a/README.md b/README.md index 4d8f7b385..67a742bc5 100644 --- a/README.md +++ b/README.md @@ -224,3 +224,8 @@ Cargo feature, enabled by default. Disable default features to omit checkpoint support and its `bincode` dependency. The CLI, FFI, and OCI crates forward this feature to `sandlock-core`. FFI checkpoint entry points remain available and return their documented failure values when checkpoint support is disabled. + +The `image` feature enables container image loading and is enabled by default. +Remote registry and Docker daemon transports additionally require `http`; local +OCI layouts and archives work without it. Disabled image requests through the +CLI or FFI return an error. diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 283a279f1..f4743e99c 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -13,8 +13,9 @@ name = "sandlock" path = "src/main.rs" [features] +image = ["sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint"] +default = ["http", "checkpoint", "image"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 4aff534a4..7bd764014 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -645,9 +645,14 @@ async fn run_command(args: RunArgs) -> Result { let image_cmd: Option>; if let Some(ref img) = args.image { + #[cfg(not(feature = "image"))] + return Err(anyhow!("image support is disabled; enable the image feature")); + #[cfg(feature = "image")] + { let image = sandlock_core::image::pull(img, None).await?; builder = builder.image(&image); image_cmd = args.cmd.is_empty().then(|| image.config.default_cmd()); + } } else { image_cmd = None; } diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index e5fe1ee57..e980ab52c 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -33,21 +33,22 @@ hyper = { version = "1", features = ["server", "client", "http1"], optional = tr hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"], optional = true } http-body-util = { version = "0.1", optional = true } hyper-rustls = { version = "0.27", default-features = false, features = ["ring", "native-tokio", "http1", "tls12", "logging"], optional = true } -tar = "0.4" -flate2 = { version = "1", default-features = false, features = ["rust_backend"] } -ruzstd = "0.9" -base64 = "0.22" +tar = { version = "0.4", optional = true } +flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true } +ruzstd = { version = "0.9", optional = true } +base64 = { version = "0.22", optional = true } clap = { version = "4", features = ["derive"], optional = true } bollard = { version = "0.21", optional = true } futures-util = { version = "0.3", optional = true } [features] +image = ["dep:tar", "dep:flate2", "dep:ruzstd", "dep:base64", "dep:bollard", "dep:futures-util"] checkpoint = ["dep:bincode"] -default = ["http", "checkpoint"] +default = ["http", "checkpoint", "image"] cli = ["dep:clap"] http = [ "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-rustls", - "dep:tokio-rustls", "dep:rustls", "dep:rcgen", "dep:bollard", "dep:futures-util", + "dep:tokio-rustls", "dep:rustls", "dep:rcgen", ] [dev-dependencies] diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 1ed171d63..21f90ac0d 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -34,6 +34,7 @@ pub(crate) mod port_remap; pub mod pipeline; pub mod transaction; pub mod policy_fn; +#[cfg(feature = "image")] pub mod image; pub mod fork; pub(crate) mod ca_inject; diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 0ed60723c..4270bbebc 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -744,6 +744,7 @@ impl SandboxBuilder { /// has not set, so explicit settings win whichever order they come in. /// Writes land in a copy-on-write branch that is always discarded; see /// `build_unchecked`. + #[cfg(feature = "image")] pub fn image(mut self, image: &crate::image::Image) -> Self { self.chroot = Some(image.rootfs.clone()); self.image_rootfs = Some(image.rootfs.clone()); diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index 026d838d0..ca8fcc931 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -12,8 +12,9 @@ description = "C ABI for sandlock process sandbox" crate-type = ["cdylib", "staticlib", "rlib"] [features] +image = ["sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint"] +default = ["http", "checkpoint", "image"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 920afb4dc..2a9c56047 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -200,6 +200,7 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_chroot( /// /// # Safety /// `b` and `image_json` must be valid pointers. +#[cfg(feature = "image")] #[no_mangle] pub unsafe extern "C" fn sandlock_sandbox_builder_image( b: *mut SandboxBuilder, @@ -2668,6 +2669,7 @@ pub unsafe extern "C" fn sandlock_checkpoint_save( /// # Safety /// `reference` must be a valid C string; `cache_dir` a valid C string or /// NULL; `err_msg` a valid pointer or NULL. +#[cfg(feature = "image")] #[no_mangle] pub unsafe extern "C" fn sandlock_image_pull( reference: *const c_char, diff --git a/crates/sandlock-ffi/tests/image.rs b/crates/sandlock-ffi/tests/image.rs index 953c8cc80..156a138e8 100644 --- a/crates/sandlock-ffi/tests/image.rs +++ b/crates/sandlock-ffi/tests/image.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "image")] + //! Container images through the C ABI: `sandlock_image_pull` hands back //! JSON that `sandlock_sandbox_builder_image` turns into a chrooted run. diff --git a/crates/sandlock-oci/Cargo.toml b/crates/sandlock-oci/Cargo.toml index f58529dab..6d75cd32a 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -17,8 +17,9 @@ name = "sandlock-oci" path = "src/main.rs" [features] +image = ["sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint"] +default = ["http", "checkpoint", "image"] http = ["sandlock-core/http"] [dependencies] From 19b452829c9b83d376eeae09a8564d0a22c3e92b Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Mon, 12 Oct 2026 13:51:21 -0700 Subject: [PATCH 3/4] core: make TOML profiles an optional feature Embedders that construct policies directly do not need TOML profile parsing or serialization. The profile schema is also used by live JSON inspection, so removing that schema would disable unrelated functionality. Add a default-enabled profiles feature for TOML loading, writing and CLI learning. Keep the policy schema and JSON inspection available in minimal builds. Return a feature-disabled error for TOML operations when profiles are omitted, and forward the feature through the workspace crates. Gate TOML-specific tests and extend GitHub CI with a profiles-only job. Keep TOML as a core development dependency for the existing expansion fixtures without retaining it in the minimal production dependency graph. Document which profile operations remain available. Signed-off-by: Cong Wang --- .github/workflows/features.yml | 4 +-- README.md | 4 +++ crates/sandlock-cli/Cargo.toml | 5 ++-- crates/sandlock-cli/src/main.rs | 9 ++++++ crates/sandlock-cli/tests/cli_test.rs | 3 ++ .../sandlock-cli/tests/learn_integration.rs | 2 ++ crates/sandlock-cli/tests/learn_test.rs | 2 ++ .../sandlock-cli/tests/profile_integration.rs | 2 ++ crates/sandlock-core/Cargo.toml | 6 ++-- crates/sandlock-core/src/profile.rs | 30 +++++++++++++++++-- .../tests/integration/test_control.rs | 2 ++ crates/sandlock-ffi/Cargo.toml | 3 +- crates/sandlock-oci/Cargo.toml | 3 +- 13 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index a849552d7..e0615c910 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - feature: [minimal, checkpoint, image, default] + feature: [minimal, checkpoint, image, profiles, default] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -27,7 +27,7 @@ jobs: if: matrix.feature == 'minimal' run: | if cargo tree --workspace --no-default-features -e normal --prefix none \ - | grep -E '^(bincode|tar|flate2|ruzstd|base64|hyper|rustls|rcgen|bollard) '; then + | grep -E '^(toml|bincode|tar|flate2|ruzstd|base64|hyper|rustls|rcgen|bollard) '; then exit 1 fi - name: Test selected features diff --git a/README.md b/README.md index 67a742bc5..c38eb50b6 100644 --- a/README.md +++ b/README.md @@ -229,3 +229,7 @@ The `image` feature enables container image loading and is enabled by default. Remote registry and Docker daemon transports additionally require `http`; local OCI layouts and archives work without it. Disabled image requests through the CLI or FFI return an error. + +The `profiles` feature enables TOML profile loading, writing, and CLI learning. +It is enabled by default. The profile schema and JSON policy inspection remain +available without it; TOML operations return a feature-disabled error. diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index f4743e99c..2cc509b39 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -13,9 +13,10 @@ name = "sandlock" path = "src/main.rs" [features] +profiles = ["sandlock-core/profiles", "dep:toml"] image = ["sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint", "image"] +default = ["http", "checkpoint", "image", "profiles"] http = ["sandlock-core/http"] [dependencies] @@ -23,7 +24,7 @@ sandlock-core = { version = "0.8.9", path = "../sandlock-core", default-features clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } anyhow = "1" -toml = "0.8" +toml = { version = "0.8", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" libc = "0.2" diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 7bd764014..33b82f344 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -5,6 +5,7 @@ use sandlock_core::profile; use anyhow::{Result, anyhow}; use std::path::PathBuf; +#[cfg(feature = "profiles")] mod learn; #[derive(Parser)] #[command(name = "sandlock", about = "Lightweight process sandbox", version)] @@ -321,12 +322,17 @@ async fn main() -> Result<()> { Ok(resp) if resp.ok => { if let Some(data) = resp.data { if toml { + #[cfg(not(feature = "profiles"))] + return Err(anyhow!("TOML output requires the profiles feature")); + #[cfg(feature = "profiles")] + { // Round-trip through ProfileInput -> TOML. let profile: sandlock_core::ProfileInput = serde_json::from_value(data) .map_err(|e| anyhow!("parse config response: {}", e))?; let toml_str = toml::to_string_pretty(&profile)?; println!("{}", toml_str); + } } else { let json_str = serde_json::to_string_pretty(&data)?; println!("{}", json_str); @@ -413,7 +419,10 @@ async fn main() -> Result<()> { } Command::Learn(args) => { + #[cfg(feature = "profiles")] learn::run(args).await?; + #[cfg(not(feature = "profiles"))] + return Err(anyhow!("learning profiles requires the profiles feature")); } Command::Profile { action } => { diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 5ff9f8758..e63a2ff18 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -82,6 +82,7 @@ fn test_run_hostname_virtualized() { } #[test] +#[cfg(feature = "profiles")] fn test_profile_list_empty() { let output = sandlock_bin() .args(["profile", "list"]) @@ -366,6 +367,7 @@ fn test_uid_mapping_arbitrary_uid() { /// `sandlock run --profile-file` prints a hint to stderr suggesting /// `sandlock learn --merge` when the sandboxed process exits non-zero. #[test] +#[cfg(feature = "profiles")] fn test_run_denial_hint_on_nonzero_exit() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); let profile_path = profile.path().to_str().unwrap().to_owned(); @@ -585,6 +587,7 @@ fn test_inspect_returns_json_policy() { } #[test] +#[cfg(feature = "profiles")] fn test_inspect_toml_flag_produces_toml() { let name = format!("test-config-toml-cli-{}", std::process::id()); let mut child = spawn_sandbox(&name); diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index dfad4d675..da76f744c 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "profiles")] + // Each test here spawns a full `sandlock` process. // Running all tests in parallel exhausts kernel resources on most hosts. // Limit parallelism when running this suite: diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index 9ae262a2d..e527ca929 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "profiles")] + // Each test here spawns a full `sandlock` process. // Running all tests in parallel exhausts kernel resources on most hosts. // Limit parallelism when running this suite: diff --git a/crates/sandlock-cli/tests/profile_integration.rs b/crates/sandlock-cli/tests/profile_integration.rs index 1710c27ee..18b8e4b04 100644 --- a/crates/sandlock-cli/tests/profile_integration.rs +++ b/crates/sandlock-cli/tests/profile_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "profiles")] + use std::process::Command; fn sandlock_bin() -> Command { diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index e980ab52c..5f46ac231 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -23,7 +23,7 @@ bincode = { version = "1", optional = true } serde_json = "1" walkdir = "2" ring = "0.17" -toml = "0.8" +toml = { version = "0.8", optional = true } jiff = "0.2" pathdiff = "0.2" tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"], optional = true } @@ -42,9 +42,10 @@ bollard = { version = "0.21", optional = true } futures-util = { version = "0.3", optional = true } [features] +profiles = ["dep:toml"] image = ["dep:tar", "dep:flate2", "dep:ruzstd", "dep:base64", "dep:bollard", "dep:futures-util"] checkpoint = ["dep:bincode"] -default = ["http", "checkpoint", "image"] +default = ["http", "checkpoint", "image", "profiles"] cli = ["dep:clap"] http = [ "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-rustls", @@ -52,6 +53,7 @@ http = [ ] [dev-dependencies] +toml = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } tempfile = "3" rustls-pemfile = "2" diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 2a9c9a4c8..49a51f54b 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -191,6 +191,7 @@ pub struct LimitsSection { impl ProfileInput { /// Serialize the profile to a TOML string. + #[cfg(feature = "profiles")] pub fn to_toml(&self) -> Result { toml::to_string(self) } @@ -753,6 +754,7 @@ pub fn sandbox_to_profile(s: &Sandbox, extra_denied: &[String]) -> ProfileInput } /// Serialize a `Sandbox` to a TOML profile string. +#[cfg(feature = "profiles")] pub fn sandbox_to_toml(s: &Sandbox, extra_denied: &[String]) -> Result { let input = sandbox_to_profile(s, extra_denied); toml::to_string_pretty(&input).map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid( @@ -770,10 +772,11 @@ pub fn sandbox_to_json(s: &Sandbox, extra_denied: &[String]) -> Result Result { Ok(PathBuf::from(crate::expand::resolve_home()?).join(".config/sandlock/profiles")) } - +#[cfg(feature = "profiles")] fn parse_toml(content: &str) -> Result { toml::from_str(content) .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid( @@ -823,7 +826,7 @@ pub fn list_profiles() -> Result, SandlockError> { Ok(names) } -#[cfg(test)] +#[cfg(all(test, feature = "profiles"))] mod tests { use super::*; @@ -1339,3 +1342,26 @@ mod tests { assert!(msg.contains("unknown field"), "got: {msg}"); } } + +#[cfg(not(feature = "profiles"))] +fn profiles_disabled() -> SandlockError { + crate::error::SandboxError::FeatureDisabled { + what: "TOML profiles".into(), + feature: "profiles", + }.into() +} + +#[cfg(not(feature = "profiles"))] +fn parse_toml(_content: &str) -> Result { + Err(profiles_disabled()) +} + +#[cfg(not(feature = "profiles"))] +pub fn sandbox_to_toml(_s: &Sandbox, _extra_denied: &[String]) -> Result { + Err(profiles_disabled()) +} + +#[cfg(not(feature = "profiles"))] +pub fn profile_dir() -> Result { + Err(profiles_disabled()) +} diff --git a/crates/sandlock-core/tests/integration/test_control.rs b/crates/sandlock-core/tests/integration/test_control.rs index 9d77e55b4..a0eb34bf3 100644 --- a/crates/sandlock-core/tests/integration/test_control.rs +++ b/crates/sandlock-core/tests/integration/test_control.rs @@ -201,6 +201,7 @@ fn test_control_sandbox_to_profile() { } #[test] +#[cfg(feature = "profiles")] fn test_control_mode_stays_out_of_profile() { // The mode marker is ps metadata, not policy: inspect output (a // ProfileInput) must not carry it. @@ -251,6 +252,7 @@ fn test_control_sandbox_to_profile_merges_dynamic_denies() { } #[test] +#[cfg(feature = "profiles")] fn test_control_sandbox_to_toml_roundtrip() { let sb = sandlock_core::Sandbox::builder() .fs_read("/usr") diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index ca8fcc931..dbc53d4a2 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -12,9 +12,10 @@ description = "C ABI for sandlock process sandbox" crate-type = ["cdylib", "staticlib", "rlib"] [features] +profiles = ["sandlock-core/profiles"] image = ["sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint", "image"] +default = ["http", "checkpoint", "image", "profiles"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-oci/Cargo.toml b/crates/sandlock-oci/Cargo.toml index 6d75cd32a..2858a01ae 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -17,9 +17,10 @@ name = "sandlock-oci" path = "src/main.rs" [features] +profiles = ["sandlock-core/profiles"] image = ["sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint", "image"] +default = ["http", "checkpoint", "image", "profiles"] http = ["sandlock-core/http"] [dependencies] From b8f6460218afe1f25dfe5dc84bcab605d2072d42 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Mon, 12 Oct 2026 13:53:29 -0700 Subject: [PATCH 4/4] core: make filesystem COW an optional feature Copy-on-write branches, recovery and transactions bring filesystem and hashing dependencies into every sandbox build. Minimal embedders should be able to omit them while retaining ordinary filesystem isolation. Add a default-enabled cow feature and gate branch state, syscall handlers, recovery and transaction code. Reject COW configuration when the feature is disabled, both in the builder and before starting a deserialized sandbox, so a requested COW policy cannot silently become direct writes. Keep serialized policy fields stable across feature configurations. Make image support enable cow to protect cached image contents. Require both profiles and cow for CLI learning, which discards observed writes. Keep the chroot syscall-filter test active without cow and gate its COW counterpart separately. Expand the existing minimal-build CI job into a matrix for each feature and the minimal build, including production dependency checks. Reuse the existing default-build and C header jobs instead of a separate workflow. Add disabled-feature tests for policy validation and JSON inspection. Forward the selected features into the C smoke test build and check that checkpoint and image symbols are exported exactly when enabled. Document the feature dependencies and the reduced C API in the build guide. Signed-off-by: Cong Wang --- .github/workflows/ci.yml | 53 ++++++++++----- .github/workflows/features.yml | 43 ------------ README.md | 31 ++++----- crates/sandlock-cli/Cargo.toml | 5 +- crates/sandlock-cli/src/main.rs | 8 +-- crates/sandlock-cli/tests/cli_test.rs | 4 +- .../sandlock-cli/tests/learn_integration.rs | 2 +- crates/sandlock-cli/tests/learn_test.rs | 2 +- crates/sandlock-core/Cargo.toml | 11 +-- crates/sandlock-core/src/chroot/dispatch.rs | 22 ++++++ crates/sandlock-core/src/context/tests.rs | 6 +- crates/sandlock-core/src/lib.rs | 5 ++ crates/sandlock-core/src/profile.rs | 1 + crates/sandlock-core/src/sandbox.rs | 52 +++++++++++++- crates/sandlock-core/src/sandbox/builder.rs | 11 +++ crates/sandlock-core/src/seccomp/dispatch.rs | 3 +- crates/sandlock-core/src/seccomp/state.rs | 6 +- crates/sandlock-core/tests/features.rs | 48 +++++++++++++ crates/sandlock-core/tests/integration.rs | 3 + .../tests/integration/test_chroot.rs | 8 +++ .../tests/integration/test_policy.rs | 1 + .../tests/integration/test_procfs.rs | 1 + crates/sandlock-ffi/Cargo.toml | 5 +- crates/sandlock-ffi/tests/branch_action.rs | 2 + crates/sandlock-ffi/tests/c_smoke.rs | 54 +++++++++++++++ crates/sandlock-ffi/tests/changes.rs | 2 + crates/sandlock-ffi/tests/defer.rs | 2 + crates/sandlock-ffi/tests/size_time_spec.rs | 1 + crates/sandlock-oci/Cargo.toml | 5 +- docs/building.md | 68 +++++++++++++++++++ 30 files changed, 364 insertions(+), 101 deletions(-) delete mode 100644 .github/workflows/features.yml create mode 100644 crates/sandlock-core/tests/features.rs create mode 100644 docs/building.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75b720726..9e2b7569f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,31 +41,48 @@ jobs: timeout-minutes: 20 run: cargo test --release --workspace --no-fail-fast -- --test-threads=1 - rust-minimal: - name: Rust without optional features + rust-features: + name: Rust features (${{ matrix.feature }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + feature: [minimal, http, checkpoint, image, profiles, cow] steps: - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: features-${{ matrix.feature }} - name: Allow unprivileged user namespaces run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - - name: Minimal build pulls in no HTTP stack + - name: Check production dependency graph + env: + FEATURE: ${{ matrix.feature }} run: | - if cargo tree --workspace --no-default-features -e normal --prefix none \ - | grep -E '^(hyper|rustls|rcgen|bollard) '; then - exit 1 - fi - - - name: Run all tests + args=(--no-default-features) + if [ "$FEATURE" != minimal ]; then args+=(--features "$FEATURE"); fi + cargo tree --workspace "${args[@]}" -e normal --prefix none > /tmp/dependencies.txt + case "$FEATURE" in + minimal) forbidden='bincode|toml|tar|flate2|ruzstd|base64|walkdir|pathdiff|ring|hyper|rustls|rcgen|bollard' ;; + http) forbidden='bincode|toml|tar|flate2|ruzstd|walkdir|pathdiff|bollard' ;; + checkpoint) forbidden='toml|tar|flate2|ruzstd|walkdir|pathdiff|ring|hyper|rustls|rcgen|bollard' ;; + image) forbidden='bincode|toml|rcgen' ;; + profiles) forbidden='bincode|tar|flate2|ruzstd|walkdir|pathdiff|ring|hyper|rustls|rcgen|bollard' ;; + cow) forbidden='bincode|toml|tar|flate2|ruzstd|hyper|rustls|rcgen|bollard' ;; + esac + if grep -E "^($forbidden) " /tmp/dependencies.txt; then exit 1; fi + - name: Test selected features timeout-minutes: 20 - run: cargo test --release --workspace --no-default-features --no-fail-fast -- --test-threads=1 + env: + FEATURE: ${{ matrix.feature }} + run: | + case "$FEATURE" in + minimal) args=(--no-default-features) ;; + *) args=(--no-default-features --features "$FEATURE") ;; + esac + cargo test --release --workspace "${args[@]}" --no-fail-fast -- --test-threads=1 + rust-low-abi: name: Rust low-ABI (ubuntu-22.04) diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml deleted file mode 100644 index e0615c910..000000000 --- a/.github/workflows/features.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Optional features - -on: - push: - branches: [optional-core-features] - pull_request: - branches: [main] - workflow_dispatch: - -jobs: - feature: - name: Rust features (${{ matrix.feature }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - feature: [minimal, checkpoint, image, profiles, default] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: features-${{ matrix.feature }} - - name: Allow unprivileged user namespaces - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Check minimal dependency graph - if: matrix.feature == 'minimal' - run: | - if cargo tree --workspace --no-default-features -e normal --prefix none \ - | grep -E '^(toml|bincode|tar|flate2|ruzstd|base64|hyper|rustls|rcgen|bollard) '; then - exit 1 - fi - - name: Test selected features - timeout-minutes: 20 - env: - FEATURE: ${{ matrix.feature }} - run: | - case "$FEATURE" in - minimal) args=(--no-default-features) ;; - default) args=() ;; - *) args=(--no-default-features --features "$FEATURE") ;; - esac - cargo test --release --workspace "${args[@]}" --no-fail-fast -- --test-threads=1 diff --git a/README.md b/README.md index c38eb50b6..57e766cf0 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,19 @@ cd python && pip install -e . cargo install --path crates/sandlock-cli ``` +### Optional features + +All five features (`http`, `checkpoint`, `image`, `profiles`, `cow`) are enabled +by default. Build with none or select the ones you need: + +```bash +cargo build --release --no-default-features +cargo build --release --no-default-features --features http,profiles +``` + +See [Building Sandlock](docs/building.md) for feature dependencies and Rust +library configuration. + ## Quick Start ### CLI @@ -181,6 +194,7 @@ confine(&confinement)?; | Document | Contents | |---|---| +| [`docs/building.md`](docs/building.md) | Build commands, optional features, and minimal Rust embedding | | [`docs/cli.md`](docs/cli.md) | Every `sandlock run` option by example, profiles, `ps` / `inspect` / `kill` | | [`docs/sandbox-reference.md`](docs/sandbox-reference.md) | Every `Sandbox` field, default, and TOML key; credentials; protection opt-out | | [`docs/network.md`](docs/network.md) | Endpoint grammar, protocol gating, HTTP interception, bind rules, port virtualization | @@ -216,20 +230,3 @@ cargo test --release # Python tests cd python && pip install -e . && pytest tests/ ``` - -### Optional features - -Checkpoint capture, restore, and persistence are controlled by the `checkpoint` -Cargo feature, enabled by default. Disable default features to omit checkpoint -support and its `bincode` dependency. The CLI, FFI, and OCI crates forward this -feature to `sandlock-core`. FFI checkpoint entry points remain available and -return their documented failure values when checkpoint support is disabled. - -The `image` feature enables container image loading and is enabled by default. -Remote registry and Docker daemon transports additionally require `http`; local -OCI layouts and archives work without it. Disabled image requests through the -CLI or FFI return an error. - -The `profiles` feature enables TOML profile loading, writing, and CLI learning. -It is enabled by default. The profile schema and JSON policy inspection remain -available without it; TOML operations return a feature-disabled error. diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 2cc509b39..080a0dd0c 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -13,10 +13,11 @@ name = "sandlock" path = "src/main.rs" [features] +cow = ["sandlock-core/cow"] profiles = ["sandlock-core/profiles", "dep:toml"] -image = ["sandlock-core/image"] +image = ["cow", "sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint", "image", "profiles"] +default = ["http", "checkpoint", "image", "profiles", "cow"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 33b82f344..08b4c0833 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -5,7 +5,7 @@ use sandlock_core::profile; use anyhow::{Result, anyhow}; use std::path::PathBuf; -#[cfg(feature = "profiles")] +#[cfg(all(feature = "profiles", feature = "cow"))] mod learn; #[derive(Parser)] #[command(name = "sandlock", about = "Lightweight process sandbox", version)] @@ -419,10 +419,10 @@ async fn main() -> Result<()> { } Command::Learn(args) => { - #[cfg(feature = "profiles")] + #[cfg(all(feature = "profiles", feature = "cow"))] learn::run(args).await?; - #[cfg(not(feature = "profiles"))] - return Err(anyhow!("learning profiles requires the profiles feature")); + #[cfg(not(all(feature = "profiles", feature = "cow")))] + return Err(anyhow!("learning profiles requires the profiles and cow features")); } Command::Profile { action } => { diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index e63a2ff18..06411b0a0 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -278,6 +278,7 @@ fn test_no_supervisor_exit_code() { /// stayed orphaned in `/tmp/sandlock-cow-*/upper/` and never appeared /// in the workdir, even though the default `on_exit` is `commit`. #[test] +#[cfg(feature = "cow")] fn test_cow_commit_runs_on_cli_exit() { let workdir = tempfile::tempdir().expect("tempdir"); let sentinel = workdir.path().join("sentinel.txt"); @@ -367,7 +368,7 @@ fn test_uid_mapping_arbitrary_uid() { /// `sandlock run --profile-file` prints a hint to stderr suggesting /// `sandlock learn --merge` when the sandboxed process exits non-zero. #[test] -#[cfg(feature = "profiles")] +#[cfg(all(feature = "profiles", feature = "cow"))] fn test_run_denial_hint_on_nonzero_exit() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); let profile_path = profile.path().to_str().unwrap().to_owned(); @@ -699,6 +700,7 @@ fn test_help_shows_ps_and_inspect() { /// `--dry-run` is a run with both branch actions forced to `abort`: the /// change list is reported on stderr and nothing lands in the workdir. #[test] +#[cfg(feature = "cow")] fn test_dry_run_reports_changes_and_writes_nothing() { let workdir = tempfile::tempdir().expect("tempdir"); let sentinel = workdir.path().join("planned.txt"); diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index da76f744c..5e587d920 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "profiles")] +#![cfg(all(feature = "profiles", feature = "cow"))] // Each test here spawns a full `sandlock` process. // Running all tests in parallel exhausts kernel resources on most hosts. diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index e527ca929..5b1966d5a 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "profiles")] +#![cfg(all(feature = "profiles", feature = "cow"))] // Each test here spawns a full `sandlock` process. // Running all tests in parallel exhausts kernel resources on most hosts. diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index 5f46ac231..fb158c9e5 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -21,11 +21,11 @@ goblin = "0.9" uuid = { version = "1", features = ["v4"] } bincode = { version = "1", optional = true } serde_json = "1" -walkdir = "2" -ring = "0.17" +walkdir = { version = "2", optional = true } +ring = { version = "0.17", optional = true } toml = { version = "0.8", optional = true } jiff = "0.2" -pathdiff = "0.2" +pathdiff = { version = "0.2", optional = true } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"], optional = true } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"], optional = true } rcgen = { version = "0.13", features = ["pem", "x509-parser"], optional = true } @@ -42,10 +42,11 @@ bollard = { version = "0.21", optional = true } futures-util = { version = "0.3", optional = true } [features] +cow = ["dep:walkdir", "dep:pathdiff", "dep:ring"] profiles = ["dep:toml"] -image = ["dep:tar", "dep:flate2", "dep:ruzstd", "dep:base64", "dep:bollard", "dep:futures-util"] +image = ["cow", "dep:tar", "dep:flate2", "dep:ruzstd", "dep:base64", "dep:bollard", "dep:futures-util"] checkpoint = ["dep:bincode"] -default = ["http", "checkpoint", "image", "profiles"] +default = ["http", "checkpoint", "image", "profiles", "cow"] cli = ["dep:clap"] http = [ "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:hyper-rustls", diff --git a/crates/sandlock-core/src/chroot/dispatch.rs b/crates/sandlock-core/src/chroot/dispatch.rs index 55bef5b14..f26478efe 100644 --- a/crates/sandlock-core/src/chroot/dispatch.rs +++ b/crates/sandlock-core/src/chroot/dispatch.rs @@ -481,7 +481,9 @@ async fn cow_resolve( cow_state: &Arc>, host_path: &Path, ) -> Result { + #[cfg(feature = "cow")] let cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(ref cow) = cs.branch { let host_str = host_path.to_string_lossy(); if cow.matches(&host_str) { @@ -616,7 +618,9 @@ pub(crate) async fn handle_chroot_open( // A symlink in the image is followed inside that layer's root, never on // the host, because the supervisor holds no chroot of its own. { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let host_str = host_path.to_string_lossy(); if cow.matches(&host_str) { @@ -1303,7 +1307,9 @@ pub(crate) async fn handle_chroot_write( let is_dir = (notif.data.args[2] & libc::AT_REMOVEDIR as u64) != 0; { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let s = host_path.to_string_lossy(); if cow.matches(&s) { @@ -1330,7 +1336,9 @@ pub(crate) async fn handle_chroot_write( let mode = notif.data.args[2] as u32; { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let s = host_path.to_string_lossy(); if cow.matches(&s) { @@ -1372,7 +1380,9 @@ pub(crate) async fn handle_chroot_write( } { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let old_str = old_host.to_string_lossy(); if cow.matches(&old_str) { @@ -1411,7 +1421,9 @@ pub(crate) async fn handle_chroot_write( if !ctx.can_write(&link_vp) { return NotifAction::Errno(libc::EACCES); } { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let s = host_link.to_string_lossy(); if cow.matches(&s) { @@ -1494,7 +1506,9 @@ pub(crate) async fn handle_chroot_write( } { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let old_s = old_host.to_string_lossy(); let new_s = new_host.to_string_lossy(); @@ -1554,7 +1568,9 @@ pub(crate) async fn handle_chroot_write( let mode = (notif.data.args[2] & 0o7777) as u32; { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let s = host_path.to_string_lossy(); if cow.matches(&s) { @@ -1585,7 +1601,9 @@ pub(crate) async fn handle_chroot_write( let gid = notif.data.args[3] as u32; { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let s = host_path.to_string_lossy(); if cow.matches(&s) { @@ -1618,7 +1636,9 @@ pub(crate) async fn handle_chroot_write( let length = notif.data.args[1] as i64; { + #[cfg(feature = "cow")] let mut cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_mut() { let s = host_path.to_string_lossy(); if cow.matches(&s) { @@ -1902,7 +1922,9 @@ pub(crate) async fn handle_chroot_readlink( // COW { + #[cfg(feature = "cow")] let cs = cow_state.lock().await; + #[cfg(feature = "cow")] if let Some(cow) = cs.branch.as_ref() { let host_str = host_path.to_string_lossy(); if cow.matches(&host_str) { diff --git a/crates/sandlock-core/src/context/tests.rs b/crates/sandlock-core/src/context/tests.rs index 845d46ce7..2e5e15c88 100644 --- a/crates/sandlock-core/src/context/tests.rs +++ b/crates/sandlock-core/src/context/tests.rs @@ -137,7 +137,11 @@ fn test_notif_syscalls_faccessat2() { assert!(nrs.contains(&(arch::SYS_FACCESSAT2 as u32)), "chroot notif filter must include SYS_faccessat2 (439)"); - // COW mode +} + +#[cfg(feature = "cow")] +#[test] +fn test_notif_syscalls_faccessat2_cow() { let policy = Sandbox::builder() .workdir("/tmp") .build() diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 21f90ac0d..f43610c46 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -23,7 +23,9 @@ pub(crate) mod seccomp_plan; pub(crate) mod vdso; pub(crate) mod random; pub(crate) mod time; +#[cfg(feature = "cow")] pub(crate) mod cow; +#[cfg(feature = "cow")] pub mod recovery; #[cfg(feature = "checkpoint")] pub(crate) mod checkpoint; @@ -32,6 +34,7 @@ pub mod netlink; pub(crate) mod procfs; pub(crate) mod port_remap; pub mod pipeline; +#[cfg(feature = "cow")] pub mod transaction; pub mod policy_fn; #[cfg(feature = "image")] @@ -56,10 +59,12 @@ pub use sandbox::{ }; pub use result::{renames, Change, ChangeKind, Entry, EntryKind, ExitStatus, RunResult}; pub use pipeline::{Stage, Pipeline, Gather}; +#[cfg(feature = "cow")] pub use transaction::{AbortReason, Transaction, TxnDisposition, TxnError, TxnOutcome}; // Recovery of COW branch storage that was preserved rather than reclaimed. The // rest of `cow` is internal; the `recovery` module is the backend-neutral path // these belong to, and the flat aliases here are kept for convenience. +#[cfg(feature = "cow")] pub use recovery::{list_preserved, read_preserved, PreserveReason, PreservedBranch}; // Sectioned-profile parsing types: ProfileInput is the top-level deserialization // target; ProgramSpec carries [program].exec/args (not a Sandbox field). diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 49a51f54b..5d979cc74 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -1075,6 +1075,7 @@ mod tests { #[cfg(feature = "http")] #[test] + #[cfg(feature = "cow")] fn parse_profile_full_example() { let toml = r#" [config] diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 4bc879862..5c6360d28 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -306,6 +306,7 @@ struct Runtime { // Parent-held write end of a piped stdin (popen). The caller takes it via // `Process::take_stdin`; closing it signals EOF to the child. _stdin_write: Option, + #[cfg(feature = "cow")] seccomp_cow: Option, supervisor_resource: Option>>, supervisor_cow: Option>>, @@ -326,6 +327,7 @@ struct Runtime { /// reuses this `CowState` (instead of building its own branch), and neither /// `wait()` nor `Drop` take/commit/abort the branch — the transaction /// coordinator owns the single commit/abort. See [`SharedCow`]. + #[cfg(feature = "cow")] shared_cow: Option, // The interactive child took the terminal's foreground process group at // spawn; whoever reaps it must hand the foreground back to this process. @@ -337,6 +339,7 @@ struct Runtime { /// `Runtime` so sequential stages accumulate writes in the same upper /// (read-committed), while the coordinator retains the original to commit/abort /// once at the end. +#[cfg(feature = "cow")] #[derive(Clone)] pub(crate) struct SharedCow { /// The shared supervisor COW state (holds the single `SeccompCowBranch`). @@ -1042,6 +1045,7 @@ impl Sandbox { /// set, and apply the exit action. `Defer` leaves the branch in place for /// [`Self::commit`] / [`Self::abort`], so a repeat call finds it again and /// only re-reads the changes. + #[cfg(feature = "cow")] async fn settle_branch(&mut self) -> Vec { // A transactional-pipeline stage leaves the branch in the shared COW // state for the next stage / the coordinator's single commit: taking it @@ -1083,6 +1087,7 @@ impl Sandbox { /// Whether a [`BranchAction::Defer`] run has exited and is waiting for /// [`Self::commit`] or [`Self::abort`]. + #[cfg(feature = "cow")] pub fn pending(&self) -> bool { self.runtime.as_ref().is_some_and(|rt| { rt.seccomp_cow.is_some() && matches!(rt.state, RuntimeState::Stopped(_)) @@ -1091,6 +1096,7 @@ impl Sandbox { /// The pending branch's upper directory, laid out like the workdir and /// holding the new bytes of every added or modified file. + #[cfg(feature = "cow")] pub fn upper_dir(&self) -> Option<&std::path::Path> { if !self.pending() { return None; } self.rt().seccomp_cow.as_ref().map(|b| b.upper_dir()) @@ -1100,15 +1106,17 @@ impl Sandbox { /// contended workdir; on timeout the branch is preserved and the error /// names it. Last-writer-wins against anything that changed the workdir /// since the run. + #[cfg(feature = "cow")] pub fn commit(&mut self) -> Result<(), crate::error::BranchError> { self.take_pending()?.commit() } /// Discard the pending branch. + #[cfg(feature = "cow")] pub fn abort(&mut self) -> Result<(), crate::error::BranchError> { self.take_pending()?.abort() } - + #[cfg(feature = "cow")] fn take_pending(&mut self) -> Result { if !self.pending() { return Err(crate::error::BranchError::Operation("no deferred branch to dispose".into())); @@ -1116,6 +1124,25 @@ impl Sandbox { Ok(self.rt_mut().seccomp_cow.take().expect("pending() checked seccomp_cow")) } + #[cfg(not(feature = "cow"))] + async fn settle_branch(&mut self) -> Vec { Vec::new() } + + #[cfg(not(feature = "cow"))] + pub fn pending(&self) -> bool { false } + + #[cfg(not(feature = "cow"))] + pub fn upper_dir(&self) -> Option<&std::path::Path> { None } + + #[cfg(not(feature = "cow"))] + pub fn commit(&mut self) -> Result<(), crate::error::BranchError> { + Err(crate::error::BranchError::Operation("COW support is disabled; enable the cow feature".into())) + } + + #[cfg(not(feature = "cow"))] + pub fn abort(&mut self) -> Result<(), crate::error::BranchError> { + Err(crate::error::BranchError::Operation("COW support is disabled; enable the cow feature".into())) + } + /// Join the capture-pipe drains, if this runtime still holds them. /// /// Only called once the child has been reaped, so the EOF each drain is @@ -1656,6 +1683,7 @@ impl Sandbox { stdout_drain: None, stderr_drain: None, _stdin_write: None, + #[cfg(feature = "cow")] seccomp_cow: None, supervisor_resource: None, supervisor_cow: None, @@ -1669,6 +1697,7 @@ impl Sandbox { on_bind: None, handlers: Vec::new(), ready_w: None, + #[cfg(feature = "cow")] shared_cow: None, tty_foreground_taken: false, control_handle: None, @@ -1761,6 +1790,7 @@ impl Sandbox { stdout_drain: None, stderr_drain: None, _stdin_write: None, + #[cfg(feature = "cow")] seccomp_cow: None, supervisor_resource: None, supervisor_cow: None, @@ -1774,6 +1804,7 @@ impl Sandbox { on_bind: None, handlers: Vec::new(), ready_w: None, + #[cfg(feature = "cow")] shared_cow: None, tty_foreground_taken: false, })); @@ -1784,6 +1815,7 @@ impl Sandbox { /// The stage reuses the shared upper instead of building its own, and leaves /// commit/abort to the transaction coordinator. Internal — used only by /// [`Transaction`](crate::transaction::Transaction). + #[cfg(feature = "cow")] pub(crate) fn set_shared_cow(&mut self, shared: SharedCow) -> Result<(), crate::error::SandlockError> { self.ensure_runtime()?; self.rt_mut().shared_cow = Some(shared); @@ -1818,6 +1850,14 @@ impl Sandbox { use crate::sys::syscall; use std::time::Duration; + #[cfg(not(feature = "cow"))] + if self.workdir.is_some() || self.fs_storage.is_some() || self.max_disk.is_some() + || self.on_exit != BranchAction::Commit || self.on_error != BranchAction::Commit + { + return Err(crate::error::SandboxError::FeatureDisabled { + what: "copy-on-write filesystem".into(), feature: "cow", + }.into()); + } self.ensure_runtime()?; if !matches!(self.rt().state, RuntimeState::Created) { @@ -1927,7 +1967,9 @@ impl Sandbox { // A transactional-pipeline stage reuses one shared upper across stages; // it must not build its own branch. Grant Landlock read+exec on the // shared upper so a binary created in the workdir stays executable. + #[cfg(feature = "cow")] let shared_cow = self.rt().shared_cow.clone(); + #[cfg(feature = "cow")] let seccomp_cow_branch = if let Some(ref shared) = shared_cow { self.fs_readable.push(shared.upper_dir.clone()); self.cow_upper = Some(shared.upper_dir.clone()); @@ -2269,10 +2311,12 @@ impl Sandbox { res_state.proc_count = 1; let mut cow_state = CowState::new(); - cow_state.branch = seccomp_cow_branch; + #[cfg(feature = "cow")] + { cow_state.branch = seccomp_cow_branch; } // A shared transactional-pipeline branch overrides the per-stage one: // reuse the coordinator's single COW state so every stage writes into // (and reads through) the same upper. + #[cfg(feature = "cow")] let shared_cow_state = shared_cow.as_ref().map(|s| Arc::clone(&s.state)); let mut policy_fn_state = PolicyFnState::new(); @@ -2323,10 +2367,13 @@ impl Sandbox { let res_state = Arc::new(tokio::sync::Mutex::new(res_state)); self.rt_mut().supervisor_resource = Some(Arc::clone(&res_state)); + #[cfg(feature = "cow")] let cow_state = match shared_cow_state { Some(shared) => shared, None => Arc::new(tokio::sync::Mutex::new(cow_state)), }; + #[cfg(not(feature = "cow"))] + let cow_state = Arc::new(tokio::sync::Mutex::new(cow_state)); self.rt_mut().supervisor_cow = Some(Arc::clone(&cow_state)); let net_state = Arc::new(tokio::sync::Mutex::new(net_state)); @@ -2588,6 +2635,7 @@ impl Drop for Sandbox { // deferred branch nobody decided on, or one a cancelled wait() never // reached. let action = self.branch_action(); + #[cfg(feature = "cow")] if let Some(cow) = self.runtime.as_mut().and_then(|rt| rt.seccomp_cow.as_mut()) { match action { // NOTE: commit() is synchronous and blocks up to diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 4270bbebc..6a945ff2c 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -878,6 +878,16 @@ impl SandboxBuilder { )); } + #[cfg(not(feature = "cow"))] + if self.workdir.is_some() || self.fs_storage.is_some() || self.max_disk.is_some() + || self.on_exit.as_ref().is_some_and(|a| *a != BranchAction::Commit) + || self.on_error.as_ref().is_some_and(|a| *a != BranchAction::Commit) + { + return Err(SandboxError::FeatureDisabled { + what: "copy-on-write filesystem".into(), feature: "cow", + }); + } + // An image rootfs is a cache entry every sandbox of that image shares, // so, like the read-only layers under a Docker container, it may only // be the lower side of a copy-on-write branch that is thrown away. @@ -1248,6 +1258,7 @@ mod tests { use std::path::PathBuf; #[test] + #[cfg(feature = "cow")] fn spec_setters_take_the_core_grammar() { let sb = super::SandboxBuilder::default() .max_memory_spec("512M") diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index 45024f78e..b24c689f5 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -555,6 +555,7 @@ pub(crate) fn build_dispatch_table( // ------------------------------------------------------------------ // COW filesystem interception // ------------------------------------------------------------------ + #[cfg(feature = "cow")] if policy.cow_enabled { register_cow_handlers(&mut table, ctx); } @@ -999,7 +1000,7 @@ fn register_chroot_handlers( // ============================================================ // COW handler registration // ============================================================ - +#[cfg(feature = "cow")] fn register_cow_handlers(table: &mut DispatchTable, ctx: &Arc) { // Helper that captures `ctx.cow` and `ctx.processes` once at table-build // time, then re-clones the per-handler `Arc`s on each invocation. diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index e2db20940..d03c17e57 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -499,12 +499,16 @@ impl Default for ProcessIndex { /// lives in `PerProcessState`. pub struct CowState { /// Seccomp-based COW branch (None if COW disabled). + #[cfg(feature = "cow")] pub branch: Option, } impl CowState { pub fn new() -> Self { - Self { branch: None } + Self { + #[cfg(feature = "cow")] + branch: None, + } } } diff --git a/crates/sandlock-core/tests/features.rs b/crates/sandlock-core/tests/features.rs new file mode 100644 index 000000000..19b5f1bf2 --- /dev/null +++ b/crates/sandlock-core/tests/features.rs @@ -0,0 +1,48 @@ +use sandlock_core::Sandbox; + +#[test] +fn json_policy_inspection_is_available() { + let sandbox = Sandbox::builder().fs_read("/usr").build_unchecked().unwrap(); + let json = sandlock_core::profile::sandbox_to_json(&sandbox, &[]).unwrap(); + assert!(json.contains("/usr")); +} + +#[cfg(not(feature = "profiles"))] +#[test] +fn toml_profiles_require_the_feature() { + let error = sandlock_core::profile::parse_profile("[filesystem]\nread = ['/usr']") + .unwrap_err(); + assert!(matches!(error, sandlock_core::SandlockError::Sandbox( + sandlock_core::error::SandboxError::FeatureDisabled { feature: "profiles", .. } + ))); +} + +#[cfg(not(feature = "cow"))] +#[test] +fn cow_configuration_requires_the_feature() { + use sandlock_core::sandbox::{BranchAction, ByteSize}; + for builder in [ + Sandbox::builder().workdir("/tmp"), + Sandbox::builder().fs_storage("/tmp"), + Sandbox::builder().max_disk(ByteSize(1024)), + Sandbox::builder().on_exit(BranchAction::Abort), + Sandbox::builder().on_error(BranchAction::Keep), + ] { + assert!(matches!(builder.build_unchecked(), Err( + sandlock_core::error::SandboxError::FeatureDisabled { feature: "cow", .. } + ))); + } +} + +#[cfg(not(feature = "cow"))] +#[tokio::test] +async fn deserialized_cow_policy_is_rejected_before_spawn() { + let sandbox = Sandbox::builder().build_unchecked().unwrap(); + let mut json = serde_json::to_value(&sandbox).unwrap(); + json["workdir"] = serde_json::json!("/tmp"); + let mut sandbox: Sandbox = serde_json::from_value(json).unwrap(); + let error = sandbox.create_interactive(&["/bin/true"]).await.err().unwrap(); + assert!(matches!(error, sandlock_core::SandlockError::Sandbox( + sandlock_core::error::SandboxError::FeatureDisabled { feature: "cow", .. } + ))); +} diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 94fb67e73..c24900c49 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -7,6 +7,7 @@ mod test_sandbox; #[path = "integration/test_determinism.rs"] mod test_determinism; +#[cfg(feature = "cow")] #[path = "integration/test_cow.rs"] mod test_cow; @@ -35,6 +36,7 @@ mod test_landlock; #[path = "integration/test_pipeline.rs"] mod test_pipeline; +#[cfg(feature = "cow")] #[path = "integration/test_transaction.rs"] mod test_transaction; @@ -56,6 +58,7 @@ mod test_user_mapping; #[path = "integration/test_chroot.rs"] mod test_chroot; +#[cfg(feature = "cow")] #[path = "integration/test_branch_action.rs"] mod test_branch_action; diff --git a/crates/sandlock-core/tests/integration/test_chroot.rs b/crates/sandlock-core/tests/integration/test_chroot.rs index bf6f2ddf0..a24a21d95 100644 --- a/crates/sandlock-core/tests/integration/test_chroot.rs +++ b/crates/sandlock-core/tests/integration/test_chroot.rs @@ -980,6 +980,7 @@ async fn test_chroot_write_file() { /// Directory opens under chroot + COW must stay inside the chroot. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_cow_directory_open_stays_in_rootfs() { let rootfs = build_test_rootfs("cow-dir-open"); let tmp_dir = rootfs.join("tmp"); @@ -1034,6 +1035,7 @@ async fn test_chroot_cow_directory_open_stays_in_rootfs() { /// chroot + COW with BranchAction::Abort discards writes #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_with_cow() { let rootfs = build_test_rootfs("cow"); let tmp_dir = rootfs.join("tmp"); @@ -1085,6 +1087,7 @@ async fn test_chroot_with_cow() { /// `open(O_RDONLY)` with no preceding `stat` (rootfs-helper.c), so it drives the /// open path rather than short-circuiting on the (correct) stat ENOENT. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_cow_read_deleted_file_is_enoent() { let rootfs = build_test_rootfs("cow-read-deleted"); let tmp_dir = rootfs.join("tmp"); @@ -2028,6 +2031,7 @@ async fn test_chroot_hardlink_follow_cannot_alias_a_denied_path_under_a_mount() /// pristine workdir instead. EXDEV is the kernel's own word for a link that /// cannot span two sides. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_hardlink_into_a_branch_is_refused() { let rootfs = build_test_rootfs("hardlink-branch-in"); let tmp_dir = rootfs.join("tmp"); @@ -2087,6 +2091,7 @@ async fn test_chroot_hardlink_into_a_branch_is_refused() { /// aliases the *lower* inode, so a write through it edits the very file the /// branch promised to leave alone, and the edit outlives the abort. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_hardlink_out_of_a_branch_is_refused() { let rootfs = build_test_rootfs("hardlink-branch-out"); let tmp_dir = rootfs.join("tmp"); @@ -2145,6 +2150,7 @@ async fn test_chroot_hardlink_out_of_a_branch_is_refused() { /// keep working, and it must stay in the branch: after an abort neither the /// second name nor the copy of the first is left in the workdir. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_hardlink_within_a_branch_stays_in_the_branch() { let rootfs = build_test_rootfs("hardlink-branch-within"); let tmp_dir = rootfs.join("tmp"); @@ -2201,6 +2207,7 @@ async fn test_chroot_hardlink_within_a_branch_stays_in_the_branch() { /// disk with its pre-delete bytes. Linking it must answer ENOENT rather than /// resurrect it, which is the same rule the read-open path already follows. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_hardlink_to_a_file_deleted_in_the_branch_is_enoent() { let rootfs = build_test_rootfs("hardlink-branch-whiteout"); let tmp_dir = rootfs.join("tmp"); @@ -2301,6 +2308,7 @@ async fn test_chroot_fchmodat2_resolves_in_rootfs() { /// of the rootfs: an image could read and write any host file the supervisor /// can, and reach the host's /proc. #[tokio::test] +#[cfg(feature = "cow")] async fn test_chroot_cow_symlink_stays_inside_the_rootfs() { let rootfs = build_test_rootfs("cow-symlink"); let host_dir = temp_dir("cow-symlink-host"); diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 4708cfeb4..1dcd531b3 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -170,6 +170,7 @@ fn test_icmp_default_denied() { } #[test] +#[cfg(feature = "cow")] fn test_branch_action_defaults() { let p = Sandbox::builder() .workdir("/tmp") diff --git a/crates/sandlock-core/tests/integration/test_procfs.rs b/crates/sandlock-core/tests/integration/test_procfs.rs index ca130aa9a..8e79b8891 100644 --- a/crates/sandlock-core/tests/integration/test_procfs.rs +++ b/crates/sandlock-core/tests/integration/test_procfs.rs @@ -1126,6 +1126,7 @@ print('OK') } #[tokio::test] +#[cfg(feature = "cow")] async fn test_proc_net_chroot_and_cow_aliases() { let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("proc")).unwrap(); diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index dbc53d4a2..c1eaac1cc 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -12,10 +12,11 @@ description = "C ABI for sandlock process sandbox" crate-type = ["cdylib", "staticlib", "rlib"] [features] +cow = ["sandlock-core/cow"] profiles = ["sandlock-core/profiles"] -image = ["sandlock-core/image"] +image = ["cow", "sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint", "image", "profiles"] +default = ["http", "checkpoint", "image", "profiles", "cow"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-ffi/tests/branch_action.rs b/crates/sandlock-ffi/tests/branch_action.rs index 8d786793b..0858f1d8b 100644 --- a/crates/sandlock-ffi/tests/branch_action.rs +++ b/crates/sandlock-ffi/tests/branch_action.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "cow")] + //! C ABI `BranchAction` setters: discriminants map onto the Rust enum, and //! any other value fails the build instead of picking an action. diff --git a/crates/sandlock-ffi/tests/c_smoke.rs b/crates/sandlock-ffi/tests/c_smoke.rs index 51d339133..b3c325021 100644 --- a/crates/sandlock-ffi/tests/c_smoke.rs +++ b/crates/sandlock-ffi/tests/c_smoke.rs @@ -2,6 +2,8 @@ #[test] fn c_smoke_compiles_and_runs() { + use std::ffi::{CStr, CString}; + use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; use std::process::Command; @@ -22,6 +24,16 @@ fn c_smoke_compiles_and_runs() { // the outer build lock is released before tests run. let mut build = Command::new(env!("CARGO")); build.args(["build", "-p", "sandlock-ffi", "--lib"]); + build.arg("--no-default-features"); + for (name, enabled) in [ + ("http", cfg!(feature = "http")), + ("checkpoint", cfg!(feature = "checkpoint")), + ("image", cfg!(feature = "image")), + ("profiles", cfg!(feature = "profiles")), + ("cow", cfg!(feature = "cow")), + ] { + if enabled { build.args(["--features", name]); } + } if profile == "release" { build.arg("--release"); } @@ -51,6 +63,48 @@ fn c_smoke_compiles_and_runs() { }) .expect("libsandlock_ffi cdylib should exist in target output"); + struct Library(*mut libc::c_void); + impl Drop for Library { + fn drop(&mut self) { + unsafe { libc::dlclose(self.0) }; + } + } + + let library_path = CString::new( + cdylib_dir.join("libsandlock_ffi.so").as_os_str().as_bytes(), + ).expect("library path has no NUL bytes"); + let handle = unsafe { libc::dlopen(library_path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) }; + if handle.is_null() { + let error = unsafe { libc::dlerror() }; + let message = if error.is_null() { + "unknown loader error".into() + } else { + unsafe { CStr::from_ptr(error) }.to_string_lossy().into_owned() + }; + panic!("failed to load FFI library: {message}"); + } + let library = Library(handle); + for (symbol, enabled) in [ + ("sandlock_sandbox_builder_image", cfg!(feature = "image")), + ("sandlock_image_pull", cfg!(feature = "image")), + ("sandlock_handle_checkpoint", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_save", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_load", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_set_name", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_name", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_set_app_state", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_app_state", cfg!(feature = "checkpoint")), + ("sandlock_checkpoint_free", cfg!(feature = "checkpoint")), + ("sandlock_restore_interactive", cfg!(feature = "checkpoint")), + ("sandlock_handle_restore_skipped_len", cfg!(feature = "checkpoint")), + ("sandlock_handle_restore_skipped_fd", cfg!(feature = "checkpoint")), + ("sandlock_handle_restore_skipped_path", cfg!(feature = "checkpoint")), + ] { + let name = CString::new(symbol).unwrap(); + let exported = !unsafe { libc::dlsym(library.0, name.as_ptr()) }.is_null(); + assert_eq!(exported, enabled, "unexpected export state for {symbol}"); + } + let rpath_arg = format!("-Wl,-rpath,{}", cdylib_dir.to_str().unwrap()); let status = Command::new("cc") diff --git a/crates/sandlock-ffi/tests/changes.rs b/crates/sandlock-ffi/tests/changes.rs index e8c798d80..562d7ce0b 100644 --- a/crates/sandlock-ffi/tests/changes.rs +++ b/crates/sandlock-ffi/tests/changes.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "cow")] + //! The C ABI side of a change set: both sides of every change are //! readable as plain scalars, plus a symlink target string. diff --git a/crates/sandlock-ffi/tests/defer.rs b/crates/sandlock-ffi/tests/defer.rs index f33e0030a..381c1c451 100644 --- a/crates/sandlock-ffi/tests/defer.rs +++ b/crates/sandlock-ffi/tests/defer.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "cow")] + //! The C ABI side of `BranchAction::Defer`: a handle that outlives its wait //! holds the change set until `sandlock_handle_commit` / `_abort`. diff --git a/crates/sandlock-ffi/tests/size_time_spec.rs b/crates/sandlock-ffi/tests/size_time_spec.rs index 9b080466f..d99b63f40 100644 --- a/crates/sandlock-ffi/tests/size_time_spec.rs +++ b/crates/sandlock-ffi/tests/size_time_spec.rs @@ -27,6 +27,7 @@ fn build_err(setter: Setter, value: &str) -> String { } #[test] +#[cfg(feature = "cow")] fn setters_take_the_core_spellings() { let mem = CString::new("512M").unwrap(); let disk = CString::new("1G").unwrap(); diff --git a/crates/sandlock-oci/Cargo.toml b/crates/sandlock-oci/Cargo.toml index 2858a01ae..052de5ec5 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -17,10 +17,11 @@ name = "sandlock-oci" path = "src/main.rs" [features] +cow = ["sandlock-core/cow"] profiles = ["sandlock-core/profiles"] -image = ["sandlock-core/image"] +image = ["cow", "sandlock-core/image"] checkpoint = ["sandlock-core/checkpoint"] -default = ["http", "checkpoint", "image", "profiles"] +default = ["http", "checkpoint", "image", "profiles", "cow"] http = ["sandlock-core/http"] [dependencies] diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 000000000..7cf15804f --- /dev/null +++ b/docs/building.md @@ -0,0 +1,68 @@ +# Building Sandlock + +Run these commands from the repository root. See the +[requirements](../README.md#requirements) for the supported platform. + +## Selecting features + +The workspace enables `http`, `checkpoint`, `image`, `profiles`, and `cow` by +default. The CLI, FFI, and OCI crates forward these features to `sandlock-core`. + +```bash +# All default features +cargo build --release + +# No optional features +cargo build --release --no-default-features + +# Selected features +cargo build --release --no-default-features --features http,profiles + +# Build just the FFI library with checkpoint support +cargo build --release -p sandlock-ffi --no-default-features --features checkpoint +``` + +Disabling defaults retains Landlock confinement, seccomp supervision, process +tracking, ordinary filesystem access and network policy enforcement. + +## Feature behavior + +| Feature | Enables | Dependencies and behavior when disabled | +|---------|---------|----------------------------------------| +| `http` | HTTP interception, HTTP access rules and TLS handling | HTTP operations require this feature; ordinary network policy remains available. | +| `checkpoint` | Checkpoint capture, restore and persistence | Enables `bincode` and compilation of the C restore stub. Rust checkpoint APIs and OCI checkpoint commands are omitted when disabled. | +| `image` | Container image extraction and loading | Enables `cow` to protect cached image contents. Registry and Docker daemon transports also require `http`; local OCI layouts and archives work without it. | +| `profiles` | TOML profile loading and writing | Enables TOML support. The profile schema and JSON policy inspection remain available without it. TOML operations return a feature-disabled error. | +| `cow` | Filesystem branches, commit/abort, transactions and recovery | Enables filesystem traversal and hashing dependencies. Requests for a workdir, branch storage, disk limits or nondefault branch actions fail explicitly when disabled. | + +CLI learning requires both `profiles` and `cow`: it observes writes in an +isolated layer and discards them afterward. Enabling `profiles` alone supports +TOML configuration without enabling learning. + +Checkpoint and image C entry points are compiled and exported only when their +respective features are enabled. The shipped C header describes the default +build with all features; applications linking a reduced library must avoid +entry points for disabled features. CLI image requests fail explicitly when +image support is disabled. Serialized policy fields are retained +across feature configurations; COW configuration is validated again before a +sandbox starts. + +## Embedding the Rust library + +Disable defaults on the library dependency for a minimal embedder: + +```toml +[dependencies] +sandlock-core = { version = "0.8", default-features = false } +``` + +Add selected features as needed: + +```toml +[dependencies] +sandlock-core = { version = "0.8", default-features = false, features = ["checkpoint"] } +``` + +Cargo combines features requested by dependencies. Another dependency that +enables Sandlock defaults can enable all five features in the resulting build. +The same applies when building multiple workspace crates together.