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 cache_file_path_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.join("image-freshness.json"))
}
fn cache_file_path() -> Result<PathBuf> {
cache_file_path_in(None)
}
fn load_cache(image: &str) -> Option<FreshnessCache> {
let cache_path = cache_file_path().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()?;
if cache.image != image {
return None;
}
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()?;
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: &str, image: &str) -> Result<String> {
let output = Command::new(runtime)
.args(["image", "inspect", "--format", "{{.Id}}", 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());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
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) -> 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 check_freshness(image: &str, runtime: SandboxRuntime) -> Result<bool> {
if matches!(runtime, SandboxRuntime::AppleContainer) {
return Ok(true);
}
let runtime_bin = runtime.binary_name();
let local_digests =
get_local_repo_digests(runtime_bin, image).context("Failed to get local image digests")?;
let remote_digest = get_remote_digest(image).context("Failed to get remote image digest")?;
let is_fresh = local_digests.iter().any(|d| d.contains(&remote_digest));
if !is_fresh {
eprintln!(
"hint: a newer sandbox image is available (run `workmux sandbox pull` to update)"
);
}
Ok(is_fresh)
}
pub fn mark_fresh(image: &str, runtime: SandboxRuntime) {
if matches!(runtime, SandboxRuntime::AppleContainer) {
return;
}
let runtime_bin = runtime.binary_name();
let local_id = get_local_image_id(runtime_bin, image).ok();
let _ = save_cache(image, true, local_id);
}
pub fn check_in_background(image: String, runtime: SandboxRuntime) {
std::thread::spawn(move || {
if matches!(runtime, SandboxRuntime::AppleContainer) {
return;
}
if !image.starts_with(DEFAULT_IMAGE_REGISTRY) {
return;
}
let runtime_bin = runtime.binary_name();
if let Some(cache) = load_cache(&image) {
if cache.is_fresh {
return;
}
if let Ok(current_id) = get_local_image_id(runtime_bin, &image)
&& cache.local_image_id.as_deref() == Some(¤t_id)
{
eprintln!(
"hint: a newer sandbox image is available (run `workmux sandbox pull` to update)"
);
return;
}
}
let local_id = get_local_image_id(runtime_bin, &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())).unwrap();
assert!(path.to_string_lossy().contains("workmux"));
assert!(path.to_string_lossy().ends_with("image-freshness.json"));
assert!(path.parent().unwrap().is_dir());
}
#[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_check_freshness_skips_apple_container() {
let result = check_freshness("test-image:latest", SandboxRuntime::AppleContainer);
assert!(result.unwrap());
}
#[test]
fn test_mark_fresh_skips_apple_container() {
mark_fresh("test-image:latest", SandboxRuntime::AppleContainer);
}
}