use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
const OVERLAY_NAME: &str = "Dockerfile";
pub(crate) const LOCAL_LABEL: &str = "csb.local.sha256";
pub(crate) struct Overlay {
pub dir: PathBuf,
pub content: String,
}
pub(crate) fn config_dir(lookup: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
if let Some(xdg) = lookup("XDG_CONFIG_HOME").filter(|v| !v.trim().is_empty()) {
return Some(PathBuf::from(xdg).join("csb"));
}
lookup("HOME")
.filter(|v| !v.trim().is_empty())
.map(|home| PathBuf::from(home).join(".config").join("csb"))
}
pub(crate) fn discover() -> Result<Option<Overlay>> {
let Some(dir) = config_dir(|key| std::env::var(key).ok()) else {
return Ok(None);
};
let path = dir.join(OVERLAY_NAME);
if !path.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read overlay {}", path.display()))?;
Ok(Some(Overlay { dir, content }))
}
pub(crate) fn compose(content: &str, base_image: &str) -> String {
if has_from(content) {
return content.to_string();
}
format!("FROM {base_image}\n\n{content}")
}
fn has_from(content: &str) -> bool {
content.lines().any(|line| {
let line = line.trim_start();
line.len() >= 4 && line[..4].eq_ignore_ascii_case("from") && {
let rest = &line[4..];
rest.starts_with(char::is_whitespace)
}
})
}
pub(crate) fn hash(composed: &str, base_hash: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(composed.as_bytes());
hasher.update(b"\0");
hasher.update(base_hash.as_bytes());
format!("{:x}", hasher.finalize())
}
pub(crate) fn derived_image_name(image: &str) -> String {
match image.rsplit_once(':') {
Some((name, tag)) if !tag.contains('/') => format!("{name}-local:{tag}"),
_ => format!("{image}-local"),
}
}
pub(crate) fn build(
overlay: &Overlay,
base_image: &str,
derived: &str,
base_hash: &str,
) -> Result<()> {
let composed = compose(&overlay.content, base_image);
let label = format!("{LOCAL_LABEL}={}", hash(&composed, base_hash));
let dir = tempfile::tempdir().context("failed to create temp build directory")?;
let dockerfile = dir.path().join(OVERLAY_NAME);
std::fs::write(&dockerfile, &composed)
.context("failed to write composed overlay Dockerfile")?;
let status = crate::container::run_interactive(&[
"build",
"-t",
derived,
"-f",
path_str(&dockerfile)?,
"--label",
&label,
path_str(&overlay.dir)?,
]);
crate::container::builder_stop();
if !status?.success() {
anyhow::bail!("overlay image build failed");
}
Ok(())
}
fn path_str(path: &Path) -> Result<&str> {
path.to_str()
.with_context(|| format!("path is not valid UTF-8: {}", path.display()))
}
pub(crate) fn staleness(inspect_json: &str, expected: &str) -> Option<String> {
match crate::image::find_label(inspect_json, LOCAL_LABEL) {
Some(found) if found == expected => None,
Some(_) => Some("Your ~/.config/csb/Dockerfile changed since this image was built.".into()),
None => Some("Local image is missing its csb overlay label.".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compose_should_supply_the_base_when_the_overlay_has_no_from() {
let composed = compose("RUN npm install -g context-mode\n", "claude-box");
assert!(composed.starts_with("FROM claude-box\n"));
assert!(composed.contains("RUN npm install -g context-mode"));
}
#[test]
fn compose_should_respect_a_hand_written_from() {
let overlay = "FROM my-own-base\nRUN true\n";
assert_eq!(compose(overlay, "claude-box"), overlay);
}
#[test]
fn compose_should_detect_from_case_insensitively_and_indented() {
assert_eq!(compose(" from x\n", "claude-box"), " from x\n");
}
#[test]
fn compose_should_not_mistake_other_directives_for_from() {
let overlay = "RUN echo fromage\nENV FROMAGE=1\n";
assert!(compose(overlay, "claude-box").starts_with("FROM claude-box"));
}
#[test]
fn hash_should_change_with_overlay_or_base() {
let a = hash("FROM x\nRUN a", "base1");
assert_eq!(a, hash("FROM x\nRUN a", "base1"));
assert_ne!(a, hash("FROM x\nRUN b", "base1"));
assert_ne!(a, hash("FROM x\nRUN a", "base2"));
}
#[test]
fn derived_name_should_preserve_tags() {
assert_eq!(derived_image_name("claude-box"), "claude-box-local");
assert_eq!(derived_image_name("claude-box:v2"), "claude-box-local:v2");
assert_eq!(derived_image_name("ghcr.io:5000/x"), "ghcr.io:5000/x-local");
}
#[test]
fn config_dir_should_prefer_xdg_then_home() {
let xdg = config_dir(|k| match k {
"XDG_CONFIG_HOME" => Some("/x/cfg".into()),
"HOME" => Some("/Users/me".into()),
_ => None,
});
assert_eq!(xdg.unwrap(), PathBuf::from("/x/cfg/csb"));
let home = config_dir(|k| (k == "HOME").then(|| "/Users/me".to_string()));
assert_eq!(home.unwrap(), PathBuf::from("/Users/me/.config/csb"));
assert!(config_dir(|_| None).is_none());
assert!(config_dir(|k| (k == "XDG_CONFIG_HOME").then(|| " ".to_string())).is_none());
}
#[test]
fn staleness_should_flag_changed_and_unlabelled_images() {
let current = r#"{"Labels":{"csb.local.sha256":"abc"}}"#;
assert_eq!(staleness(current, "abc"), None);
assert!(staleness(current, "def").is_some());
assert!(staleness(r#"{"Labels":{}}"#, "abc").is_some());
}
}