sboxd 0.1.8

Policy-driven command runner for sandboxed dependency installation
Documentation
use std::path::Path;
use std::process::{Command, ExitCode, Stdio};

use crate::cli::{CleanCommand, Cli};
use crate::config::{LoadOptions, load_config};
use crate::error::SboxError;

pub fn execute(cli: &Cli, command: &CleanCommand) -> Result<ExitCode, SboxError> {
    let scope = CleanScope::from_command(command);
    let loaded = load_config(&LoadOptions {
        workspace: cli.workspace.clone(),
        config: cli.config.clone(),
    })?;

    let mut removed = Vec::new();

    if scope.sessions {
        let session_names = reusable_session_names(&loaded.config, &loaded.workspace_root);
        if session_names.is_empty() {
            println!("sessions: no reusable sessions configured for this workspace");
        } else {
            for name in session_names {
                remove_podman_container(&name)?;
                removed.push(format!("session:{name}"));
            }
        }
    }

    if scope.images {
        if let Some(tag) = derived_image_tag(&loaded.config, &loaded.workspace_root) {
            remove_podman_image(&tag)?;
            removed.push(format!("image:{tag}"));
        } else {
            println!("images: no workspace-derived image configured");
        }
    }

    if scope.caches {
        let names = cache_volume_names(&loaded.config.caches, &loaded.workspace_root);
        if names.is_empty() {
            println!("caches: no workspace caches configured");
        } else {
            for name in names {
                remove_podman_volume(&name)?;
                removed.push(format!("cache:{name}"));
            }
        }
    }

    if removed.is_empty() {
        println!("clean: nothing removed");
    } else {
        println!("clean: removed {}", removed.join(", "));
    }

    Ok(ExitCode::SUCCESS)
}

#[derive(Debug, Clone, Copy)]
struct CleanScope {
    sessions: bool,
    images: bool,
    caches: bool,
}

impl CleanScope {
    fn from_command(command: &CleanCommand) -> Self {
        if command.all {
            return Self {
                sessions: true,
                images: true,
                caches: true,
            };
        }

        if !command.sessions && !command.images && !command.caches {
            return Self {
                sessions: true,
                images: false,
                caches: false,
            };
        }

        Self {
            sessions: command.sessions,
            images: command.images,
            caches: command.caches,
        }
    }
}

fn derived_image_tag(
    config: &crate::config::model::Config,
    workspace_root: &Path,
) -> Option<String> {
    let image = config.image.as_ref()?;
    let build = image.build.as_ref()?;
    if let Some(tag) = &image.tag {
        return Some(tag.clone());
    }

    let recipe_path = if build.is_absolute() {
        build.clone()
    } else {
        workspace_root.join(build)
    };

    Some(format!(
        "sbox-build-{}",
        stable_hash(&recipe_path.display().to_string())
    ))
}

fn cache_volume_names(
    caches: &[crate::config::model::CacheConfig],
    workspace_root: &Path,
) -> Vec<String> {
    caches
        .iter()
        .filter(|cache| cache.source.is_none())
        .map(|cache| {
            format!(
                "sbox-cache-{}-{}",
                stable_hash(&workspace_root.display().to_string()),
                sanitize_volume_name(&cache.name)
            )
        })
        .collect()
}

fn reusable_session_names(
    config: &crate::config::model::Config,
    workspace_root: &Path,
) -> Vec<String> {
    let runtime_reuse = config
        .runtime
        .as_ref()
        .and_then(|runtime| runtime.reuse_container)
        .unwrap_or(false);
    let template = config
        .runtime
        .as_ref()
        .and_then(|runtime| runtime.container_name.as_ref());

    config
        .profiles
        .iter()
        .filter(|(_, profile)| profile.reuse_container.unwrap_or(runtime_reuse))
        .map(|(profile_name, _)| reusable_session_name(template, workspace_root, profile_name))
        .collect()
}

fn remove_podman_image(tag: &str) -> Result<(), SboxError> {
    let status = Command::new("podman")
        .args(["image", "rm", "-f", tag])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map_err(|source| SboxError::BackendUnavailable {
            backend: "podman".to_string(),
            source,
        })?;

    if status.success() || status.code() == Some(1) {
        Ok(())
    } else {
        Err(SboxError::BackendCommandFailed {
            backend: "podman".to_string(),
            command: format!("podman image rm -f {tag}"),
            status: status.code().unwrap_or(1),
        })
    }
}

