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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion crates/sandlock-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 9 additions & 6 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Fn(&str, &str, &str) + Send + Sync> =
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<dyn Fn(&str, &str, &str) + Send + Sync> =
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);
}
Expand Down
3 changes: 3 additions & 0 deletions crates/sandlock-cli/tests/learn_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions crates/sandlock-cli/tests/learn_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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") {
Expand Down
24 changes: 14 additions & 10 deletions crates/sandlock-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/sandlock-core/src/checkpoint/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions crates/sandlock-core/src/credential_disabled.rs
Original file line number Diff line number Diff line change
@@ -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<InjectRule>, Vec<String>), SandboxError> {
if credentials.is_empty() && inject.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
Err(SandboxError::FeatureDisabled { what: "credential injection".into(), feature: "http" })
}
3 changes: 3 additions & 0 deletions crates/sandlock-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions crates/sandlock-core/src/image/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,8 +68,16 @@ impl ImageConfig {
pub async fn pull(reference: &str, cache_dir: Option<&Path>) -> Result<Image, SandlockError> {
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)?;
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions crates/sandlock-core/src/image/oci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "a blob directory has no index"))
Expand Down Expand Up @@ -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()
}
Expand Down
3 changes: 3 additions & 0 deletions crates/sandlock-core/src/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions crates/sandlock-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions crates/sandlock-core/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand All @@ -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#"
Expand Down Expand Up @@ -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#"
Expand Down Expand Up @@ -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#"
Expand All @@ -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#"
Expand All @@ -1293,6 +1298,7 @@ mod tests {
);
}

#[cfg(feature = "http")]
#[test]
fn profile_combined_http_round_trips_without_duplicating_derived_rules() {
let toml = r#"
Expand Down
19 changes: 19 additions & 0 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading