kavach 1.0.1

Sandbox execution framework — backend abstraction, strength scoring, policy engine, credential proxy, and audit hooks
Documentation
//! OCI container backend — runc/crun integration.
//!
//! Detects available OCI runtime (prefers crun over runc) and executes
//! commands in OCI-compliant containers.

use crate::backend::oci_spec;
use crate::backend::oci_spec::oci_runtime::ProcessBuilder;
use crate::backend::{Backend, SandboxBackend};
use crate::lifecycle::{ExecResult, SandboxConfig};
use crate::policy::SandboxPolicy;

/// OCI image manager — pull and unpack container images.
#[derive(Debug)]
pub struct OciImageManager {
    /// Image pull tool (skopeo, crane, or container runtime).
    pull_tool: String,
}

impl OciImageManager {
    /// Create an image manager. Detects the best available pull tool.
    pub fn detect() -> crate::Result<Self> {
        // Prefer skopeo for native OCI support
        let tool = crate::backend::which_first(&["skopeo", "crane"])
            .map(Into::into)
            .or_else(detect_runtime)
            .ok_or_else(|| {
                crate::KavachError::BackendUnavailable(
                    "no image pull tool found (skopeo, crane, runc, crun)".into(),
                )
            })?;

        Ok(Self { pull_tool: tool })
    }

    /// Pull an OCI image to a local OCI layout directory.
    pub async fn pull(&self, image_ref: &str, dest: &std::path::Path) -> crate::Result<()> {
        tracing::debug!(image = image_ref, tool = %self.pull_tool, "pulling OCI image");

        let output = if self.pull_tool.contains("skopeo") {
            tokio::process::Command::new(&self.pull_tool)
                .args([
                    "copy",
                    &format!("docker://{image_ref}"),
                    &format!("oci:{}", dest.display()),
                ])
                .output()
                .await
        } else if self.pull_tool.contains("crane") {
            tokio::process::Command::new(&self.pull_tool)
                .args(["pull", image_ref, &dest.join("image.tar").to_string_lossy()])
                .output()
                .await
        } else {
            // Fallback: use docker/podman to pull then export
            let pull = tokio::process::Command::new(&self.pull_tool)
                .args(["pull", image_ref])
                .output()
                .await;
            if pull.as_ref().is_ok_and(|o| o.status.success()) {
                tokio::process::Command::new(&self.pull_tool)
                    .args([
                        "save",
                        "-o",
                        &dest.join("image.tar").to_string_lossy(),
                        image_ref,
                    ])
                    .output()
                    .await
            } else {
                pull
            }
        };

        let output =
            output.map_err(|e| crate::KavachError::ExecFailed(format!("image pull: {e}")))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(crate::KavachError::ExecFailed(format!(
                "image pull failed: {stderr}"
            )));
        }

        tracing::debug!(image = image_ref, "image pull complete");
        Ok(())
    }

    /// Unpack an OCI image tar into a rootfs directory.
    pub async fn unpack(
        &self,
        image_tar: &std::path::Path,
        rootfs_dir: &std::path::Path,
    ) -> crate::Result<()> {
        tracing::debug!(
            tar = %image_tar.display(),
            rootfs = %rootfs_dir.display(),
            "unpacking OCI image"
        );

        std::fs::create_dir_all(rootfs_dir)
            .map_err(|e| crate::KavachError::CreationFailed(format!("rootfs dir: {e}")))?;

        let output = tokio::process::Command::new("tar")
            .args([
                "xf",
                &image_tar.to_string_lossy(),
                "-C",
                &rootfs_dir.to_string_lossy(),
            ])
            .output()
            .await
            .map_err(|e| crate::KavachError::ExecFailed(format!("image unpack: {e}")))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(crate::KavachError::ExecFailed(format!(
                "image unpack failed: {stderr}"
            )));
        }

        Ok(())
    }
}

/// OCI container-based sandbox backend.
#[derive(Debug)]
pub struct OciBackend {
    config: SandboxConfig,
    runtime: String,
}

impl OciBackend {
    /// Create a new OCI backend. Detects available runtime.
    pub fn new(config: &SandboxConfig) -> crate::Result<Self> {
        let runtime = detect_runtime().ok_or_else(|| {
            crate::KavachError::BackendUnavailable("no OCI runtime (runc/crun) found".into())
        })?;
        Ok(Self {
            config: config.clone(),
            runtime,
        })
    }
}

#[async_trait::async_trait]
impl SandboxBackend for OciBackend {
    fn backend_type(&self) -> Backend {
        Backend::Oci
    }

