From 93809f1ebdf6d64a73432e33dc8bacead5287a69 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 8 Oct 2026 23:30:24 +0200 Subject: [PATCH 1/3] core: make HTTP support an optional feature More than half of sandlock-core's dependency graph (hyper, rustls, rcgen, bollard and what they pull in) exists only for the HTTP ACL proxy, credential injection and pulling images from a registry or the Docker daemon. Users who only need the syscall sandbox want to build without it, the way a kernel config drops subsystems it does not use. The http feature is on by default, so nothing changes unless a build opts out. Without it, a stub module stands in for the proxy and for credential injection, so call sites need no cfg, and the builder refuses any HTTP option rather than dropping a network restriction without a word. Networked image references fail with the same error, while oci: and oci-archive: images keep working. Signed-off-by: Cong Wang --- crates/sandlock-core/Cargo.toml | 24 ++++++----- crates/sandlock-core/src/checkpoint/image.rs | 2 + .../sandlock-core/src/credential_disabled.rs | 17 ++++++++ crates/sandlock-core/src/error.rs | 3 ++ crates/sandlock-core/src/image/mod.rs | 19 +++++++++ crates/sandlock-core/src/image/oci.rs | 3 ++ crates/sandlock-core/src/landlock.rs | 3 ++ crates/sandlock-core/src/lib.rs | 8 ++++ crates/sandlock-core/src/profile.rs | 6 +++ crates/sandlock-core/src/sandbox/builder.rs | 19 +++++++++ crates/sandlock-core/src/sandbox/tests.rs | 23 ++++++++++ .../src/transparent_proxy/disabled.rs | 42 +++++++++++++++++++ crates/sandlock-core/tests/integration.rs | 3 ++ .../tests/integration/test_network.rs | 1 + 14 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 crates/sandlock-core/src/credential_disabled.rs create mode 100644 crates/sandlock-core/src/transparent_proxy/disabled.rs diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index c290e0130..d8365692a 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -26,24 +26,28 @@ ring = "0.17" toml = "0.8" jiff = "0.2" pathdiff = "0.2" -tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } -rcgen = { version = "0.13", features = ["pem", "x509-parser"] } -hyper = { version = "1", features = ["server", "client", "http1"] } -hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } -http-body-util = "0.1" -hyper-rustls = { version = "0.27", default-features = false, features = ["ring", "native-tokio", "http1", "tls12", "logging"] } +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 } +hyper = { version = "1", features = ["server", "client", "http1"], optional = true } +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" clap = { version = "4", features = ["derive"], optional = true } -bollard = "0.21" -futures-util = "0.3" +bollard = { version = "0.21", optional = true } +futures-util = { version = "0.3", optional = true } [features] -default = [] +default = ["http"] 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", +] [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/crates/sandlock-core/src/checkpoint/image.rs b/crates/sandlock-core/src/checkpoint/image.rs index f84fde5ca..d09259cc6 100644 --- a/crates/sandlock-core/src/checkpoint/image.rs +++ b/crates/sandlock-core/src/checkpoint/image.rs @@ -380,6 +380,7 @@ mod tests { assert!(loaded.net_allow_is_active()); } + #[cfg(feature = "http")] #[test] fn image_round_trip_preserves_deny_only_http_as_default_allow() { let policy = Sandbox::builder() @@ -397,6 +398,7 @@ mod tests { assert_eq!(loaded.effective_net_allow().len(), 1); } + #[cfg(feature = "http")] #[test] fn image_round_trip_preserves_http_only_as_restrictive_allowlist() { let policy = Sandbox::builder() diff --git a/crates/sandlock-core/src/credential_disabled.rs b/crates/sandlock-core/src/credential_disabled.rs new file mode 100644 index 000000000..4fe8c431f --- /dev/null +++ b/crates/sandlock-core/src/credential_disabled.rs @@ -0,0 +1,17 @@ +//! Stand-in for credential injection when sandlock is built without the +//! `http` feature: injection happens in the HTTP ACL proxy, which is absent. + +use crate::error::SandboxError; + +#[derive(Debug)] +pub enum InjectRule {} + +pub fn resolve_inject_rules( + credentials: &[String], + inject: &[String], +) -> Result<(Vec, Vec), SandboxError> { + if credentials.is_empty() && inject.is_empty() { + return Ok((Vec::new(), Vec::new())); + } + Err(SandboxError::FeatureDisabled { what: "credential injection".into(), feature: "http" }) +} diff --git a/crates/sandlock-core/src/error.rs b/crates/sandlock-core/src/error.rs index 3453ddfa3..ab3866f0d 100644 --- a/crates/sandlock-core/src/error.rs +++ b/crates/sandlock-core/src/error.rs @@ -28,6 +28,9 @@ pub enum SandboxError { #[error("confine() only accepts Landlock filesystem policy; unsupported fields: {0}")] UnsupportedForConfine(String), + #[error("{what} needs sandlock built with the \"{feature}\" feature")] + FeatureDisabled { what: String, feature: &'static str }, + #[error("chroot path {path} does not exist or is inaccessible: {source}")] ChrootNotFound { path: std::path::PathBuf, diff --git a/crates/sandlock-core/src/image/mod.rs b/crates/sandlock-core/src/image/mod.rs index b5b59bbac..202cbeb62 100644 --- a/crates/sandlock-core/src/image/mod.rs +++ b/crates/sandlock-core/src/image/mod.rs @@ -28,9 +28,11 @@ use serde::{Deserialize, Serialize}; use crate::error::{SandboxRuntimeError, SandlockError}; +#[cfg(feature = "http")] mod docker; mod layer; mod oci; +#[cfg(feature = "http")] mod registry; /// An unpacked image: its root filesystem and how it expects to be run. @@ -66,8 +68,16 @@ impl ImageConfig { pub async fn pull(reference: &str, cache_dir: Option<&Path>) -> Result { let cache = Cache::new(cache_dir); match Source::parse(reference)? { + #[cfg(feature = "http")] Source::Registry(name) => registry::pull(&cache, &name).await, + #[cfg(feature = "http")] Source::DockerDaemon(name) => docker::pull(&cache, &name).await, + #[cfg(not(feature = "http"))] + Source::Registry(_) | Source::DockerDaemon(_) => Err(crate::error::SandboxError::FeatureDisabled { + what: format!("image reference {reference:?}"), + feature: "http", + } + .into()), Source::OciDir { path, tag } => { blocking(move || { let blobs = oci::LayoutDir::open(&path)?; @@ -256,6 +266,15 @@ mod tests { use super::*; use oci::tests::{tar_of, TestLayout}; + #[cfg(not(feature = "http"))] + #[tokio::test] + async fn network_transports_need_the_http_feature() { + for reference in ["docker://alpine", "docker-daemon:alpine"] { + let err = pull(reference, Some(Path::new("/nonexistent"))).await.unwrap_err(); + assert!(err.to_string().contains("\"http\" feature"), "{err}"); + } + } + #[test] fn default_cmd_combines_entrypoint_and_cmd() { let cfg = ImageConfig { diff --git a/crates/sandlock-core/src/image/oci.rs b/crates/sandlock-core/src/image/oci.rs index 45e2f6676..14967d968 100644 --- a/crates/sandlock-core/src/image/oci.rs +++ b/crates/sandlock-core/src/image/oci.rs @@ -96,8 +96,10 @@ impl Blobs for LayoutDir { } /// A bare directory of blobs named by their sha256 hex. +#[cfg(feature = "http")] pub(super) struct BlobDir(pub PathBuf); +#[cfg(feature = "http")] impl Blobs for BlobDir { fn index(&self) -> io::Result> { Err(io::Error::new(io::ErrorKind::Unsupported, "a blob directory has no index")) @@ -407,6 +409,7 @@ pub(super) mod tests { use super::*; use std::fs; + #[cfg(feature = "http")] pub(in crate::image) fn host_arch_for_tests() -> &'static str { host_arch() } diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 23dfd62bd..dfbca627a 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -931,6 +931,7 @@ mod mask_contract_tests { ); } + #[cfg(feature = "http")] #[test] fn net_mask_http_only_keeps_connect_tcp() { // HTTP-only has no explicit allow rules but generates a finite @@ -952,6 +953,7 @@ mod mask_contract_tests { ); } + #[cfg(feature = "http")] #[test] fn net_mask_deny_only_http_stays_wildcard() { // Deny-only + HTTP stays default-allow: HTTP reachability must not @@ -973,6 +975,7 @@ mod mask_contract_tests { ); } + #[cfg(feature = "http")] #[test] fn net_mask_combined_http_keeps_connect_tcp() { let pol = ProtectionPolicy::strict_all(); diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index d1b9e2837..14c1c3bc4 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -1,5 +1,9 @@ pub mod error; pub mod http; +#[cfg(feature = "http")] +pub(crate) mod credential; +#[cfg(not(feature = "http"))] +#[path = "credential_disabled.rs"] pub(crate) mod credential; pub mod sandbox; // formerly `policy`; contains Sandbox + SandboxBuilder + Confinement pub mod profile; @@ -34,6 +38,10 @@ pub mod fork; pub(crate) mod ca_inject; pub(crate) mod chroot; pub mod control; +#[cfg(feature = "http")] +mod transparent_proxy; +#[cfg(not(feature = "http"))] +#[path = "transparent_proxy/disabled.rs"] mod transparent_proxy; pub use error::SandlockError; diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 18cb96eba..2a9c9a4c8 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -979,6 +979,7 @@ mod tests { assert_eq!(parsed.filesystem, FilesystemSection::default()); } + #[cfg(feature = "http")] #[test] fn config_section_maps_to_policy_http_fields() { let toml = r#" @@ -994,6 +995,7 @@ mod tests { assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key"))); } + #[cfg(feature = "http")] #[test] fn parses_http_inject_ca_and_ca_out() { let toml = r#" @@ -1068,6 +1070,7 @@ mod tests { assert!(msg.contains("non-empty"), "got: {msg}"); } + #[cfg(feature = "http")] #[test] fn parse_profile_full_example() { let toml = r#" @@ -1246,6 +1249,7 @@ mod tests { assert_eq!(round_tripped.net_deny_bind, policy.net_deny_bind); } + #[cfg(feature = "http")] #[test] fn profile_deny_only_http_policy_does_not_promote_generated_allow_rules() { let toml = r#" @@ -1270,6 +1274,7 @@ mod tests { assert_eq!(round_tripped.effective_net_allow().len(), 1); } + #[cfg(feature = "http")] #[test] fn profile_http_only_round_trips_as_restrictive_allowlist() { let toml = r#" @@ -1293,6 +1298,7 @@ mod tests { ); } + #[cfg(feature = "http")] #[test] fn profile_combined_http_round_trips_without_duplicating_derived_rules() { let toml = r#" diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index d344a1047..44bd072f7 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -939,6 +939,25 @@ impl SandboxBuilder { )); } + #[cfg(not(feature = "http"))] + { + let set = [ + ("--http-allow", !self.http_allow.is_empty()), + ("--http-deny", !self.http_deny.is_empty()), + ("--http-port", !self.http_ports.is_empty()), + ("--http-ca", self.http_ca.is_some()), + ("--http-key", self.http_key.is_some()), + ("--http-inject-ca", !self.http_inject_ca.is_empty()), + ("--http-ca-out", self.http_ca_out.is_some()), + ("--http-auth", !self.http_auth.is_empty()), + ("--credential", !self.credentials.is_empty()), + ("http_log_fn", self.http_log_fn.is_some()), + ]; + if let Some((what, _)) = set.iter().find(|(_, on)| *on) { + return Err(SandboxError::FeatureDisabled { what: what.to_string(), feature: "http" }); + } + } + // Validate: http_ca and http_key must both be set or both unset if self.http_ca.is_some() != self.http_key.is_some() { return Err(SandboxError::Invalid( diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index 9a9fcdaab..6ad536df5 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -2,6 +2,19 @@ use super::*; use std::path::{Path, PathBuf}; use std::str::FromStr; +#[cfg(not(feature = "http"))] +#[test] +fn http_options_need_the_http_feature() { + for builder in [ + Sandbox::builder().http_allow("GET example.com/*"), + Sandbox::builder().http_ca("/ca.pem").http_key("/ca.key"), + Sandbox::builder().credential("openai", "env:OPENAI_API_KEY"), + ] { + let err = builder.build().unwrap_err(); + assert!(matches!(err, SandboxError::FeatureDisabled { feature: "http", .. }), "{err}"); + } +} + #[test] fn run_as_parses_uid_and_gid() { let r = RunAs::from_str("1000:2000").unwrap(); @@ -49,6 +62,7 @@ fn resolve_sandbox_path_mount_takes_precedence() { assert_eq!(r, PathBuf::from("/host/ssl/x.pem")); } +#[cfg(feature = "http")] #[tokio::test] async fn inject_ca_nonexistent_path_errors_at_run() { // Wildcard host rule avoids DNS; the missing inject path must error @@ -64,6 +78,7 @@ async fn inject_ca_nonexistent_path_errors_at_run() { // --- SandboxBuilder integration --- +#[cfg(feature = "http")] #[test] fn builder_http_rules() { let policy = Sandbox::builder() @@ -109,6 +124,7 @@ fn builder_http_key_without_ca_returns_error() { assert!(result.is_err()); } +#[cfg(feature = "http")] #[test] fn builder_http_ca_and_key_together_ok() { let policy = Sandbox::builder() @@ -120,6 +136,7 @@ fn builder_http_ca_and_key_together_ok() { assert!(policy.http_key.is_some()); } +#[cfg(feature = "http")] #[test] fn inject_ca_adds_443_and_requires_http_rule() { // No http rule -> error. @@ -138,6 +155,7 @@ fn inject_ca_adds_443_and_requires_http_rule() { assert_eq!(policy.http_inject_ca.len(), 1); } +#[cfg(feature = "http")] #[test] fn http_ca_out_requires_trigger() { let err = Sandbox::builder() @@ -309,6 +327,7 @@ fn builder_combines_net_allow_and_net_deny() { assert!(policy.net_allow_is_active()); } +#[cfg(feature = "http")] #[test] fn builder_keeps_http_reachability_out_of_net_allow() { // `net_allow` holds only explicit rules; HTTP reachability is generated @@ -323,6 +342,7 @@ fn builder_keeps_http_reachability_out_of_net_allow() { assert_eq!(policy.effective_net_allow().len(), 1); } +#[cfg(feature = "http")] #[test] fn builder_http_only_stays_restrictive_allowlist() { let policy = Sandbox::builder() @@ -335,6 +355,7 @@ fn builder_http_only_stays_restrictive_allowlist() { assert_eq!(policy.effective_net_allow().len(), 1); } +#[cfg(feature = "http")] #[test] fn builder_deny_only_http_stays_default_allow() { let policy = Sandbox::builder() @@ -349,6 +370,7 @@ fn builder_deny_only_http_stays_default_allow() { assert_eq!(policy.effective_net_allow().len(), 1); } +#[cfg(feature = "http")] #[test] fn builder_combined_http_merges_at_resolution_time() { let policy = Sandbox::builder() @@ -363,6 +385,7 @@ fn builder_combined_http_merges_at_resolution_time() { assert_eq!(policy.effective_net_allow().len(), 3); } +#[cfg(feature = "http")] #[test] fn outbound_mode_survives_policy_bincode_round_trip() { for builder in [ diff --git a/crates/sandlock-core/src/transparent_proxy/disabled.rs b/crates/sandlock-core/src/transparent_proxy/disabled.rs new file mode 100644 index 000000000..178d6edd2 --- /dev/null +++ b/crates/sandlock-core/src/transparent_proxy/disabled.rs @@ -0,0 +1,42 @@ +//! Stand-in for the HTTP ACL proxy when sandlock is built without the `http` +//! feature. The builder rejects every HTTP option first; these errors only +//! guard a policy that skipped the builder, such as a deserialized one. + +use std::collections::HashMap; +use std::io; +use std::net::{IpAddr, SocketAddr}; +use std::path::Path; +use std::sync::Arc; + +use crate::http::HttpRule; + +pub(crate) type OrigDestMap = Arc>>; + +pub(crate) struct CaMaterial { + pub(crate) cert_pem: String, + pub(crate) key_pem: String, +} + +pub(crate) struct HttpAclProxyHandle { + pub(crate) addr: SocketAddr, + pub(crate) orig_dest: OrigDestMap, +} + +fn disabled() -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, "sandlock was built without the \"http\" feature") +} + +pub(crate) fn resolve_ca(_: Option<&Path>, _: Option<&Path>, _: bool) -> io::Result> { + Err(disabled()) +} + +pub(crate) async fn spawn_transparent_proxy( + _: Vec, + _: Vec, + _: Arc>, + _: Option<&str>, + _: Option<&str>, + _: Option>, +) -> io::Result { + Err(disabled()) +} diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index d21c1a630..c1ec3165c 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -58,6 +58,7 @@ mod test_chroot; #[path = "integration/test_branch_action.rs"] mod test_branch_action; +#[cfg(feature = "http")] #[path = "integration/test_http_acl.rs"] mod test_http_acl; @@ -70,9 +71,11 @@ mod test_control; #[path = "integration/test_protection.rs"] mod test_protection; +#[cfg(feature = "http")] #[path = "integration/test_http_inject_ca.rs"] mod test_http_inject_ca; +#[cfg(feature = "http")] #[path = "integration/test_http_strict_tls.rs"] mod test_http_strict_tls; diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 4933272ca..f22ac5a83 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -1551,6 +1551,7 @@ async fn test_combined_hostname_resolving_into_denied_cidr_refused() { /// HTTP-derived rules live outside `net_allow` and must not promote the /// policy to combined. A live listener on an address outside both the HTTP /// allow host and the denylist must still connect. +#[cfg(feature = "http")] #[tokio::test] async fn test_deny_only_with_http_allow_stays_deny_only() { let listener = TcpListener::bind("127.0.0.2:0").unwrap(); From 7ce9b07391c3471b41a6188ebef64ee55dd15e56 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 8 Oct 2026 23:30:24 +0200 Subject: [PATCH 2/3] cli, ffi, oci: forward the http feature Each crate took sandlock-core with its default features, so a build of the CLI, the C library or the OCI shim could never leave the HTTP stack out. They now depend on the core without defaults and expose the same http feature, on by default. learn always installed an HTTP request logger, which needs the proxy, so without the feature it would fail at policy build. It now records everything but HTTP requests in that case, and the tests that check HTTP capture only run when the feature is enabled. Signed-off-by: Cong Wang --- crates/sandlock-cli/Cargo.toml | 6 +++++- crates/sandlock-cli/src/learn.rs | 15 +++++++++------ crates/sandlock-cli/tests/learn_integration.rs | 3 +++ crates/sandlock-cli/tests/learn_test.rs | 4 ++++ crates/sandlock-ffi/Cargo.toml | 6 +++++- crates/sandlock-oci/Cargo.toml | 8 ++++++-- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index b3e0e2e24..02e10434a 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -12,8 +12,12 @@ description = "CLI for sandlock process sandbox" name = "sandlock" path = "src/main.rs" +[features] +default = ["http"] +http = ["sandlock-core/http"] + [dependencies] -sandlock-core = { version = "0.8.9", path = "../sandlock-core", features = ["cli"] } +sandlock-core = { version = "0.8.9", path = "../sandlock-core", default-features = false, features = ["cli"] } clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } anyhow = "1" diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 6a90a617a..11e41ec6c 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -585,12 +585,15 @@ pub async fn run(args: LearnArgs) -> Result<()> { .max_memory(sandlock_core::sandbox::ByteSize(1 << 43)) // 8 TiB .policy_fn(move |event, _ctx| observer_cb.on_event(event)); - let http_requests_cb = Arc::clone(&observer.http_requests); - let http_log: Arc = - Arc::new(move |method, host, path| { - http_requests_cb.lock().unwrap().insert(format!("{method} {host}{path}")); - }); - builder = builder.http_log_fn(http_log); + #[cfg(feature = "http")] + { + let http_requests_cb = Arc::clone(&observer.http_requests); + let http_log: Arc = + Arc::new(move |method, host, path| { + http_requests_cb.lock().unwrap().insert(format!("{method} {host}{path}")); + }); + builder = builder.http_log_fn(http_log); + } for path in &args.http_inject_ca { builder = builder.http_inject_ca(path); } diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index 46d1d3581..dfad4d675 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -464,6 +464,7 @@ fn spawn_http_server() -> u16 { /// Learn HTTP traffic then run with the learned profile: /// - the learned path is allowed and returns the real response /// - a different path that was not observed is blocked (403 from proxy) +#[cfg(feature = "http")] #[test] fn test_learn_then_run_http() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); @@ -533,6 +534,7 @@ fn https_reachable(host: &str) -> bool { /// - the learned HTTPS path is allowed /// - an unlearned path is blocked (403 from proxy) /// Skipped if example.com is unreachable or no system CA bundle is found. +#[cfg(feature = "http")] #[test] fn test_learn_then_run_https() { if !https_reachable("example.com") { @@ -584,6 +586,7 @@ fn test_learn_then_run_https() { /// --merge carries [http].allow and [http].ports from the observed run into the /// existing profile. +#[cfg(feature = "http")] #[test] fn test_learn_then_run_merge_http() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index 3b2431239..9ae262a2d 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -994,6 +994,7 @@ fn spawn_http_server() -> u16 { } /// A plaintext HTTP request is captured and written as an [http] allow rule. +#[cfg(feature = "http")] #[test] fn test_learn_captures_http_request() { let port = spawn_http_server(); @@ -1015,6 +1016,7 @@ fn test_learn_captures_http_request() { } /// --http-port writes only the intercepted port to [http].ports, not port 80. +#[cfg(feature = "http")] #[test] fn test_learn_http_port_not_inflated() { let port = spawn_http_server(); @@ -1033,6 +1035,7 @@ fn test_learn_http_port_not_inflated() { } /// Multiple distinct requests to different paths are all recorded and deduplicated. +#[cfg(feature = "http")] #[test] fn test_learn_captures_http_multiple_paths() { let port = spawn_http_server(); @@ -1082,6 +1085,7 @@ fn https_reachable(host: &str) -> bool { /// Uses a real HTTPS server (example.com) so that TLS termination, MITM cert signing, /// CA injection, and proxy forwarding are all exercised end to end. /// Skipped if example.com is unreachable or no system CA bundle is found. +#[cfg(feature = "http")] #[test] fn test_learn_captures_https_request() { if !https_reachable("example.com") { diff --git a/crates/sandlock-ffi/Cargo.toml b/crates/sandlock-ffi/Cargo.toml index e71257d6a..c00fcad58 100644 --- a/crates/sandlock-ffi/Cargo.toml +++ b/crates/sandlock-ffi/Cargo.toml @@ -11,9 +11,13 @@ description = "C ABI for sandlock process sandbox" [lib] crate-type = ["cdylib", "staticlib", "rlib"] +[features] +default = ["http"] +http = ["sandlock-core/http"] + [dependencies] libc = "0.2" -sandlock-core = { version = "0.8.9", path = "../sandlock-core" } +sandlock-core = { version = "0.8.9", path = "../sandlock-core", default-features = false } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread"] } diff --git a/crates/sandlock-oci/Cargo.toml b/crates/sandlock-oci/Cargo.toml index b08967a91..f1802f3ee 100644 --- a/crates/sandlock-oci/Cargo.toml +++ b/crates/sandlock-oci/Cargo.toml @@ -16,8 +16,12 @@ path = "src/lib.rs" name = "sandlock-oci" path = "src/main.rs" +[features] +default = ["http"] +http = ["sandlock-core/http"] + [dependencies] -sandlock-core = { version = "0.8.9", path = "../sandlock-core" } +sandlock-core = { version = "0.8.9", path = "../sandlock-core", default-features = false } anyhow = "1" clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util"] } @@ -29,4 +33,4 @@ oci-spec = { version = "0.7", features = ["runtime"] } [dev-dependencies] tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } -sandlock-core = { path = "../sandlock-core" } \ No newline at end of file +sandlock-core = { path = "../sandlock-core", default-features = false } \ No newline at end of file From 8606a40e778aac54abbbe01254895f31a6de99ba Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 8 Oct 2026 23:30:24 +0200 Subject: [PATCH 3/3] ci: test the build without the http feature The existing jobs only build the default feature set, so a cfg slip in the minimal build would go unnoticed. The new job asserts the minimal build pulls in no HTTP stack and runs the full test suite without the http feature. Signed-off-by: Cong Wang --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47c5b062e..75b720726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,32 @@ jobs: timeout-minutes: 20 run: cargo test --release --workspace --no-fail-fast -- --test-threads=1 + rust-minimal: + name: Rust without optional features + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Allow unprivileged user namespaces + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + + - name: Minimal build pulls in no HTTP stack + 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 + timeout-minutes: 20 + run: cargo test --release --workspace --no-default-features --no-fail-fast -- --test-threads=1 + rust-low-abi: name: Rust low-ABI (ubuntu-22.04) runs-on: ubuntu-22.04