fn remove_podman_volume(name: &str) -> Result<(), SboxError> {
    let status = Command::new("podman")
        .args(["volume", "rm", "-f", name])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map_err(|source| SboxError::BackendUnavailable {
            backend: "podman".to_string(),
            source,
        })?;

    if status.success() || status.code() == Some(1) {
        Ok(())
    } else {
        Err(SboxError::BackendCommandFailed {
            backend: "podman".to_string(),
            command: format!("podman volume rm -f {name}"),
            status: status.code().unwrap_or(1),
        })
    }
}

fn remove_podman_container(name: &str) -> Result<(), SboxError> {
    let status = Command::new("podman")
        .args(["rm", "-f", name])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map_err(|source| SboxError::BackendUnavailable {
            backend: "podman".to_string(),
            source,
        })?;

    if status.success() || status.code() == Some(1) {
        Ok(())
    } else {
        Err(SboxError::BackendCommandFailed {
            backend: "podman".to_string(),
            command: format!("podman rm -f {name}"),
            status: status.code().unwrap_or(1),
        })
    }
}

fn sanitize_volume_name(name: &str) -> String {
    name.chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {
                ch
            } else {
                '-'
            }
        })
        .collect()
}

fn stable_hash(input: &str) -> String {
    let mut hash = 0xcbf29ce484222325u64;
    for byte in input.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    format!("{hash:016x}")
}

fn reusable_session_name(
    template: Option<&String>,
    workspace_root: &Path,
    profile_name: &str,
) -> String {
    let workspace_hash = stable_hash(&workspace_root.display().to_string());
    let base = template
        .map(|template| {
            template
                .replace("{profile}", profile_name)
                .replace("{workspace_hash}", &workspace_hash)
        })
        .unwrap_or_else(|| format!("sbox-{workspace_hash}-{profile_name}"));

    sanitize_volume_name(&base)
}

#[cfg(test)]
mod tests {
    use super::{CleanScope, cache_volume_names, reusable_session_names};
    use crate::cli::CleanCommand;
    use crate::config::model::{
        BackendKind, CacheConfig, Config, ExecutionMode, ProfileConfig, RuntimeConfig,
    };
    use indexmap::IndexMap;
    use std::path::Path;

    #[test]
    fn defaults_to_sessions_only() {
        let scope = CleanScope::from_command(&CleanCommand::default());
        assert!(scope.sessions);
        assert!(!scope.images);
        assert!(!scope.caches);
    }

    #[test]
    fn all_flag_enables_every_cleanup_target() {
        let scope = CleanScope::from_command(&CleanCommand {
            all: true,
            ..CleanCommand::default()
        });
        assert!(scope.sessions);
        assert!(scope.images);
        assert!(scope.caches);
    }

    #[test]
    fn cache_volume_names_only_include_implicit_volumes() {
        let caches = vec![
            CacheConfig {
                name: "first".into(),
                target: "/cache".into(),
                source: None,
                read_only: None,
            },
            CacheConfig {
                name: "host".into(),
                target: "/host".into(),
                source: Some("./cache".into()),
                read_only: None,
            },
        ];

        let names = cache_volume_names(&caches, Path::new("/tmp/workspace"));
        assert_eq!(names.len(), 1);
        assert!(names[0].contains("sbox-cache-"));
    }

    #[test]
    fn reusable_session_names_follow_runtime_defaults() {
        let mut profiles = IndexMap::new();
        profiles.insert(
            "default".to_string(),
            ProfileConfig {
                mode: ExecutionMode::Sandbox,
                image: None,
                network: Some("off".into()),
                writable: Some(true),
                require_pinned_image: None,
                require_lockfile: None,
                role: None,
                lockfile_files: Vec::new(),
                pre_run: Vec::new(),
                network_allow: Vec::new(),
                ports: Vec::new(),
                capabilities: None,
                no_new_privileges: Some(true),
                read_only_rootfs: None,
                reuse_container: None,
                shell: None,

                writable_paths: None,
            },
        );
        let config = Config {
            version: 1,
            runtime: Some(RuntimeConfig {
                backend: Some(BackendKind::Podman),
                rootless: Some(true),
                reuse_container: Some(true),
                container_name: None,
                pull_policy: None,
                strict_security: None,
                require_pinned_image: None,
            }),
            workspace: None,
            identity: None,
            image: None,
            environment: None,
            mounts: Vec::new(),
            caches: Vec::new(),
            secrets: Vec::new(),
            profiles,
            dispatch: IndexMap::new(),

            package_manager: None,
        };

        let names = reusable_session_names(&config, Path::new("/tmp/workspace"));
        assert_eq!(names.len(), 1);
        assert!(names[0].starts_with("sbox-"));
    }
}