use async_trait::async_trait;
use crate::config::LandlockConfig;
use crate::runtime::types::{
CommandOutput, ContainerConfig, ContainerRuntime, RuntimeError, RuntimeResult,
};
pub struct LandlockRuntime {
config: LandlockConfig,
}
impl LandlockRuntime {
pub fn new(config: LandlockConfig) -> Self {
Self { config }
}
}
#[async_trait]
impl ContainerRuntime for LandlockRuntime {
fn name(&self) -> &str {
"landlock"
}
async fn is_available(&self) -> bool {
cfg!(all(target_os = "linux", feature = "sandbox-landlock"))
}
async fn execute(
&self,
command: &str,
config: &ContainerConfig,
) -> RuntimeResult<CommandOutput> {
let config_clone = config.clone();
let ll_config = self.config.clone();
let command = command.to_string();
let workspace = config.workdir.clone();
tokio::task::spawn_blocking(move || {
execute_with_landlock(&command, &config_clone, &ll_config, workspace.as_deref())
})
.await
.map_err(|e| RuntimeError::ExecutionFailed(format!("spawn_blocking join error: {e}")))?
}
}
#[cfg(not(all(target_os = "linux", feature = "sandbox-landlock")))]
fn execute_with_landlock(
_command: &str,
_config: &ContainerConfig,
_ll_config: &LandlockConfig,
_workspace: Option<&std::path::Path>,
) -> RuntimeResult<CommandOutput> {
Err(RuntimeError::NotAvailable(
"Recompile with --features sandbox-landlock to use the Landlock runtime.".to_string(),
))
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
fn execute_with_landlock(
command: &str,
config: &ContainerConfig,
ll_config: &LandlockConfig,
workspace: Option<&std::path::Path>,
) -> RuntimeResult<CommandOutput> {
execute_with_landlock_inner(command, config, ll_config, workspace)
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
fn execute_with_landlock_inner(
command: &str,
config: &ContainerConfig,
ll_config: &LandlockConfig,
workspace: Option<&std::path::Path>,
) -> RuntimeResult<CommandOutput> {
use std::os::unix::process::CommandExt;
use std::process::Stdio;
use std::time::Duration;
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg(command);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
if let Some(ref workdir) = config.workdir {
cmd.current_dir(workdir);
}
for (k, v) in &config.env {
cmd.env(k, v);
}
let ll_config_clone = ll_config.clone();
let workspace_clone = workspace.map(|p| p.to_path_buf());
unsafe {
cmd.pre_exec(move || {
apply_landlock_rules_in_child(&ll_config_clone, workspace_clone.as_deref()).map_err(
|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()),
)
});
}
let timeout = Duration::from_secs(config.timeout_secs);
let child = cmd
.spawn()
.map_err(|e| RuntimeError::ExecutionFailed(format!("Failed to spawn command: {e}")))?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
match rx.recv_timeout(timeout) {
Ok(Ok(output)) => Ok(CommandOutput::new(
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.code(),
)),
Ok(Err(e)) => Err(RuntimeError::ExecutionFailed(format!(
"Command wait failed: {e}"
))),
Err(_) => Err(RuntimeError::Timeout(config.timeout_secs)),
}
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
fn apply_landlock_rules_in_child(
config: &LandlockConfig,
workspace: Option<&std::path::Path>,
) -> Result<(), RuntimeError> {
use landlock::{
Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus, ABI,
};
let abi = ABI::V3;
let ruleset = Ruleset::default()
.handle_access(AccessFs::from_read(abi))
.map_err(|e| RuntimeError::ExecutionFailed(format!("Landlock ruleset read error: {e}")))?
.handle_access(AccessFs::from_write(abi))
.map_err(|e| RuntimeError::ExecutionFailed(format!("Landlock ruleset write error: {e}")))?
.create()
.map_err(|e| RuntimeError::ExecutionFailed(format!("Landlock create error: {e}")))?;
let mut read_dirs: Vec<String> = config.fs_read_dirs.clone();
let mut write_dirs: Vec<String> = config.fs_write_dirs.clone();
if let Some(ws) = workspace {
let ws_str = ws.to_string_lossy().to_string();
if config.allow_read_workspace && !read_dirs.contains(&ws_str) {
read_dirs.push(ws_str.clone());
}
if config.allow_write_workspace && !write_dirs.contains(&ws_str) {
write_dirs.push(ws_str);
}
}
let mut ruleset = ruleset;
for dir in &read_dirs {
if let Ok(fd) = PathFd::new(dir) {
ruleset = match ruleset.add_rule(PathBeneath::new(fd, AccessFs::from_read(abi))) {
Ok(rs) => rs,
Err(e) => {
eprintln!("landlock: failed to add read rule for {dir:?}: {e}");
return Err(RuntimeError::ExecutionFailed(format!(
"Landlock read rule for {dir:?}: {e}"
)));
}
};
}
}
for dir in &write_dirs {
if let Ok(fd) = PathFd::new(dir) {
ruleset = match ruleset.add_rule(PathBeneath::new(fd, AccessFs::from_all(abi))) {
Ok(rs) => rs,
Err(e) => {
eprintln!("landlock: failed to add write rule for {dir:?}: {e}");
return Err(RuntimeError::ExecutionFailed(format!(
"Landlock write rule for {dir:?}: {e}"
)));
}
};
}
}
match ruleset.restrict_self() {
Ok(status) => {
if status.ruleset == RulesetStatus::NotEnforced {
}
Ok(())
}
Err(e) => Err(RuntimeError::ExecutionFailed(format!(
"Landlock restrict_self failed: {e}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LandlockConfig;
#[test]
fn test_landlock_runtime_name() {
let rt = LandlockRuntime::new(LandlockConfig::default());
assert_eq!(rt.name(), "landlock");
}
#[tokio::test]
async fn test_landlock_runtime_available_matches_feature() {
let rt = LandlockRuntime::new(LandlockConfig::default());
assert_eq!(
rt.is_available().await,
cfg!(all(target_os = "linux", feature = "sandbox-landlock")),
"is_available() should reflect whether sandbox-landlock feature is compiled in"
);
}
#[test]
fn test_landlock_runtime_config_stored() {
let mut config = LandlockConfig::default();
config.fs_write_dirs.push("/home".to_string());
let rt = LandlockRuntime::new(config.clone());
assert_eq!(rt.config.fs_write_dirs, config.fs_write_dirs);
}
#[cfg(not(all(target_os = "linux", feature = "sandbox-landlock")))]
#[tokio::test]
async fn test_landlock_execute_without_feature_returns_error() {
let rt = LandlockRuntime::new(LandlockConfig::default());
let cfg = ContainerConfig::new();
let result = rt.execute("echo hi", &cfg).await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("sandbox-landlock"),
"Expected error mentioning sandbox-landlock, got: {msg}"
);
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[tokio::test]
async fn test_landlock_runtime_echo() {
let rt = LandlockRuntime::new(LandlockConfig::default());
let cfg = ContainerConfig::new();
let out = rt.execute("echo hello", &cfg).await.unwrap();
assert!(out.success());
assert_eq!(out.stdout.trim(), "hello");
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[tokio::test]
async fn test_landlock_runtime_timeout() {
let rt = LandlockRuntime::new(LandlockConfig::default());
let cfg = ContainerConfig::new().with_timeout(1);
let result = rt.execute("sleep 10", &cfg).await;
assert!(matches!(result, Err(RuntimeError::Timeout(1))));
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[tokio::test]
async fn test_landlock_runtime_with_env() {
let rt = LandlockRuntime::new(LandlockConfig::default());
let cfg = ContainerConfig::new().with_env("MY_VAR", "hello_landlock");
let out = rt.execute("echo $MY_VAR", &cfg).await.unwrap();
assert!(out.success());
assert_eq!(out.stdout.trim(), "hello_landlock");
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[tokio::test]
async fn test_landlock_runtime_with_workdir() {
let rt = LandlockRuntime::new(LandlockConfig::default());
let cfg = ContainerConfig::new().with_workdir(std::path::PathBuf::from("/tmp"));
let out = rt.execute("pwd", &cfg).await.unwrap();
assert!(out.success());
assert!(out.stdout.contains("tmp"));
}
#[cfg(all(target_os = "linux", feature = "sandbox-landlock"))]
#[tokio::test]
async fn test_landlock_runtime_exit_code() {
let rt = LandlockRuntime::new(LandlockConfig::default());
let cfg = ContainerConfig::new();
let out = rt.execute("exit 42", &cfg).await.unwrap();
assert!(!out.success());
assert_eq!(out.exit_code, Some(42));
}
}