use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::SandboxRuntime;
use crate::sandbox::DEFAULT_IMAGE_REGISTRY;
const CACHE_TTL_SECONDS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FreshnessCache {
image: String,
checked_at: u64,
is_fresh: bool,
#[serde(default)]
local_image_id: Option<String>,
}
fn image_to_filename(image: &str) -> String {
image.replace(['/', ':'], "-")
}
fn cache_dir_in(base: Option<&std::path::Path>) -> Result<PathBuf> {
let state_dir = if let Some(base) = base {
base.join("workmux")
} else if let Ok(xdg_state) = std::env::var("XDG_STATE_HOME") {
PathBuf::from(xdg_state).join("workmux")
} else if let Some(home) = home::home_dir() {
home.join(".local/state/workmux")
} else {
anyhow::bail!("Could not determine state directory");
};
fs::create_dir_all(&state_dir)
.with_context(|| format!("Failed to create state directory: {}", state_dir.display()))?;
Ok(state_dir)
}
fn cache_file_path_in(base: Option<&std::path::Path>, image: &str) -> Result<PathBuf> {
let dir = cache_dir_in(base)?;
Ok(dir.join(format!("image-freshness-{}.json", image_to_filename(image))))
}
fn cache_file_path(image: &str) -> Result<PathBuf> {
cache_file_path_in(None, image)
}
fn load_cache(image: &str) -> Option<FreshnessCache> {
let cache_path = cache_file_path(image).ok()?;
if !cache_path.exists() {
return None;
}
let contents = fs::read_to_string(&cache_path).ok()?;
let cache: FreshnessCache = serde_json::from_str(&contents).ok()?;
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
if now.saturating_sub(cache.checked_at) > CACHE_TTL_SECONDS {
return None;
}
Some(cache)
}
fn save_cache(image: &str, is_fresh: bool, local_image_id: Option<String>) -> Result<()> {
let cache_path = cache_file_path(image)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("Failed to get current time")?
.as_secs();
let cache = FreshnessCache {
image: image.to_string(),
checked_at: now,
is_fresh,
local_image_id,
};
let json = serde_json::to_string_pretty(&cache).context("Failed to serialize cache")?;
fs::write(&cache_path, json)
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
Ok(())
}
fn get_local_image_id(runtime: SandboxRuntime, image: &str) -> Result<String> {
if matches!(runtime, SandboxRuntime::AppleContainer) {
return get_apple_index_digest(image);
}
let runtime_bin = runtime.binary_name();
let output = Command::new(runtime_bin)
.args(["image", "inspect", "--format", "{{.Id}}", image])
.output()
.with_context(|| format!("Failed to run {} image inspect", runtime_bin))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Image inspect failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn get_apple_index_digest(image: &str) -> Result<String> {
let output = Command::new("container")
.args(["image", "inspect", image])
.output()
.context("Failed to run container image inspect")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("container image inspect failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value =
serde_json::from_str(stdout.trim()).context("Failed to parse container inspect JSON")?;
json.as_array()
.and_then(|arr| arr.first())
.and_then(|entry| entry.pointer("/index/digest"))
.and_then(|d| d.as_str())
.map(|s| s.to_string())
.context("No index.digest in container inspect output")
}
fn get_local_repo_digests(runtime: &str, image: &str) -> Result<Vec<String>> {
let output = Command::new(runtime)
.args([
"image",
"inspect",
"--format",
"{{json .RepoDigests}}",
image,
])
.output()
.with_context(|| format!("Failed to run {} image inspect", runtime))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Image inspect failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let digests: Vec<String> =
serde_json::from_str(stdout.trim()).context("Failed to parse RepoDigests JSON")?;
if digests.is_empty() {
anyhow::bail!("No RepoDigests found (locally built image?)");
}
Ok(digests)
}
fn get_remote_digest(image: &str, runtime: SandboxRuntime) -> Result<String> {
match runtime {
SandboxRuntime::Podman => get_remote_digest_podman(image),
SandboxRuntime::AppleContainer => get_remote_digest_apple(image),
_ => get_remote_digest_docker(image),
}
}
fn get_remote_digest_docker(image: &str) -> Result<String> {
let output = Command::new("docker")
.args(["buildx", "imagetools", "inspect", image])
.output()
.context("Failed to run docker buildx imagetools inspect")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("imagetools inspect failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if let Some(digest) = line.strip_prefix("Digest:") {
let digest = digest.trim();
if digest.starts_with("sha256:") {
return Ok(digest.to_string());
}
}
}
anyhow::bail!("Could not find Digest in imagetools output");
}
fn get_remote_digest_podman(image: &str) -> Result<String> {
let output = Command::new("podman")
.args(["manifest", "inspect", image])
.output()
.context("Failed to run podman manifest inspect")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("podman manifest inspect failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value =
serde_json::from_str(stdout.trim()).context("Failed to parse manifest JSON")?;
if let Some(manifests) = json.get("manifests").and_then(|m| m.as_array()) {
for manifest in manifests {
if let Some(digest) = manifest.get("digest").and_then(|d| d.as_str())
&& digest.starts_with("sha256:")
{
return Ok(digest.to_string());
}
}
}
anyhow::bail!("Could not find digest in podman manifest output");
}
fn get_remote_digest_apple(image: &str) -> Result<String> {
let without_registry = image
.strip_prefix("ghcr.io/")
.context("Apple Container freshness check only supports ghcr.io images")?;
let (repo, tag) = without_registry
.rsplit_once(':')
.unwrap_or((without_registry, "latest"));
let token_url = format!("https://ghcr.io/token?scope=repository:{}:pull", repo);
let token_output = Command::new("curl")
.args(["-sf", &token_url])
.output()
.context("Failed to run curl for ghcr.io token")?;
if !token_output.status.success() {
anyhow::bail!("Failed to get ghcr.io bearer token");
}
let token_json: serde_json::Value =
serde_json::from_slice(&token_output.stdout).context("Failed to parse token response")?;
let token = token_json
.get("token")
.and_then(|t| t.as_str())
.context("No token in ghcr.io response")?;
let manifest_url = format!("https://ghcr.io/v2/{}/manifests/{}", repo, tag);
let head_output = Command::new("curl")
.args([
"-sfI",
"-H",
&format!("Authorization: Bearer {}", token),
"-H",
"Accept: application/vnd.oci.image.index.v1+json",
"-H",
"Accept: application/vnd.docker.distribution.manifest.list.v2+json",
&manifest_url,
])
.output()
.context("Failed to run curl for manifest HEAD")?;
if !head_output.status.success() {
anyhow::bail!("Failed to fetch manifest from ghcr.io");
}
let headers = String::from_utf8_lossy(&head_output.stdout);
for line in headers.lines() {
if let Some((key, value)) = line.split_once(':')
&& key.trim().eq_ignore_ascii_case("docker-content-digest")
{
let digest = value.trim();
if digest.starts_with("sha256:") {
return Ok(digest.to_string());
}
}
}
anyhow::bail!("No Docker-Content-Digest header in ghcr.io response");
}
pub fn check_freshness(image: &str, runtime: SandboxRuntime) -> Result<bool> {
let remote_digest =
get_remote_digest(image, runtime).context("Failed to get remote image digest")?;
if matches!(runtime, SandboxRuntime::AppleContainer) {
let local_digest =
get_apple_index_digest(image).context("Failed to get local Apple Container digest")?;
return Ok(local_digest == remote_digest);
}
let runtime_bin = runtime.binary_name();
let local_digests =
get_local_repo_digests(runtime_bin, image).context("Failed to get local image digests")?;
let is_fresh = local_digests.iter().any(|d| d.contains(&remote_digest));
Ok(is_fresh)
}
pub fn is_official_image(image: &str) -> bool {
image
.strip_prefix(DEFAULT_IMAGE_REGISTRY)
.is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('@'))
}
pub fn cached_is_stale(image: &str, runtime: SandboxRuntime) -> Option<bool> {
let cache = load_cache(image)?;
if cache.is_fresh {
return Some(false);
}
if let Ok(current_id) = get_local_image_id(runtime, image)
&& cache.local_image_id.as_deref() == Some(¤t_id)
{
Some(true)
} else {
None
}
}
pub fn mark_fresh(image: &str, runtime: SandboxRuntime) {
let local_id = get_local_image_id(runtime, image).ok();
let _ = save_cache(image, true, local_id);
}
pub fn check_in_background(image: String, runtime: SandboxRuntime) {
std::thread::spawn(move || {
if !is_official_image(&image) {
return;
}
if let Some(cache) = load_cache(&image) {
if cache.is_fresh {
return;
}
if let Ok(current_id) = get_local_image_id(runtime, &image)
&& cache.local_image_id.as_deref() == Some(¤t_id)
{
return;
}
}
let local_id = get_local_image_id(runtime, &image).ok();
match check_freshness(&image, runtime) {
Ok(is_fresh) => {
let _ = save_cache(&image, is_fresh, local_id);
}
Err(_e) => {
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_file_path() {
let tmp = tempfile::tempdir().unwrap();
let path =
cache_file_path_in(Some(tmp.path()), "ghcr.io/raine/workmux-sandbox:claude").unwrap();
assert!(path.to_string_lossy().contains("workmux"));
assert!(
path.to_string_lossy()
.ends_with("image-freshness-ghcr.io-raine-workmux-sandbox-claude.json")
);
assert!(path.parent().unwrap().is_dir());
}
#[test]
fn test_cache_file_path_per_image() {
let tmp = tempfile::tempdir().unwrap();
let path_claude =
cache_file_path_in(Some(tmp.path()), "ghcr.io/raine/workmux-sandbox:claude").unwrap();
let path_codex =
cache_file_path_in(Some(tmp.path()), "ghcr.io/raine/workmux-sandbox:codex").unwrap();
assert_ne!(path_claude, path_codex);
}
#[test]
fn test_load_cache_missing_file() {
let result = load_cache("test-image:latest");
assert!(result.is_none());
}
#[test]
fn test_freshness_cache_serialization() {
let cache = FreshnessCache {
image: "ghcr.io/raine/workmux-sandbox:claude".to_string(),
checked_at: 1707350400,
is_fresh: true,
local_image_id: Some("sha256:abc123".to_string()),
};
let json = serde_json::to_string(&cache).unwrap();
let parsed: FreshnessCache = serde_json::from_str(&json).unwrap();
assert_eq!(cache.image, parsed.image);
assert_eq!(cache.checked_at, parsed.checked_at);
assert_eq!(cache.is_fresh, parsed.is_fresh);
assert_eq!(cache.local_image_id, parsed.local_image_id);
}
#[test]
fn test_freshness_cache_without_local_image_id() {
let json = r#"{"image":"ghcr.io/raine/workmux-sandbox:claude","checked_at":1707350400,"is_fresh":false}"#;
let parsed: FreshnessCache = serde_json::from_str(json).unwrap();
assert!(!parsed.is_fresh);
assert_eq!(parsed.local_image_id, None);
}
#[test]
fn test_parse_apple_container_index_digest() {
let json = r#"[{"index":{"mediaType":"application/vnd.oci.image.index.v1+json","size":1609,"digest":"sha256:abc123"},"variants":[],"name":"ghcr.io/raine/workmux-sandbox:claude"}]"#;
let parsed: serde_json::Value = serde_json::from_str(json).unwrap();
let digest = parsed
.as_array()
.and_then(|arr| arr.first())
.and_then(|entry| entry.pointer("/index/digest"))
.and_then(|d| d.as_str())
.unwrap();
assert_eq!(digest, "sha256:abc123");
}
#[test]
fn test_parse_ghcr_docker_content_digest() {
let headers = "HTTP/2 200\r\ncontent-type: application/vnd.oci.image.index.v1+json\r\nDocker-Content-Digest: sha256:abc123\r\n";
let mut found = None;
for line in headers.lines() {
if let Some((key, value)) = line.split_once(':') {
if key.trim().eq_ignore_ascii_case("docker-content-digest") {
let digest = value.trim();
if digest.starts_with("sha256:") {
found = Some(digest.to_string());
}
}
}
}
assert_eq!(found.unwrap(), "sha256:abc123");
}
#[test]
fn test_parse_ghcr_docker_content_digest_lowercase() {
let headers = "HTTP/2 200\r\ndocker-content-digest: sha256:def456\r\n";
let mut found = None;
for line in headers.lines() {
if let Some((key, value)) = line.split_once(':') {
if key.trim().eq_ignore_ascii_case("docker-content-digest") {
let digest = value.trim();
if digest.starts_with("sha256:") {
found = Some(digest.to_string());
}
}
}
}
assert_eq!(found.unwrap(), "sha256:def456");
}
#[test]
#[ignore]
fn test_apple_container_local_digest() {
let digest = get_apple_index_digest("ghcr.io/raine/workmux-sandbox:claude").unwrap();
assert!(
digest.starts_with("sha256:"),
"expected sha256 digest, got: {}",
digest
);
}
#[test]
#[ignore]
fn test_apple_container_remote_digest() {
let digest = get_remote_digest_apple("ghcr.io/raine/workmux-sandbox:claude").unwrap();
assert!(
digest.starts_with("sha256:"),
"expected sha256 digest, got: {}",
digest
);
}
#[test]
#[ignore]
fn test_apple_container_freshness_check() {
let is_fresh = check_freshness(
"ghcr.io/raine/workmux-sandbox:claude",
SandboxRuntime::AppleContainer,
)
.unwrap();
assert!(is_fresh, "freshly pulled image should be detected as fresh");
}
#[test]
#[ignore]
fn test_apple_container_digests_match() {
let local = get_apple_index_digest("ghcr.io/raine/workmux-sandbox:claude").unwrap();
let remote = get_remote_digest_apple("ghcr.io/raine/workmux-sandbox:claude").unwrap();
assert_eq!(local, remote, "local and remote digests should match");
}
#[test]
fn test_is_official_image() {
assert!(is_official_image("ghcr.io/raine/workmux-sandbox:claude"));
assert!(is_official_image("ghcr.io/raine/workmux-sandbox:base"));
assert!(is_official_image(
"ghcr.io/raine/workmux-sandbox@sha256:abc"
));
assert!(is_official_image("ghcr.io/raine/workmux-sandbox"));
assert!(!is_official_image(
"ghcr.io/raine/workmux-sandbox-dev:claude"
));
assert!(!is_official_image("ghcr.io/raine/workmux-sandboxx:claude"));
assert!(!is_official_image("docker.io/library/ubuntu:latest"));
}
}