🥇 export from upstream (fe643f3)

This commit is contained in:
sandcage-export
2026-05-22 01:57:18 +02:00
parent 1bc5dc11dc
commit f2f7206d06
3 changed files with 153 additions and 37 deletions
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from sandcage!")
if __name__ == "__main__":
main()
+41 -17
View File
@@ -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<figment::Error>),
}
pub type Result<T> = std::result::Result<T, ConfigError>;
@@ -47,6 +48,10 @@ struct RawConfig {
shell: Option<String>,
#[serde(default)]
justfile: Option<PathBuf>,
#[serde(default)]
dockerfiles: Option<HashMap<String, PathBuf>>,
#[serde(default)]
trusted_projects: Option<Vec<PathBuf>>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
@@ -72,13 +77,17 @@ pub struct SandcageConfig {
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justfile: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dockerfiles: Option<HashMap<String, PathBuf>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trusted_projects: Option<Vec<PathBuf>>,
}
// ---------------------------------------------------------------------------
// 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<SandcageConfig> {
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<SandcageConfig> {
// Empty / whitespace-only content → all fields None
if content.trim().is_empty() {
@@ -160,6 +171,21 @@ pub fn load_from_str(content: &str) -> Result<SandcageConfig> {
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<PathBuf> {
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)))
}
// ---------------------------------------------------------------------------
+112 -14
View File
@@ -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<String, String>) -> Result<
Ok(())
}
/// Build a single image.
/// Writes the `dockerfile_content` to a temp directory, then invokes
/// `docker build -t <image>:latest <temp_dir>`.
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<String> {
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 != &current_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(&current_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 {