diff --git a/crates/smolvm-agent/Cargo.toml b/crates/smolvm-agent/Cargo.toml index e631ef00..07da836c 100644 --- a/crates/smolvm-agent/Cargo.toml +++ b/crates/smolvm-agent/Cargo.toml @@ -20,6 +20,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } libc = "0.2" parking_lot = "0.12" tempfile = "3" +regex = "1.12.3" # Linux-specific dependencies for vsock [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/smolvm-agent/src/main.rs b/crates/smolvm-agent/src/main.rs index 7ba7a8b6..6d0a5b10 100644 --- a/crates/smolvm-agent/src/main.rs +++ b/crates/smolvm-agent/src/main.rs @@ -73,6 +73,7 @@ fn boot_log(level: &str, msg: &str) { } } mod dns_proxy; +mod name; mod network; mod oci; mod paths; @@ -532,6 +533,9 @@ fn setup_gpu_dev_nodes() { } } +#[cfg(not(target_os = "linux"))] +fn log_gpu_status() {} + /// Log whether the GPU is accessible in the guest. /// /// Called at startup when `ENV_SMOLVM_GPU` is set. Checks whether the virtio-gpu @@ -1152,6 +1156,7 @@ fn create_storage_dirs(mount_point: &str) { } /// Mount ext4 /dev/vda at /storage using direct syscall (avoids ~3-5ms fork+exec). +#[cfg(target_os = "linux")] fn try_mount_storage_ext4() -> bool { let dev = cstr("/dev/vda"); let mnt = cstr("/storage"); @@ -1174,6 +1179,7 @@ fn try_mount_storage_ext4() -> bool { /// 1. resize + mount (works on subsequent boots with Linux-native FS) /// 2. fsck + resize + mount (may fix minor corruption) /// 3. mkfs + mount (first boot from macOS template, or unrecoverable) +#[cfg(target_os = "linux")] fn mount_storage_disk() -> bool { use std::process::Command; @@ -1276,6 +1282,11 @@ fn mount_storage_disk() -> bool { false } +#[cfg(not(target_os = "linux"))] +fn mount_storage_disk() -> bool { + false +} + /// Maximum number of vsock connections serviced concurrently. /// Bounds thread count and prevents vsock channel saturation. /// 8 (2^3) gives headroom for several parallel execs plus the state probe. diff --git a/crates/smolvm-agent/src/name.rs b/crates/smolvm-agent/src/name.rs new file mode 100644 index 00000000..1317a632 --- /dev/null +++ b/crates/smolvm-agent/src/name.rs @@ -0,0 +1,397 @@ +//! This module handles: +//! - parsing image refs + +// Reference definition (https://pkg.go.dev/github.com/distribution/reference#pkg-overview) +// reference := name [ ":" tag ] [ "@" digest ] +// name := [domain '/'] remote-name +// domain := host [':' port-number] +// host := domain-name | IPv4address | \[ IPv6address \] ; rfc3986 appendix-A +// domain-name := domain-component ['.' domain-component]* +// domain-component := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ +// port-number := /[0-9]+/ +// path-component := alpha-numeric [separator alpha-numeric]* +// path (or "remote-name") := path-component ['/' path-component]* +// alpha-numeric := /[a-z0-9]+/ +// separator := /[_.]|__|[-]*/ +// tag := /[\w][\w.-]{0,127}/ + +// digest := digest-algorithm ":" digest-hex +// digest-algorithm := digest-algorithm-component [ digest-algorithm-separator digest-algorithm-component ]* +// digest-algorithm-separator := /[+.-_]/ +// digest-algorithm-component := /[A-Za-z][A-Za-z0-9]*/ +// digest-hex := /[0-9a-fA-F]{32,}/ ; At least 128 bit digest value + +// identifier := /[a-f0-9]{64}/ +// +use regex::Regex; +use std::fmt; + +const DEFAULT_REGISTRY: &str = "index.docker.io"; +const DEFAULT_REGISTRY_ALIAS: &str = "docker.io"; +const DEFAULT_REPOSITORY: &str = "library"; +const DEFAULT_TAG: &str = "latest"; + +/// Error variants returned when parsing or validating image references. +pub enum ImageRefError { + InvalidReference, + InvalidDigestAlgorithm, + InvalidDigestContent, + InvalidTagLength, + InvalidTagCharacters, + InvalidRepoLength, + // InvalidRepoCharacters, +} + +impl fmt::Display for ImageRefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + ImageRefError::InvalidReference => write!(f, "Invalid reference"), + ImageRefError::InvalidDigestAlgorithm => { + write!(f, "Invalid digest algorithm: only sha256 supported") + } + ImageRefError::InvalidDigestContent => { + write!(f, "Invalid digest content, must be sha256") + } + ImageRefError::InvalidTagLength => { + write!(f, "Invalid tag: must be between 1 and 128 chars") + } + ImageRefError::InvalidTagCharacters => write!(f, "Invalid tag: unsupported characters"), + ImageRefError::InvalidRepoLength => { + write!(f, "Invalid repostory name: must be between 1 and 255 chars") + } + } + } +} + +/// Canonical image reference split into registry, repository, tag, and digest. +pub struct Reference { + tag: String, + registry: String, + repository: String, + digest: Option, +} + +impl Reference { + pub fn to_fqdn(&self) -> String { + if let Some(dig) = &self.digest { + return format!("{}/{}@{}", self.registry, self.repository, dig); + } + return format!("{}/{}:{}", self.registry, self.repository, self.tag); + } + + pub fn sanitized(&self) -> String { + self.to_fqdn().replace(['/', ':', '@'], "_").to_string() + } +} + +/// Parse and normalize an image reference into canonical components. +pub fn parse_image_ref(img: &str) -> Result { + if img == "" { + return Err(ImageRefError::InvalidReference); + } + if let Some((root, digest)) = img.split_once("@") { + let (registry, repository, tag) = parse_tag(root); + let reference = canonicalize(registry, repository, tag, digest); + validate_registry(&reference.registry)?; + validate_tag(&reference.tag)?; + if let Some(dig) = &reference.digest { + validate_dig(dig)?; + } + validate_repo(&reference.repository)?; + Ok(reference) + } else { + let (registry, repository, tag) = parse_tag(img); + let reference = canonicalize(registry, repository, tag, ""); + validate_registry(&reference.registry)?; + validate_tag(&reference.tag)?; + validate_repo(&reference.repository)?; + Ok(reference) + } +} + +/// Split a reference into registry/repository and optional tag. +fn parse_tag(root_no_dig: &str) -> (Option<&str>, &str, Option<&str>) { + let last_slash = root_no_dig.rfind("/"); + let last_colon = root_no_dig.rfind(":"); + if last_colon > last_slash { + if let Some((root, tag)) = root_no_dig.rsplit_once(":") { + let (registry, repository) = parse_remote(root); + return (registry, repository, Some(tag)); + } + } + let (registry, repository) = parse_remote(root_no_dig); + (registry, repository, None) +} + +/// Detect registry host prefix and return the remaining repository path. +fn parse_remote(root: &str) -> (Option<&str>, &str) { + if let Some((first, rest)) = root.split_once("/") { + if first.contains(".") || first.contains(":") || first == "localhost" { + return (Some(first), rest); + } + } + (None, root) +} + +/// Apply defaults and Docker Hub normalization to parsed reference parts. +fn canonicalize( + registry: Option<&str>, + repository: &str, + tag: Option<&str>, + digest: &str, +) -> Reference { + let rslvd_registry = registry.unwrap_or(DEFAULT_REGISTRY_ALIAS).to_string(); + let is_docker_registry = + rslvd_registry == DEFAULT_REGISTRY || rslvd_registry == DEFAULT_REGISTRY_ALIAS; + let rslvd_repo = if !repository.contains("/") && is_docker_registry && !repository.is_empty() { + format!("{}/{}", DEFAULT_REPOSITORY, repository) + } else { + repository.to_string() + }; + + let rslvd_tag = tag.unwrap_or(DEFAULT_TAG).to_string(); + let rslvd_dig = if digest.is_empty() { + None + } else { + Some(digest.to_string()) + }; + Reference { + registry: rslvd_registry, + repository: rslvd_repo, + tag: rslvd_tag, + digest: rslvd_dig, + } +} + +/// Validate that a registry value exists. +fn validate_registry(registry: &str) -> Result<(), ImageRefError> { + if registry.len() < 1 { + return Err(ImageRefError::InvalidReference); + } + Ok(()) +} + +/// Validate tag length and character set. +fn validate_tag(tag: &str) -> Result<(), ImageRefError> { + let length = tag.len(); + if length < 1 || length > 128 { + return Err(ImageRefError::InvalidTagLength); + }; + let re = Regex::new(r"^[A-Za-z0-9_.-]+$").unwrap(); + if !re.is_match(tag) { + return Err(ImageRefError::InvalidTagCharacters); + }; + Ok(()) +} + +/// Validate repository path length constraints. +fn validate_repo(repo: &str) -> Result<(), ImageRefError> { + let length = repo.len(); + if length < 1 || length > 255 { + return Err(ImageRefError::InvalidRepoLength); + } + // More complex parsers check that repo names + // conform to path component type. I think it's + // okay to rely on upstream resistance to these + // pickier elements of the repo/image names. + // TODO: path component regex checks for repo + Ok(()) +} + +/// Validate digest format and enforce sha256 with 64 hex chars. +fn validate_dig(dig: &str) -> Result<(), ImageRefError> { + if dig == "" { + return Ok(()); + } + if let Some((_, sha)) = dig.split_once("sha256:") { + let re = Regex::new(r"^[A-Fa-f0-9]{64}$").unwrap(); + if re.is_match(sha) { + return Ok(()); + } + Err(ImageRefError::InvalidDigestContent) + } else { + Err(ImageRefError::InvalidDigestAlgorithm) + } +} + +/// Reverse a sanitized filename back into an approximate image reference. +pub fn unsanitize_image_name(name: &str) -> String { + // This is approximate - we lose some info + name.replacen('_', "/", 1).replacen('_', ":", 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parser_normalizes_simple_refs() { + let valid_refs = [ + "alpine", + "alpine:latest", + "library/alpine:latest", + "docker.io/alpine:latest", + "docker.io/library/alpine:latest", + ]; + + for image in valid_refs { + let parsed = parse_image_ref(image); + match parsed { + Ok(reference) => assert_eq!(reference.to_fqdn(), "docker.io/library/alpine:latest"), + _ => panic!("Failed to parse valid ref"), + } + } + } + + #[test] + fn test_parser_sanitizes_fqdn_for_fs() { + let image = Reference { + tag: "latest".to_string(), + registry: "docker.io".to_string(), + repository: "library/alpine".to_string(), + digest: None, + }; + + assert_eq!(image.sanitized(), "docker.io_library_alpine_latest"); + } + + #[test] + fn test_parser_handles_digest_refs() { + let digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let cases = [ + ( + format!("alpine@{digest}"), + format!("docker.io/library/alpine@{digest}"), + ), + ( + format!("docker.io/library/alpine@{digest}"), + format!("docker.io/library/alpine@{digest}"), + ), + ( + format!("ghcr.io/owner/repo@{digest}"), + format!("ghcr.io/owner/repo@{digest}"), + ), + ( + format!("alpine:3.20@{digest}"), + format!("docker.io/library/alpine@{digest}"), + ), + ]; + + for (image, expected) in cases { + let parsed = parse_image_ref(&image); + match parsed { + Ok(reference) => assert_eq!(reference.to_fqdn(), expected), + _ => panic!("Failed to parse digest reference"), + } + } + } + + #[test] + fn test_parser_handles_subdomains() { + let cases = [ + ( + "registry.example.com/alpine", + "registry.example.com/alpine:latest", + ), + ( + "registry.example.com/team/api:1.2.3", + "registry.example.com/team/api:1.2.3", + ), + ]; + + for (image, expected) in cases { + let parsed = parse_image_ref(image); + match parsed { + Ok(reference) => assert_eq!(reference.to_fqdn(), expected), + _ => panic!("Failed to parse ref with subdomain registry"), + } + } + } + + #[test] + fn test_parser_handles_host_variants() { + let cases = [ + ("localhost/alpine", "localhost/alpine:latest"), + ("localhost/team/api:dev", "localhost/team/api:dev"), + ("192.168.1.1:5000/repo:tag", "192.168.1.1:5000/repo:tag"), + ("[2001:db8::1]:5000/repo:tag", "[2001:db8::1]:5000/repo:tag"), + ]; + + for (image, expected) in cases { + let parsed = parse_image_ref(image); + match parsed { + Ok(reference) => assert_eq!(reference.to_fqdn(), expected), + _ => panic!("Failed to parse host variant"), + } + } + } + + #[test] + fn test_parser_handles_ports() { + let cases = [ + ("localhost:5000/alpine", "localhost:5000/alpine:latest"), + ( + "localhost:5000/alpine:latest", + "localhost:5000/alpine:latest", + ), + ( + "registry.internal:8443/org/api:v2", + "registry.internal:8443/org/api:v2", + ), + ]; + + for (image, expected) in cases { + let parsed = parse_image_ref(image); + match parsed { + Ok(reference) => assert_eq!(reference.to_fqdn(), expected), + _ => panic!("Failed to parse host:port reference"), + } + } + } + + #[test] + fn test_parser_handles_nested_namespaces() { + let cases = [ + ( + "ghcr.io/owner/platform/image:1.0", + "ghcr.io/owner/platform/image:1.0", + ), + ("docker.io/repo/image:latest", "docker.io/repo/image:latest"), + ("repo/image:latest", "docker.io/repo/image:latest"), + ]; + + for (image, expected) in cases { + let parsed = parse_image_ref(image); + match parsed { + Ok(reference) => assert_eq!(reference.to_fqdn(), expected), + _ => panic!("Failed to parse nested namespace reference"), + } + } + } + + #[test] + fn test_parser_rejects_invalid_refs() { + let invalid_refs = [ + // TODO: uncomment after adding path component validation + // "aa/badchars$$^/abc", + // "Uppercase/repo:tag", + // "test:5000/Uppercase/lowercase:tag", + "", + ":onlytag", + "@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "alpine@sha256:wrong", + "alpine:latest@sha123:deadbeef", + "alpine:latest@sha256:deadbeef", + "alpine:bad tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefc", + "alpine:@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ]; + + for image in invalid_refs { + let parsed = parse_image_ref(image); + assert!( + parsed.is_err(), + "Expected parse to fail for invalid ref: {image}" + ); + } + } +} diff --git a/crates/smolvm-agent/src/oci.rs b/crates/smolvm-agent/src/oci.rs index c49cd528..10e45824 100644 --- a/crates/smolvm-agent/src/oci.rs +++ b/crates/smolvm-agent/src/oci.rs @@ -616,6 +616,7 @@ impl OciSpec { /// Maximum allowed length for an image reference. const MAX_IMAGE_REF_LENGTH: usize = 512; +// TODO: consider moving oci image ref validation to `name` /// Validate an OCI image reference format. /// /// This validates that the image reference follows the expected format: diff --git a/crates/smolvm-agent/src/storage.rs b/crates/smolvm-agent/src/storage.rs index a05d4e3e..36843384 100644 --- a/crates/smolvm-agent/src/storage.rs +++ b/crates/smolvm-agent/src/storage.rs @@ -9,6 +9,7 @@ //! - Support for pre-packed OCI layers (smolvm pack) use crate::crun::CrunCommand; +use crate::name::{parse_image_ref, unsanitize_image_name}; use crate::oci::{generate_container_id, OciSpec}; use crate::paths; use crate::process::{WaitResult, TIMEOUT_EXIT_CODE}; @@ -230,6 +231,7 @@ static BOOT_VOLUME_MOUNTS: OnceLock> = OnceLock::new /// Initialize packed layers support by checking SMOLVM_PACKED_LAYERS env var. /// Format: "virtiofs_tag:mount_point" (e.g., "smolvm_layers:/packed_layers") /// Returns the mount point path if successfully mounted. +#[cfg(target_os = "linux")] pub fn init_packed_layers() -> Option { let env_val = match std::env::var("SMOLVM_PACKED_LAYERS") { Ok(v) => v, @@ -290,6 +292,11 @@ pub fn init_packed_layers() -> Option { Some(mount_point) } +#[cfg(not(target_os = "linux"))] +pub fn init_packed_layers() -> Option { + None +} + /// Get the packed layers directory if available. pub fn get_packed_layers_dir() -> Option<&'static PathBuf> { PACKED_LAYERS_DIR.get_or_init(init_packed_layers).as_ref() @@ -951,34 +958,17 @@ where } }); - // Check if already cached with correct architecture - if let Ok(Some(info)) = query_image(image) { - // Verify cached image architecture matches requested OCI platform - let cached_arch = &info.architecture; - let requested_arch = oci_platform - .map(oci_platform_to_arch) - .unwrap_or_else(|| cached_arch.clone()); + let cache_strategy = + resolve_cache_decision(query_image(image), oci_platform.map(oci_platform_to_arch)); - if cached_arch == &requested_arch { - debug!( - image = %image, - architecture = %cached_arch, - "image already cached with correct architecture, skipping pull" - ); - return Ok(info); - } else { - // Architecture mismatch - need to re-pull - info!( - image = %image, - cached_arch = %cached_arch, - requested_arch = %requested_arch, - "cached image has wrong architecture, will re-pull" - ); - // Clean up the mismatched cached manifest - let root = Path::new(STORAGE_ROOT); - let manifest_path = root - .join(MANIFESTS_DIR) - .join(sanitize_image_name(image) + ".json"); + if let CacheDecision::UseCache(info) = cache_strategy { + return Ok(info); + } + + if let CacheDecision::Fail(_) = cache_strategy { + // if querying the cache fails, we try to clean up potentially + // malformed metadata before proceeding to pull + if let Ok(manifest_path) = image_manifest_path(image) { let _ = std::fs::remove_file(&manifest_path); } } @@ -1030,9 +1020,7 @@ where let total_layers = layers.len(); // Save manifest - let manifest_path = root - .join(MANIFESTS_DIR) - .join(sanitize_image_name(image) + ".json"); + let manifest_path = image_manifest_path(image)?; std::fs::write(&manifest_path, &manifest)?; // Fetch and save config @@ -1209,13 +1197,70 @@ where }) } +#[derive(Debug)] +enum CacheDecision { + UseCache(ImageInfo), + Pull, + Fail(String), +} + +/// Get the fully-qualified and sanitized path to an image's manifest +fn image_manifest_path_at(root: &Path, image: &str) -> Result { + let sanitized_image = sanitized_image_from(image)?; + Ok(root.join(MANIFESTS_DIR).join(sanitized_image + ".json")) +} + +/// Get the fully-qualified and sanitized path to an image's manifest +fn image_manifest_path(image: &str) -> Result { + image_manifest_path_at(Path::new(STORAGE_ROOT), image) +} + +/// Use query image result to determine if cache can be used, +/// pull is needed, or if the process should fail +fn resolve_cache_decision( + query_result: Result>, + requested_arch: Option, +) -> CacheDecision { + match query_result { + Ok(None) => return CacheDecision::Pull, + Ok(Some(info)) => { + let cached_arch = &info.architecture; + let next_arch = requested_arch.unwrap_or_else(|| cached_arch.clone()); + + if cached_arch != &next_arch { + let image = &info.reference; + // Architecture mismatch - need to re-pull + info!( + image = %image, + cached_arch = %cached_arch, + requested_arch = %next_arch, + "cached image has wrong architecture, will re-pull" + ); + // Clean up the mismatched cached manifest + if let Ok(manifest_path) = image_manifest_path(image) { + let _ = std::fs::remove_file(&manifest_path); + } + return CacheDecision::Pull; + } + // cache hit with a valid arch and parsed ImageInfo + debug!( + image = %info.reference, + architecture = %cached_arch, + "image already cached with correct architecture, skipping pull" + ); + return CacheDecision::UseCache(info); + } + Err(e) => CacheDecision::Fail(format!("cache query failed: {e}")), + } +} + /// Query if an image exists locally. pub fn query_image(image: &str) -> Result> { - let root = Path::new(STORAGE_ROOT); - let manifest_path = root - .join(MANIFESTS_DIR) - .join(sanitize_image_name(image) + ".json"); + query_image_at(Path::new(STORAGE_ROOT), image) +} +fn query_image_at(root: &Path, image: &str) -> Result> { + let manifest_path = image_manifest_path_at(root, image)?; if !manifest_path.exists() { return Ok(None); } @@ -2002,11 +2047,8 @@ pub struct PreparedOverlayRootfs { } fn prepare_rootfs_for_ephemeral_run(image: &str) -> Result { - let workload_id = format!( - "run-{}-{}", - sanitize_image_name(image), - generate_container_id() - ); + let sanitized_ref = sanitized_image_from(image)?; + let workload_id = format!("run-{}-{}", sanitized_ref, generate_container_id()); let overlay = prepare_overlay(image, &workload_id)?; debug!( workload_id = %workload_id, @@ -2230,7 +2272,8 @@ pub fn setup_mounts(rootfs: &str, mounts: &[(String, String, bool)]) -> Result<( Ok(()) } -/// Setup volume mounts by mounting virtiofs and bind-mounting into the rootfs. +/// Setup volume mounts by mounting virtiofs and bind-mounting into the rootfs +#[cfg(target_os = "linux")] fn setup_volume_mounts(rootfs: &str, mounts: &[(String, String, bool)]) -> Result> { let mut mounted_paths = Vec::new(); let rootfs_path = Path::new(rootfs); @@ -2337,6 +2380,13 @@ fn setup_volume_mounts(rootfs: &str, mounts: &[(String, String, bool)]) -> Resul Ok(mounted_paths) } +#[cfg(not(target_os = "linux"))] +fn setup_volume_mounts(_rootfs: &str, _mounts: &[(String, String, bool)]) -> Result> { + Err(StorageError::Internal { + message: "Bind mounts on non-linux unsupported".to_string(), + }) +} + /// Check if a path is a mountpoint. /// Check if a path is a mountpoint (delegates to paths::is_mount_point). fn is_mountpoint(path: &Path) -> bool { @@ -2799,15 +2849,18 @@ fn crane_config( run_crane("config", image, oci_platform, auth) } -/// Sanitize image name for use as filename. -fn sanitize_image_name(image: &str) -> String { - image.replace(['/', ':', '@'], "_") -} - -/// Reverse sanitization. -fn unsanitize_image_name(name: &str) -> String { - // This is approximate - we lose some info - name.replacen('_', "/", 1).replacen('_', ":", 1) +/// Convert a string to a fully-qualified image reference +/// sanitized for use as a file name +fn sanitized_image_from(image: &str) -> Result { + match parse_image_ref(image) { + Ok(r) => return Ok(r.sanitized()), + Err(e) => { + return Err(StorageError::InvalidImageReference { + reference: image.to_string(), + reason: e.to_string(), + }) + } + }; } /// Get disk usage for a path. @@ -2905,6 +2958,190 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + fn sample_image_info(architecture: &str) -> ImageInfo { + ImageInfo { + reference: "docker.io/library/alpine:3.20".to_string(), + digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), + size: 42, + created: Some("2026-01-01T00:00:00Z".to_string()), + architecture: architecture.to_string(), + os: "linux".to_string(), + layer_count: 1, + layers: vec![ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + ], + entrypoint: vec![], + cmd: vec![], + env: vec![], + workdir: None, + user: None, + } + } + + #[test] + fn test_resolve_cache_decision_uses_cache_on_hit() { + let result = resolve_cache_decision( + Ok(Some(sample_image_info("amd64"))), + Some("amd64".to_string()), + ); + + match result { + CacheDecision::UseCache(_) => {} + _ => panic!("expected cache hit to use cache"), + } + } + + #[test] + fn test_resolve_cache_decision_pulls_on_arch_mismatch() { + let result = resolve_cache_decision( + Ok(Some(sample_image_info("arm64"))), + Some("amd64".to_string()), + ); + match result { + CacheDecision::Pull => {} + _ => panic!("expected arch mismatch to pull"), + } + } + + #[test] + fn test_resolve_cache_decision_pulls_on_cache_miss() { + let result = resolve_cache_decision(Ok(None), None); + + match result { + CacheDecision::Pull => {} + _ => panic!("expected cache miss to pull"), + } + } + + #[test] + fn test_resolve_cache_decision_returns_fail_on_query_error() { + let query_error = StorageError::parse_error( + "manifest", + serde_json::from_str::("{").unwrap_err(), + ); + + let result = resolve_cache_decision(Err(query_error), None); + + match result { + CacheDecision::Fail(_) => {} + _ => panic!("expected query error to fail"), + } + } + + #[test] + fn test_resolve_cache_decision_returns_fail_on_io_query_error() { + let query_error: StorageError = + std::io::Error::new(std::io::ErrorKind::NotFound, "manifest missing").into(); + + let result = resolve_cache_decision(Err(query_error), None); + + match result { + CacheDecision::Fail(_) => {} + _ => panic!("expected query error to fail"), + } + } + + #[test] + fn test_resolve_cache_decision_never_bubbles_query_errors() { + let parse_error = StorageError::parse_error( + "config", + serde_json::from_str::("{").unwrap_err(), + ); + + let io_error: StorageError = + std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied").into(); + + let parse_result = resolve_cache_decision(Err(parse_error), None); + let io_result = resolve_cache_decision(Err(io_error), None); + match parse_result { + CacheDecision::Fail(_) => {} + _ => panic!("query parse errors should resolve to CacheDecision::Fail"), + }; + + match io_result { + CacheDecision::Fail(_) => {} + _ => panic!("query IO errors should resolve to CacheDecision::Fail"), + }; + } + + #[test] + fn test_query_image_alias_shorthands_resolve_same_cached_manifest() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let manifests_dir = root.join(MANIFESTS_DIR); + let configs_dir = root.join(CONFIGS_DIR); + let layers_dir = root.join(LAYERS_DIR); + + std::fs::create_dir_all(&manifests_dir).unwrap(); + std::fs::create_dir_all(&configs_dir).unwrap(); + std::fs::create_dir_all(&layers_dir).unwrap(); + + let config_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let layer_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + // Static canonical manifest filename + let canonical_manifest_filename = "docker.io_library_alpine_3.20.json"; + let manifest_path = manifests_dir.join(canonical_manifest_filename); + let manifest = format!( + r#"{{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": {{ + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:{config_id}", + "size": 123 + }}, + "layers": [ + {{ + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:{layer_id}", + "size": 42 + }} + ] +}}"# + ); + let config = r#"{ + "architecture": "amd64", + "os": "linux", + "created": "2026-01-01T00:00:00Z", + "config": { + "Entrypoint": ["/bin/sh"], + "Cmd": ["-c", "echo ok"], + "Env": ["PATH=/usr/bin"], + "WorkingDir": "/", + "User": "" + } +}"#; + + std::fs::write(&manifest_path, manifest).unwrap(); + std::fs::write(configs_dir.join(format!("{config_id}.json")), config).unwrap(); + std::fs::create_dir_all(layers_dir.join(layer_id)).unwrap(); + + // Assert shorthand-specific filename is absent; lookup must normalize to canonical path. + assert!(!manifests_dir.join("alpine_3.20.json").exists()); + + let by_short = query_image_at(root, "alpine:3.20").unwrap(); + let by_canonical = query_image_at(root, "docker.io/library/alpine:3.20").unwrap(); + + assert!( + by_short.is_some(), + "short ref should resolve cached manifest" + ); + assert!( + by_canonical.is_some(), + "canonical ref should resolve cached manifest" + ); + + let short_info = by_short.unwrap(); + let canonical_info = by_canonical.unwrap(); + + assert_eq!(short_info.digest, canonical_info.digest); + assert_eq!(short_info.layer_count, canonical_info.layer_count); + assert_eq!(short_info.layers, canonical_info.layers); + } + #[test] fn test_oci_platform_to_arch_linux_arm64() { assert_eq!(oci_platform_to_arch("linux/arm64"), "arm64"); @@ -2928,19 +3165,6 @@ mod tests { assert_eq!(oci_platform_to_arch("unknown"), "unknown"); } - #[test] - fn test_sanitize_image_name() { - assert_eq!(sanitize_image_name("alpine:latest"), "alpine_latest"); - assert_eq!( - sanitize_image_name("docker.io/library/alpine:3.18"), - "docker.io_library_alpine_3.18" - ); - assert_eq!( - sanitize_image_name("ghcr.io/owner/repo@sha256:abc123"), - "ghcr.io_owner_repo_sha256_abc123" - ); - } - #[test] fn test_extract_registry_from_image_normalizes_docker_hub() { assert_eq!(