    async fn exec(&self, command: &str, policy: &SandboxPolicy) -> crate::Result<ExecResult> {
        let container_id = oci_spec::container_id("kavach-oci");

        // Create temp bundle directory
        let bundle_dir = tempfile::tempdir()
            .map_err(|e| crate::KavachError::CreationFailed(format!("temp bundle dir: {e}")))?;

        // Create rootfs directory
        let rootfs_dir = bundle_dir.path().join("rootfs");
        std::fs::create_dir_all(&rootfs_dir)
            .map_err(|e| crate::KavachError::CreationFailed(format!("rootfs dir: {e}")))?;

        // Generate and write OCI spec
        let mut spec = oci_spec::generate_spec(&self.config)?;

        // Override process args with the actual command
        if let Some(process) = spec.process_mut() {
            let cwd = self
                .config
                .workdir
                .clone()
                .unwrap_or_else(|| "/".to_string());
            *process = ProcessBuilder::default()
                .terminal(false)
                .args(vec!["/bin/sh".into(), "-c".into(), command.into()])
                .cwd(cwd)
                .env(oci_spec::build_env(&self.config))
                .build()
                .map_err(|e| crate::KavachError::ExecFailed(format!("OCI process: {e}")))?;
        }

        oci_spec::write_spec(&spec, bundle_dir.path())?;

        let _ = policy; // Policy is embedded in the OCI spec

        // Run the container
        let mut cmd = tokio::process::Command::new(&self.runtime);
        cmd.args([
            "run",
            "--bundle",
            &bundle_dir.path().to_string_lossy(),
            &container_id,
        ]);

        let result = crate::backend::exec_util::execute_with_timeout(
            &mut cmd,
            self.config.timeout_ms,
            &self.runtime,
        )
        .await;

        // Cleanup container regardless of result
        let _ = tokio::process::Command::new(&self.runtime)
            .args(["delete", "--force", &container_id])
            .output()
            .await;

        result
    }

    async fn health_check(&self) -> crate::Result<bool> {
        let output = tokio::process::Command::new(&self.runtime)
            .arg("--version")
            .output()
            .await
            .map_err(|e| crate::KavachError::ExecFailed(format!("{} health: {e}", self.runtime)))?;
        Ok(output.status.success())
    }

    async fn destroy(&self) -> crate::Result<()> {
        Ok(())
    }
}

/// Detect available OCI runtime. Prefers crun (faster) over runc.
fn detect_runtime() -> Option<String> {
    crate::backend::which_first(&["crun", "runc"]).map(Into::into)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn env_defaults() {
        let config = SandboxConfig::builder().backend(Backend::Oci).build();
        let env = oci_spec::build_env(&config);
        assert!(env.iter().any(|e| e.starts_with("PATH=")));
        assert!(env.iter().any(|e| e == "TERM=xterm"));
    }

    #[test]
    fn env_custom_vars() {
        let mut config = SandboxConfig::builder().backend(Backend::Oci).build();
        config.env.push(("MY_VAR".into(), "my_value".into()));
        let env = oci_spec::build_env(&config);
        assert!(env.iter().any(|e| e == "MY_VAR=my_value"));
    }

    #[test]
    fn env_preserves_defaults_with_custom() {
        let mut config = SandboxConfig::builder().backend(Backend::Oci).build();
        config.env.push(("X".into(), "1".into()));
        let env = oci_spec::build_env(&config);
        // defaults still present
        assert!(env.iter().any(|e| e.starts_with("PATH=")));
        assert!(env.iter().any(|e| e == "TERM=xterm"));
        // custom also present
        assert!(env.iter().any(|e| e == "X=1"));
    }

    #[test]
    fn network_mode_default_disabled() {
        let config = SandboxConfig::builder()
            .backend(Backend::Oci)
            .network(false)
            .build();
        assert_eq!(oci_spec::network_mode(&config), "none");
    }

    #[test]
    fn network_mode_when_enabled() {
        let mut config = SandboxConfig::builder().backend(Backend::Oci).build();
        config.policy.network.enabled = true;
        assert_eq!(oci_spec::network_mode(&config), "host");
    }

    #[test]
    fn container_id_has_prefix() {
        let id = oci_spec::container_id("kavach-oci");
        assert!(id.starts_with("kavach-oci-"));
    }

    #[test]
    fn container_ids_are_unique() {
        let id1 = oci_spec::container_id("kavach-oci");
        let id2 = oci_spec::container_id("kavach-oci");
        assert_ne!(id1, id2);
    }

    #[test]
    fn detect_runtime_returns_known_or_none() {
        let rt = detect_runtime();
        if let Some(ref name) = rt {
            assert!(name == "crun" || name == "runc");
        }
    }

    #[test]
    fn generate_spec_basic() {
        let config = SandboxConfig::builder().backend(Backend::Oci).build();
        let spec = oci_spec::generate_spec(&config).unwrap();
        assert_eq!(spec.version(), "1.0.2");
    }

    #[test]
    fn generate_spec_strict_has_limits() {
        let config = SandboxConfig::builder()
            .backend(Backend::Oci)
            .policy(SandboxPolicy::strict())
            .build();
        let spec = oci_spec::generate_spec(&config).unwrap();
        let linux = spec.linux().as_ref().unwrap();
        let resources = linux.resources().as_ref().unwrap();
        assert!(resources.memory().is_some());
        assert!(resources.pids().is_some());
    }

    #[test]
    fn write_and_read_spec() {
        let config = SandboxConfig::builder().backend(Backend::Oci).build();
        let spec = oci_spec::generate_spec(&config).unwrap();
        let dir = tempfile::tempdir().unwrap();
        oci_spec::write_spec(&spec, dir.path()).unwrap();
        let content = std::fs::read_to_string(dir.path().join("config.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert_eq!(parsed["ociVersion"], "1.0.2");
    }

    #[test]
    fn image_manager_detect_does_not_panic() {
        // May succeed or fail depending on available tools
        let _ = OciImageManager::detect();
    }

    #[test]
    fn new_fails_without_runtime() {
        if !Backend::Oci.is_available() {
            let config = SandboxConfig::builder().backend(Backend::Oci).build();
            let err = OciBackend::new(&config).unwrap_err();
            assert!(err.to_string().contains("OCI runtime"));
        }
    }
}