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/README.md b/README.md index 16d07e297..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 | diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index 02e10434a..080a0dd0c 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -13,7 +13,11 @@ name = "sandlock" path = "src/main.rs" [features] -default = ["http"] +cow = ["sandlock-core/cow"] +profiles = ["sandlock-core/profiles", "dep:toml"] +image = ["cow", "sandlock-core/image"] +checkpoint = ["sandlock-core/checkpoint"] +default = ["http", "checkpoint", "image", "profiles", "cow"] http = ["sandlock-core/http"] [dependencies] @@ -21,7 +25,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 4aff534a4..08b4c0833 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(all(feature = "profiles", feature = "cow"))] 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(all(feature = "profiles", feature = "cow"))] learn::run(args).await?; + #[cfg(not(all(feature = "profiles", feature = "cow")))] + return Err(anyhow!("learning profiles requires the profiles and cow features")); } Command::Profile { action } => { @@ -645,9 +654,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-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 5ff9f8758..06411b0a0 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"]) @@ -277,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"); @@ -366,6 +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(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(); @@ -585,6 +588,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); @@ -696,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 dfad4d675..5e587d920 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -1,3 +1,5 @@ +#![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. // 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..5b1966d5a 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -1,3 +1,5 @@ +#![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. // 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 d8365692a..fb158c9e5 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -19,13 +19,13 @@ 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" -toml = "0.8" +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 } @@ -33,23 +33,28 @@ 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] -default = ["http"] +cow = ["dep:walkdir", "dep:pathdiff", "dep:ring"] +profiles = ["dep:toml"] +image = ["cow", "dep:tar", "dep:flate2", "dep:ruzstd", "dep:base64", "dep:bollard", "dep:futures-util"] +checkpoint = ["dep:bincode"] +default = ["http", "checkpoint", "image", "profiles", "cow"] 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] +toml = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } tempfile = "3" rustls-pemfile = "2" 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/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 14c1c3bc4..f43610c46 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -23,16 +23,21 @@ 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; pub(crate) mod freeze; 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")] pub mod image; pub mod fork; pub(crate) mod ca_inject; @@ -46,6 +51,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::{ @@ -53,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 2a9c9a4c8..5d979cc74 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::*; @@ -1072,6 +1075,7 @@ mod tests { #[cfg(feature = "http")] #[test] + #[cfg(feature = "cow")] fn parse_profile_full_example() { let toml = r#" [config] @@ -1339,3 +1343,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/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 6d39cc503..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`). @@ -607,6 +610,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 +703,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(), } } @@ -1040,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 @@ -1081,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(_)) @@ -1089,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()) @@ -1098,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())); @@ -1114,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 @@ -1242,6 +1271,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 +1335,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 +1448,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 +1461,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 { @@ -1650,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, @@ -1663,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, @@ -1755,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, @@ -1768,6 +1804,7 @@ impl Sandbox { on_bind: None, handlers: Vec::new(), ready_w: None, + #[cfg(feature = "cow")] shared_cow: None, tty_foreground_taken: false, })); @@ -1778,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); @@ -1812,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) { @@ -1921,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()); @@ -2263,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(); @@ -2317,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)); @@ -2582,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 44bd072f7..6a945ff2c 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()); @@ -877,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. @@ -1147,6 +1158,7 @@ impl SandboxBuilder { init_fn: self.init_fn, work_fn: self.work_fn, runtime: None, + #[cfg(feature = "checkpoint")] restore_skipped: Vec::new(), }) } @@ -1246,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/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/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 c1ec3165c..c24900c49 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -7,9 +7,11 @@ mod test_sandbox; #[path = "integration/test_determinism.rs"] mod test_determinism; +#[cfg(feature = "cow")] #[path = "integration/test_cow.rs"] mod test_cow; +#[cfg(feature = "checkpoint")] #[path = "integration/test_checkpoint.rs"] mod test_checkpoint; @@ -34,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; @@ -55,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; @@ -79,6 +83,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_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_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-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-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..c1eaac1cc 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -12,7 +12,11 @@ description = "C ABI for sandlock process sandbox" crate-type = ["cdylib", "staticlib", "rlib"] [features] -default = ["http"] +cow = ["sandlock-core/cow"] +profiles = ["sandlock-core/profiles"] +image = ["cow", "sandlock-core/image"] +checkpoint = ["sandlock-core/checkpoint"] +default = ["http", "checkpoint", "image", "profiles", "cow"] http = ["sandlock-core/http"] [dependencies] diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index a5c8a6016..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, @@ -2609,6 +2610,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 +2622,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 +2642,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, @@ -2665,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, @@ -2761,6 +2766,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 +2785,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 +2801,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 +2817,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 +2839,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 +2864,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 +2895,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 +2944,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 +2960,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 +2981,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/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/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-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-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 f1802f3ee..052de5ec5 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -17,7 +17,11 @@ name = "sandlock-oci" path = "src/main.rs" [features] -default = ["http"] +cow = ["sandlock-core/cow"] +profiles = ["sandlock-core/profiles"] +image = ["cow", "sandlock-core/image"] +checkpoint = ["sandlock-core/checkpoint"] +default = ["http", "checkpoint", "image", "profiles", "cow"] 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)"); 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.