🥇 export from upstream (3b6ef04)

This commit is contained in:
2026-05-23 17:11:31 +02:00
parent 1e5cdf2476
commit 047fd9512d
9 changed files with 665 additions and 90 deletions
+90 -33
View File
@@ -14,9 +14,7 @@ use crate::config::SandcageConfig;
// Bundled Dockerfiles
// ---------------------------------------------------------------------------
pub const DOCKERFILE_BASE: &str = include_str!("../../../images/base/Dockerfile");
pub const DOCKERFILE_CLAUDE: &str = include_str!("../../../images/claude/Dockerfile");
pub const DOCKERFILE_CODEX: &str = include_str!("../../../images/codex/Dockerfile");
pub const DOCKERFILE: &str = include_str!("../../../images/base/Dockerfile");
pub const COMPOSE_YAML: &str = include_str!("../../../compose/docker-compose.yml");
@@ -179,12 +177,8 @@ fn write_compose_tempfile() -> Result<tempfile::NamedTempFile> {
Ok(tmp)
}
fn image_for_service(service: &str) -> &'static str {
match service {
"claude" => "sandcage-claude",
"codex" => "sandcage-codex",
_ => "sandcage-base",
}
fn image_for_service(_service: &str) -> &'static str {
"sandcage"
}
fn require_image(docker: &Path, image: &str) -> Result<()> {
@@ -202,6 +196,32 @@ fn require_image(docker: &Path, image: &str) -> Result<()> {
}
}
fn expand_mount_path(mount: &str) -> String {
let Some(colon_pos) = mount.find(':') else {
return mount.to_string();
};
let host_path = &mount[..colon_pos];
let rest = &mount[colon_pos..];
let expanded = if host_path == "~" {
match dirs::home_dir() {
Some(home) => home.to_string_lossy().into_owned(),
None => return mount.to_string(),
}
} else if let Some(suffix) = host_path.strip_prefix("~/") {
match dirs::home_dir() {
Some(home) => format!("{}/{suffix}", home.to_string_lossy()),
None => return mount.to_string(),
}
} else {
return mount.to_string();
};
let normalized = expanded.replace('\\', "/");
format!("{normalized}{rest}")
}
pub fn build_run_args(
service: &str,
compose_path: &str,
@@ -228,7 +248,7 @@ pub fn build_run_args(
for mount in mount_list {
if mount.contains(':') {
args.push("-v".to_string());
args.push(mount.clone());
args.push(expand_mount_path(mount));
}
}
}
@@ -484,9 +504,7 @@ pub fn build_images(force: bool) -> Result<()> {
let images: &[(&str, &str)] = &[
("sandcage-base", DOCKERFILE_BASE),
("sandcage-claude", DOCKERFILE_CLAUDE),
("sandcage-codex", DOCKERFILE_CODEX),
("sandcage", DOCKERFILE),
];
let hashes_path = build_hashes_path()?;
@@ -630,9 +648,7 @@ mod 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");
assert!(!DOCKERFILE.is_empty(), "Dockerfile should not be empty");
}
#[test]
@@ -658,13 +674,13 @@ mod tests {
let path = tmp.path().join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "aabbcc".to_string());
hashes.insert("sandcage".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").map(String::as_str), Some("aabbcc"));
assert_eq!(loaded.get("sandcage-claude").map(String::as_str), Some("ddeeff"));
}
@@ -683,9 +699,9 @@ mod tests {
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
stored.insert("sandcage".to_string(), hash.clone());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &hash);
let needs_build = stored.get("sandcage").map_or(true, |h| h != &hash);
assert!(!needs_build, "same hash should be a cache hit (no rebuild needed)");
}
@@ -695,9 +711,9 @@ mod tests {
let current_hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), "old_stale_hash".to_string());
stored.insert("sandcage".to_string(), "old_stale_hash".to_string());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
let needs_build = stored.get("sandcage").map_or(true, |h| h != &current_hash);
assert!(needs_build, "different hash should be a cache miss (rebuild needed)");
}
@@ -707,7 +723,7 @@ mod tests {
let current_hash = sha256_hex(dockerfile);
let stored: HashMap<String, String> = HashMap::new();
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
let needs_build = stored.get("sandcage").map_or(true, |h| h != &current_hash);
assert!(needs_build, "missing entry should be treated as a cache miss");
}
@@ -718,10 +734,10 @@ mod tests {
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
stored.insert("sandcage".to_string(), hash.clone());
let force = true;
let needs_build = force || stored.get("sandcage-base").map_or(true, |h| h != &hash);
let needs_build = force || stored.get("sandcage").map_or(true, |h| h != &hash);
assert!(needs_build, "force flag should always trigger a rebuild");
}
@@ -732,7 +748,7 @@ mod tests {
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());
hashes.insert("sandcage".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");
@@ -740,10 +756,10 @@ mod tests {
#[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");
assert_eq!(image_for_service("claude"), "sandcage");
assert_eq!(image_for_service("codex"), "sandcage");
assert_eq!(image_for_service("shell"), "sandcage");
assert_eq!(image_for_service("anything-else"), "sandcage");
}
#[test]
@@ -864,6 +880,8 @@ mod tests {
#[test]
fn build_run_args_with_mounts() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let config = SandcageConfig {
mounts: Some(vec![
"~/.ssh:/home/agent/.ssh:ro".to_string(),
@@ -874,8 +892,10 @@ mod tests {
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(args.contains(&"-v".to_string()), "should contain -v flag");
assert!(args.contains(&"~/.ssh:/home/agent/.ssh:ro".to_string()));
assert!(args.contains(&"~/.gitconfig:/home/agent/.gitconfig:ro".to_string()));
let expected_ssh = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
let expected_git = format!("{home_str}/.gitconfig:/home/agent/.gitconfig:ro");
assert!(args.contains(&expected_ssh), "SSH mount should be expanded");
assert!(args.contains(&expected_git), "gitconfig mount should be expanded");
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let first_v = args.iter().position(|a| a == "-v").unwrap();
@@ -901,6 +921,8 @@ mod tests {
#[test]
fn build_run_args_mounts_with_env_and_extra_args() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let config = SandcageConfig {
@@ -912,7 +934,8 @@ mod tests {
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap();
let mount_pos = args.iter().position(|a| a == "~/.ssh:/home/agent/.ssh:ro").unwrap();
let expected_mount = format!("{home_str}/.ssh:/home/agent/.ssh:ro");
let mount_pos = args.iter().position(|a| *a == expected_mount).unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(env_pos < service_pos, "env before service");
@@ -968,4 +991,38 @@ mod tests {
let env = build_compose_env(&workspace, &config).expect("build_compose_env");
assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace");
}
#[test]
fn expand_mount_path_expands_tilde_slash() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let result = expand_mount_path("~/.ssh:/home/agent/.ssh:ro");
assert_eq!(result, format!("{home_str}/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn expand_mount_path_expands_bare_tilde() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy().replace('\\', "/");
let result = expand_mount_path("~:/container");
assert_eq!(result, format!("{home_str}:/container"));
}
#[test]
fn expand_mount_path_ignores_absolute() {
let result = expand_mount_path("/absolute:/container:ro");
assert_eq!(result, "/absolute:/container:ro");
}
#[test]
fn expand_mount_path_ignores_relative() {
let result = expand_mount_path("./relative:/container");
assert_eq!(result, "./relative:/container");
}
#[test]
fn expand_mount_path_ignores_no_colon() {
let result = expand_mount_path("no-colon");
assert_eq!(result, "no-colon");
}
}