diff --git a/main.py b/main.py deleted file mode 100644 index a710537..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from sandcage!") - - -if __name__ == "__main__": - main() diff --git a/src/config.rs b/src/config.rs index c0f5cfb..a45853f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,11 +19,12 @@ pub enum ConfigError { #[error("Failed to parse YAML: {0}")] #[diagnostic(code(sandcage::config::parse_failed_str))] + #[allow(dead_code)] ParseFailedStr(#[source] serde_yaml::Error), #[error("Failed to merge configuration layers: {0}")] #[diagnostic(code(sandcage::config::merge_failed))] - MergeFailed(#[source] figment::Error), + MergeFailed(#[source] Box), } pub type Result = std::result::Result; @@ -47,6 +48,10 @@ struct RawConfig { shell: Option, #[serde(default)] justfile: Option, + #[serde(default)] + dockerfiles: Option>, + #[serde(default)] + trusted_projects: Option>, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -72,13 +77,17 @@ pub struct SandcageConfig { pub shell: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justfile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dockerfiles: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_projects: Option>, } // --------------------------------------------------------------------------- // Known keys — used to filter the flattened `extra` map // --------------------------------------------------------------------------- -const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile"]; +const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects"]; // --------------------------------------------------------------------------- // Validation helpers @@ -127,6 +136,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig { mounts: raw.mounts, shell: raw.shell, justfile: raw.justfile, + dockerfiles: raw.dockerfiles, + trusted_projects: raw.trusted_projects, } } @@ -146,7 +157,7 @@ pub fn load(path: &Path) -> Result { Ok(from_raw(raw)) } -/// Parse a `.sandcage.yml` from an in-memory string (useful for testing). +#[cfg(test)] pub fn load_from_str(content: &str) -> Result { // Empty / whitespace-only content → all fields None if content.trim().is_empty() { @@ -160,6 +171,21 @@ pub fn load_from_str(content: &str) -> Result { Ok(from_raw(raw)) } +/// Load trusted_projects from the global config only. +/// This must never be read from project-level config (a project cannot self-trust). +pub fn load_trusted_projects(global_config_path: &Path) -> Vec { + if !global_config_path.exists() { + return Vec::new(); + } + + let cfg: SandcageConfig = Figment::from(Serialized::defaults(SandcageConfig::default())) + .merge(Toml::file(global_config_path)) + .extract() + .unwrap_or_default(); + + cfg.trusted_projects.unwrap_or_default() +} + // --------------------------------------------------------------------------- // Layered config resolution via figment // --------------------------------------------------------------------------- @@ -180,24 +206,22 @@ pub fn resolve_config( // Start with compiled defaults — all fields None. let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default())); - // Layer 2: global TOML config (if provided). - if let Some(global_path) = global_config_path { - if global_path.exists() { - figment = figment.merge(Toml::file(global_path)); - } + if let Some(global_path) = global_config_path + && global_path.exists() + { + figment = figment.merge(Toml::file(global_path)); } - // Layer 3: project YAML config (if provided). - // figment has no built-in YAML provider, so we parse with serde_yaml first - // and inject via Serialized. - if let Some(project_path) = project_config_path { - if project_path.exists() { - let project_cfg = load(project_path)?; - figment = figment.merge(Serialized::defaults(project_cfg)); - } + if let Some(project_path) = project_config_path + && project_path.exists() + { + let project_cfg = load(project_path)?; + figment = figment.merge(Serialized::defaults(project_cfg)); } - figment.extract().map_err(ConfigError::MergeFailed) + figment + .extract() + .map_err(|e| ConfigError::MergeFailed(Box::new(e))) } // --------------------------------------------------------------------------- diff --git a/src/docker.rs b/src/docker.rs index 5c64cb6..9c4a158 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -1,3 +1,4 @@ +#![allow(unused_assignments)] use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; @@ -285,10 +286,8 @@ fn write_stored_hashes(path: &Path, hashes: &HashMap) -> Result< Ok(()) } -/// Build a single image. -/// Writes the `dockerfile_content` to a temp directory, then invokes -/// `docker build -t :latest `. -fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> { +/// 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() @@ -298,14 +297,44 @@ fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Resu std::fs::write(&dockerfile_path, dockerfile_content) .map_err(DockerError::DockerfileWriteFailed)?; - let status = Command::new(docker) - .args(["build", "-t", &format!("{image}:latest")]) - .arg(tmp_dir.path()) + 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()) - .status() - .map_err(DockerError::SpawnFailed)?; + .stderr(std::process::Stdio::inherit()); + + let status = cmd.status().map_err(DockerError::SpawnFailed)?; if !status.success() { return Err(DockerError::BuildFailed { @@ -317,14 +346,67 @@ fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Resu 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), @@ -334,17 +416,33 @@ pub fn build_images(force: bool) -> Result<()> { let hashes_path = build_hashes_path()?; let mut stored = read_stored_hashes(&hashes_path)?; - for (image, dockerfile) in images { - let current_hash = sha256_hex(dockerfile); - let needs_build = force || stored.get(*image).map_or(true, |h| h != ¤t_hash); + 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)"); } - build_one_image(&docker, image, dockerfile)?; + + 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 {