#![allow(unused_assignments)] use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use miette::Diagnostic; use sha2::{Digest, Sha256}; use thiserror::Error; use crate::config::SandcageConfig; // --------------------------------------------------------------------------- // Bundled Dockerfiles // --------------------------------------------------------------------------- const DOCKERFILE_BASE: &str = include_str!("../images/base/Dockerfile"); const DOCKERFILE_CLAUDE: &str = include_str!("../images/claude/Dockerfile"); const DOCKERFILE_CODEX: &str = include_str!("../images/codex/Dockerfile"); const COMPOSE_YAML: &str = include_str!("../compose/docker-compose.yml"); #[derive(Debug, Error, Diagnostic)] pub enum DockerError { #[error("docker executable not found in PATH")] #[diagnostic( code(sandcage::docker::not_found), help("Install Docker: https://docs.docker.com/get-docker/") )] DockerNotFound, #[error("docker compose plugin not available")] #[diagnostic( code(sandcage::docker::compose_not_found), help("Docker Compose V2 is required. Install it via Docker Desktop or 'docker compose' plugin.") )] ComposeNotFound, #[error("Failed to determine current user UID/GID: {0}")] #[diagnostic(code(sandcage::docker::id_failed))] IdFailed(String), #[error("Cannot determine home directory")] #[diagnostic(code(sandcage::docker::no_home_dir))] NoHomeDir, #[error("Failed to write temporary compose file: {0}")] #[diagnostic(code(sandcage::docker::tempfile_failed))] TempfileFailed(#[source] std::io::Error), #[error("Failed to spawn docker compose: {0}")] #[diagnostic(code(sandcage::docker::spawn_failed))] SpawnFailed(#[source] std::io::Error), #[error("Service '{service}' exited with status {code}")] #[diagnostic(code(sandcage::docker::service_failed))] ServiceFailed { service: String, code: i32 }, #[error("Failed to create temporary build directory: {0}")] #[diagnostic(code(sandcage::docker::temp_dir_failed))] TempDirFailed(#[source] std::io::Error), #[error("Failed to write Dockerfile to temp directory: {0}")] #[diagnostic(code(sandcage::docker::dockerfile_write_failed))] DockerfileWriteFailed(#[source] std::io::Error), #[error("Failed to read build-hashes file: {0}")] #[diagnostic(code(sandcage::docker::hash_read_failed))] HashReadFailed(#[source] std::io::Error), #[error("Failed to write build-hashes file: {0}")] #[diagnostic(code(sandcage::docker::hash_write_failed))] HashWriteFailed(#[source] std::io::Error), #[error("docker build for '{image}' exited with status {code}")] #[diagnostic(code(sandcage::docker::build_failed))] BuildFailed { image: String, code: i32 }, #[error("Image '{image}' not found locally")] #[diagnostic( code(sandcage::docker::image_not_found), help("Run `sandcage build` to build the required images.") )] ImageNotFound { image: String }, } pub type Result = std::result::Result; fn require_docker() -> Result { which::which("docker").map_err(|_| DockerError::DockerNotFound) } fn require_compose(docker: &Path) -> Result<()> { let output = Command::new(docker) .args(["compose", "version"]) .output() .map_err(|_| DockerError::ComposeNotFound)?; if !output.status.success() { return Err(DockerError::ComposeNotFound); } Ok(()) } fn id_flag(flag: &str) -> Result { let output = Command::new("id") .arg(flag) .output() .map_err(|e| DockerError::IdFailed(e.to_string()))?; if !output.status.success() { return Err(DockerError::IdFailed(format!( "id {flag} exited with {}", output.status ))); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } pub fn build_compose_env(workspace: &Path) -> Result> { let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; let sandcage_home = home.join(".sandcage"); // On Windows, UID/GID passthrough is meaningless — use the container's // built-in agent user (1000:1000). On Linux, match the host user. let (uid, gid) = if cfg!(windows) { ("1000".to_string(), "1000".to_string()) } else { (id_flag("-u")?, id_flag("-g")?) }; let mut env = HashMap::new(); env.insert("SANDCAGE_UID".into(), uid); env.insert("SANDCAGE_GID".into(), gid); env.insert( "SANDCAGE_WORKSPACE".into(), workspace.to_string_lossy().into_owned(), ); env.insert( "SANDCAGE_HOME".into(), sandcage_home.to_string_lossy().into_owned(), ); env.insert( "SANDCAGE_GLOBAL_JUSTFILE".into(), sandcage_home .join("Justfile") .to_string_lossy() .into_owned(), ); Ok(env) } fn write_compose_tempfile() -> Result { let mut tmp = tempfile::Builder::new() .prefix("sandcage-compose-") .suffix(".yml") .tempfile() .map_err(DockerError::TempfileFailed)?; tmp.write_all(COMPOSE_YAML.as_bytes()) .map_err(DockerError::TempfileFailed)?; tmp.flush().map_err(DockerError::TempfileFailed)?; Ok(tmp) } fn image_for_service(service: &str) -> &'static str { match service { "claude" => "sandcage-claude", "codex" => "sandcage-codex", _ => "sandcage-base", } } fn require_image(docker: &Path, image: &str) -> Result<()> { let output = Command::new(docker) .args(["image", "inspect", image]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); match output { Ok(status) if status.success() => Ok(()), _ => Err(DockerError::ImageNotFound { image: image.to_string(), }), } } pub fn run_service(service: &str, workspace: &Path, config: &SandcageConfig) -> Result<()> { let docker = require_docker()?; require_compose(&docker)?; let image = image_for_service(service); require_image(&docker, image)?; let compose_file = write_compose_tempfile()?; let compose_path = compose_file.path().to_string_lossy().into_owned(); let compose_env = build_compose_env(workspace)?; // Build the command let mut cmd = Command::new(&docker); cmd.args(["compose", "-f", &compose_path, "run", "--rm"]); // Add extra -e flags from the merged config env map if let Some(ref env_map) = config.env { for (key, value) in env_map { cmd.args(["-e", &format!("{key}={value}")]); } } cmd.arg(service); // Inject compose environment variables cmd.envs(&compose_env); // Inherit stdio for interactive use cmd.stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()); let status = cmd.status().map_err(DockerError::SpawnFailed)?; if !status.success() { return Err(DockerError::ServiceFailed { service: service.to_string(), code: status.code().unwrap_or(-1), }); } Ok(()) } // --------------------------------------------------------------------------- // Build with cache awareness // --------------------------------------------------------------------------- /// SHA-256 hex digest of the given string content. fn sha256_hex(content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); hex::encode(hasher.finalize()) } /// Path to the build-hashes file: `~/.sandcage/.build-hashes`. fn build_hashes_path() -> Result { let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; Ok(home.join(".sandcage").join(".build-hashes")) } /// Read the stored hashes from `~/.sandcage/.build-hashes`. /// Returns an empty map if the file does not exist. fn read_stored_hashes(path: &Path) -> Result> { if !path.exists() { return Ok(HashMap::new()); } let content = std::fs::read_to_string(path).map_err(DockerError::HashReadFailed)?; let mut map = HashMap::new(); for line in content.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some((name, hash)) = line.split_once(':') { map.insert(name.to_string(), hash.to_string()); } } Ok(map) } /// Persist the full hashes map back to the file. fn write_stored_hashes(path: &Path, hashes: &HashMap) -> Result<()> { // Ensure parent directory exists. if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(DockerError::HashWriteFailed)?; } // Build deterministic output (sorted keys). let mut lines: Vec = hashes .iter() .map(|(k, v)| format!("{k}:{v}")) .collect(); lines.sort(); let content = lines.join("\n") + "\n"; std::fs::write(path, content).map_err(DockerError::HashWriteFailed)?; Ok(()) } /// Build a single image from inline Dockerfile content (temp build context). fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> { let tmp_dir = tempfile::Builder::new() .prefix("sandcage-build-") .tempdir() .map_err(DockerError::TempDirFailed)?; let dockerfile_path = tmp_dir.path().join("Dockerfile"); std::fs::write(&dockerfile_path, dockerfile_content) .map_err(DockerError::DockerfileWriteFailed)?; run_docker_build(docker, image, tmp_dir.path(), None) } /// Build a single image from a user-provided path. /// If the path is a directory, it's used as the build context. /// If the path is a file, it's used as the Dockerfile with a temp context. fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path) -> Result<()> { if override_path.is_dir() { run_docker_build(docker, image, override_path, None) } else { let tmp_dir = tempfile::Builder::new() .prefix("sandcage-build-") .tempdir() .map_err(DockerError::TempDirFailed)?; run_docker_build(docker, image, tmp_dir.path(), Some(override_path)) } } fn run_docker_build( docker: &Path, image: &str, context: &Path, dockerfile: Option<&Path>, ) -> Result<()> { let mut cmd = Command::new(docker); cmd.args(["build", "-t", &format!("{image}:latest")]); if let Some(df) = dockerfile { cmd.args(["-f", &df.to_string_lossy()]); } cmd.arg(context) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()); let status = cmd.status().map_err(DockerError::SpawnFailed)?; if !status.success() { return Err(DockerError::BuildFailed { image: image.to_string(), code: status.code().unwrap_or(-1), }); } Ok(()) } /// Resolve the Dockerfile content for hashing purposes. /// Returns the content string for override files/dirs, or None if the path is invalid. fn read_dockerfile_at(path: &Path) -> Result { let dockerfile_path = if path.is_dir() { path.join("Dockerfile") } else { path.to_path_buf() }; std::fs::read_to_string(&dockerfile_path).map_err(DockerError::HashReadFailed) } /// Build all three sandcage images with cache awareness. /// /// * Resolves layered config to check for Dockerfile overrides. /// * When `force` is `true`, every image is rebuilt regardless of the stored hash. /// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value. /// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`. pub fn build_images(force: bool) -> Result<()> { let docker = require_docker()?; let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; let global_config = home.join(".sandcage").join("config.toml"); let project_dir = std::env::current_dir().unwrap_or_default(); let project_config = project_dir.join(".sandcage.yml"); // Global overrides are always trusted. let global_cfg = crate::config::resolve_config(Some(&global_config), None) .unwrap_or_default(); let mut overrides = global_cfg.dockerfiles.unwrap_or_default(); // Project overrides require the project to be in trusted_projects. let project_cfg = if project_config.exists() { crate::config::load(&project_config).ok() } else { None }; if let Some(ref pcfg) = project_cfg && pcfg.dockerfiles.is_some() { let trusted = crate::config::load_trusted_projects(&global_config); let is_trusted = trusted.iter().any(|p| { let canonical_trusted = std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()); let canonical_project = std::fs::canonicalize(&project_dir) .unwrap_or_else(|_| project_dir.clone()); canonical_project.starts_with(canonical_trusted) }); if is_trusted { overrides.extend(pcfg.dockerfiles.clone().unwrap_or_default()); } else { eprintln!( "sandcage: warning: project Dockerfile overrides ignored (not in trusted_projects)" ); eprintln!( "sandcage: add this project to trusted_projects in ~/.sandcage/config.toml" ); } } let images: &[(&str, &str)] = &[ ("sandcage-base", DOCKERFILE_BASE), ("sandcage-claude", DOCKERFILE_CLAUDE), ("sandcage-codex", DOCKERFILE_CODEX), ]; let hashes_path = build_hashes_path()?; let mut stored = read_stored_hashes(&hashes_path)?; for (image, bundled_dockerfile) in images { let short_name = image.strip_prefix("sandcage-").unwrap_or(image); let override_path = overrides.get(short_name); let current_hash = if let Some(path) = override_path { sha256_hex(&read_dockerfile_at(path)?) } else { sha256_hex(bundled_dockerfile) }; let needs_build = force || stored.get(*image) != Some(¤t_hash); if needs_build { if force { eprintln!("{image}: rebuilding (--force)"); } else if override_path.is_some() { eprintln!("{image}: rebuilding (custom Dockerfile changed)"); } else { eprintln!("{image}: rebuilding (Dockerfile changed)"); } if let Some(path) = override_path { build_one_image_from_path(&docker, image, path)?; } else { build_one_image_from_content(&docker, image, bundled_dockerfile)?; } stored.insert(image.to_string(), current_hash); write_stored_hashes(&hashes_path, &stored)?; } else { eprintln!("{image}: up to date"); } } Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] fn compose_yaml_is_bundled() { assert!( COMPOSE_YAML.contains("services:"), "bundled compose YAML should contain 'services:'" ); assert!( COMPOSE_YAML.contains("claude:"), "bundled compose YAML should define 'claude' service" ); assert!( COMPOSE_YAML.contains("codex:"), "bundled compose YAML should define 'codex' service" ); assert!( COMPOSE_YAML.contains("shell:"), "bundled compose YAML should define 'shell' service" ); } #[test] fn build_compose_env_contains_required_keys() { let workspace = PathBuf::from("/tmp/test-workspace"); let env = build_compose_env(&workspace).expect("build_compose_env"); assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID"); assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID"); assert!(env.contains_key("SANDCAGE_WORKSPACE"), "missing SANDCAGE_WORKSPACE"); assert!(env.contains_key("SANDCAGE_HOME"), "missing SANDCAGE_HOME"); assert!( env.contains_key("SANDCAGE_GLOBAL_JUSTFILE"), "missing SANDCAGE_GLOBAL_JUSTFILE" ); } #[test] fn build_compose_env_workspace_matches() { let workspace = PathBuf::from("/my/project"); let env = build_compose_env(&workspace).expect("build_compose_env"); assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project"); } #[test] fn build_compose_env_home_ends_with_sandcage() { let workspace = PathBuf::from("/tmp"); let env = build_compose_env(&workspace).expect("build_compose_env"); assert!( env["SANDCAGE_HOME"].ends_with(".sandcage"), "SANDCAGE_HOME should end with .sandcage, got: {}", env["SANDCAGE_HOME"] ); } #[test] fn build_compose_env_justfile_is_under_home() { let workspace = PathBuf::from("/tmp"); let env = build_compose_env(&workspace).expect("build_compose_env"); assert!( env["SANDCAGE_GLOBAL_JUSTFILE"].starts_with(&env["SANDCAGE_HOME"]), "SANDCAGE_GLOBAL_JUSTFILE should be under SANDCAGE_HOME" ); assert!( env["SANDCAGE_GLOBAL_JUSTFILE"].ends_with("Justfile"), "SANDCAGE_GLOBAL_JUSTFILE should end with Justfile" ); } #[test] fn uid_gid_are_numeric() { let workspace = PathBuf::from("/tmp"); let env = build_compose_env(&workspace).expect("build_compose_env"); let uid: u32 = env["SANDCAGE_UID"] .parse() .expect("UID should be numeric"); let gid: u32 = env["SANDCAGE_GID"] .parse() .expect("GID should be numeric"); // Basic sanity — UIDs/GIDs are non-negative (they're u32) assert!(uid < 1_000_000, "UID seems unreasonably large: {uid}"); assert!(gid < 1_000_000, "GID seems unreasonably large: {gid}"); } #[test] fn write_compose_tempfile_creates_valid_yaml() { let tmp = write_compose_tempfile().expect("write_compose_tempfile"); let content = std::fs::read_to_string(tmp.path()).expect("read tempfile"); assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML"); } #[test] fn require_docker_finds_docker_or_fails_cleanly() { // This test documents the behavior — it either finds docker or returns // DockerNotFound. We don't assert which, since CI may or may not have docker. let result = require_docker(); match result { Ok(path) => assert!(path.to_string_lossy().contains("docker")), Err(DockerError::DockerNotFound) => { /* expected on machines without docker */ } Err(e) => panic!("unexpected error: {e}"), } } // ----------------------------------------------------------------------- // build_images / cache-awareness tests // ----------------------------------------------------------------------- #[test] fn dockerfiles_are_bundled() { assert!(!DOCKERFILE_BASE.is_empty(), "base Dockerfile should not be empty"); assert!(!DOCKERFILE_CLAUDE.is_empty(), "claude Dockerfile should not be empty"); assert!(!DOCKERFILE_CODEX.is_empty(), "codex Dockerfile should not be empty"); } #[test] fn sha256_hex_is_deterministic() { let content = "FROM ubuntu:22.04\nRUN echo hello\n"; let h1 = sha256_hex(content); let h2 = sha256_hex(content); assert_eq!(h1, h2, "sha256_hex must be deterministic"); // SHA-256 produces 64 hex chars assert_eq!(h1.len(), 64, "sha256_hex should produce 64-char hex string"); } #[test] fn sha256_hex_differs_for_different_input() { let h1 = sha256_hex("FROM ubuntu:22.04\n"); let h2 = sha256_hex("FROM debian:bookworm\n"); assert_ne!(h1, h2, "different content must yield different hash"); } #[test] fn hash_file_roundtrip() { let tmp = tempfile::tempdir().expect("create temp dir"); let path = tmp.path().join(".build-hashes"); let mut hashes = HashMap::new(); hashes.insert("sandcage-base".to_string(), "aabbcc".to_string()); hashes.insert("sandcage-claude".to_string(), "ddeeff".to_string()); write_stored_hashes(&path, &hashes).expect("write hashes"); let loaded = read_stored_hashes(&path).expect("read hashes"); assert_eq!(loaded.get("sandcage-base").map(String::as_str), Some("aabbcc")); assert_eq!(loaded.get("sandcage-claude").map(String::as_str), Some("ddeeff")); } #[test] fn read_stored_hashes_returns_empty_when_file_missing() { let tmp = tempfile::tempdir().expect("create temp dir"); let path = tmp.path().join("nonexistent"); let result = read_stored_hashes(&path).expect("read hashes on missing file"); assert!(result.is_empty(), "should return empty map when file does not exist"); } #[test] fn cache_hit_detection_same_hash() { let dockerfile = "FROM ubuntu:22.04\n"; let hash = sha256_hex(dockerfile); let mut stored: HashMap = HashMap::new(); stored.insert("sandcage-base".to_string(), hash.clone()); let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &hash); assert!(!needs_build, "same hash should be a cache hit (no rebuild needed)"); } #[test] fn cache_miss_detection_different_hash() { let dockerfile = "FROM ubuntu:22.04\n"; let current_hash = sha256_hex(dockerfile); let mut stored: HashMap = HashMap::new(); stored.insert("sandcage-base".to_string(), "old_stale_hash".to_string()); let needs_build = stored.get("sandcage-base").map_or(true, |h| h != ¤t_hash); assert!(needs_build, "different hash should be a cache miss (rebuild needed)"); } #[test] fn cache_miss_detection_missing_entry() { let dockerfile = "FROM ubuntu:22.04\n"; let current_hash = sha256_hex(dockerfile); let stored: HashMap = HashMap::new(); let needs_build = stored.get("sandcage-base").map_or(true, |h| h != ¤t_hash); assert!(needs_build, "missing entry should be treated as a cache miss"); } #[test] fn force_flag_bypasses_cache() { // Even when hash matches, force=true means needs_build is true let dockerfile = "FROM ubuntu:22.04\n"; let hash = sha256_hex(dockerfile); let mut stored: HashMap = HashMap::new(); stored.insert("sandcage-base".to_string(), hash.clone()); let force = true; let needs_build = force || stored.get("sandcage-base").map_or(true, |h| h != &hash); assert!(needs_build, "force flag should always trigger a rebuild"); } #[test] fn write_stored_hashes_creates_parent_dirs() { let tmp = tempfile::tempdir().expect("create temp dir"); // Nested path that doesn't exist yet let path = tmp.path().join("a").join("b").join(".build-hashes"); let mut hashes = HashMap::new(); hashes.insert("sandcage-base".to_string(), "abc123".to_string()); write_stored_hashes(&path, &hashes).expect("should create parent dirs and write"); assert!(path.exists(), "hash file should have been created"); } #[test] fn image_for_service_maps_correctly() { assert_eq!(image_for_service("claude"), "sandcage-claude"); assert_eq!(image_for_service("codex"), "sandcage-codex"); assert_eq!(image_for_service("shell"), "sandcage-base"); assert_eq!(image_for_service("anything-else"), "sandcage-base"); } #[test] fn require_image_fails_for_nonexistent_image() { let docker = match require_docker() { Ok(path) => path, Err(_) => return, // skip if docker not installed }; let result = require_image(&docker, "sandcage-nonexistent-image-abc123"); assert!( matches!(result, Err(DockerError::ImageNotFound { .. })), "should fail for nonexistent image: {result:?}" ); } }