smolvm_protocol/image_ref.rs
1//! Image reference canonicalization.
2//!
3//! OCI image references have many valid spellings for the same image.
4//! This module provides [`normalize_image_ref`], which maps every spelling
5//! to a single canonical form so that cache keys, log messages, and
6//! protocol messages are consistent regardless of how the caller spelled
7//! the reference.
8
9/// Canonicalize an OCI image reference.
10///
11/// All equivalent spellings of the same image produce an identical string,
12/// which is safe to use as a cache key or protocol field.
13///
14/// # Normalization rules (applied in order)
15///
16/// 1. `index.docker.io` is rewritten to `docker.io` (legacy alias).
17/// 2. A missing registry defaults to `docker.io`.
18/// 3. Single-component names on `docker.io` receive the `library/` prefix
19/// (e.g. `alpine` → `docker.io/library/alpine`).
20/// 4. A missing tag defaults to `:latest`. When a digest (`@sha256:…`) is
21/// present it takes precedence and any tag is dropped.
22///
23/// # Examples
24///
25/// ```
26/// use smolvm_protocol::normalize_image_ref;
27///
28/// assert_eq!(normalize_image_ref("alpine"),
29/// "docker.io/library/alpine:latest");
30/// assert_eq!(normalize_image_ref("alpine:3.20"),
31/// "docker.io/library/alpine:3.20");
32/// assert_eq!(normalize_image_ref("docker.io/alpine:3.20"),
33/// "docker.io/library/alpine:3.20");
34/// assert_eq!(normalize_image_ref("docker.io/library/alpine:3.20"),
35/// "docker.io/library/alpine:3.20");
36/// assert_eq!(normalize_image_ref("ghcr.io/owner/repo:v1"),
37/// "ghcr.io/owner/repo:v1");
38/// ```
39pub fn normalize_image_ref(image: &str) -> String {
40 // Local image sources (host-resolved `local:<hash>` / `local-dir:<path>`)
41 // are not registry references — pass them through unchanged.
42 if image.starts_with("local:") || image.starts_with("local-dir:") {
43 return image.to_string();
44 }
45
46 // 1. Resolve index.docker.io alias.
47 let owned;
48 let image = if let Some(rest) = image.strip_prefix("index.docker.io/") {
49 owned = format!("docker.io/{rest}");
50 owned.as_str()
51 } else {
52 image
53 };
54
55 // 2. and 3. Separate the digest, and the tag from the bare repository.
56 let (ref_no_tag, tag, digest) = split_suffix(image);
57
58 // 4. Detect registry: the first '/'-delimited component is a registry
59 // hostname when it contains '.' or ':' (port). Everything else is
60 // an implicit docker.io reference.
61 let (registry, path) = split_registry(ref_no_tag);
62
63 // 5. Single-component docker.io paths get the `library/` prefix.
64 let canonical_path = if registry == "docker.io" && !path.contains('/') {
65 format!("library/{path}")
66 } else {
67 path.to_string()
68 };
69
70 // 6. Suffix: digest wins over tag; absent tag defaults to `:latest`.
71 let suffix = match digest {
72 Some(d) => format!("@{d}"),
73 None => format!(":{}", tag.unwrap_or("latest")),
74 };
75
76 format!("{registry}/{canonical_path}{suffix}")
77}
78
79/// Split an image reference into `(repo, tag, digest)`.
80///
81/// The repo is the `registry/repository` portion with no suffix.
82///
83/// The digest is everything after '@'. When a digest is present the tag is
84/// informational (the digest is authoritative), but both are still returned.
85///
86/// The tag separator is the last ':' with no '/' after it. A colon that is
87/// part of a registry hostname (e.g. `localhost:5000/repo`) always has a '/'
88/// after it, so this rule does not mistake a port for a tag.
89fn split_suffix(image: &str) -> (&str, Option<&str>, Option<&str>) {
90 let (ref_no_digest, digest) = match image.split_once('@') {
91 Some((left, right)) => (left, Some(right)),
92 None => (image, None),
93 };
94 let (repo, tag) = match ref_no_digest.rfind(':') {
95 Some(pos) if !ref_no_digest[pos..].contains('/') => {
96 (&ref_no_digest[..pos], Some(&ref_no_digest[pos + 1..]))
97 }
98 _ => (ref_no_digest, None),
99 };
100 (repo, tag, digest)
101}
102
103/// Strip the tag and/or digest suffix from an image reference, returning the registry/repo
104///
105/// # Examples
106///
107/// ```
108/// use smolvm_protocol::image_repo;
109///
110/// assert_eq!(image_repo("ghcr.io/owner/repo@sha256:abc"), "ghcr.io/owner/repo");
111/// assert_eq!(image_repo("ghcr.io/owner/repo:v1"), "ghcr.io/owner/repo");
112/// ```
113pub fn image_repo(image: &str) -> &str {
114 split_suffix(image).0
115}
116
117/// Split a tag-free, digest-free image string into `(registry, path)`.
118///
119/// Returns `("docker.io", whole_string)` when no explicit registry is found.
120fn split_registry(image: &str) -> (&str, &str) {
121 if let Some(slash) = image.find('/') {
122 let prefix = &image[..slash];
123 if prefix.contains('.') || prefix.contains(':') {
124 return (prefix, &image[slash + 1..]);
125 }
126 }
127 ("docker.io", image)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn test_normalize_image_ref() {
136 let cases: &[(&str, &str)] = &[
137 // Bare name and with tag — docker.io library prefix + :latest default.
138 ("alpine", "docker.io/library/alpine:latest"),
139 ("alpine:3.20", "docker.io/library/alpine:3.20"),
140 // Explicit docker.io without library/ — library/ is inserted.
141 ("docker.io/alpine:3.20", "docker.io/library/alpine:3.20"),
142 // Already canonical — idempotent.
143 (
144 "docker.io/library/alpine:3.20",
145 "docker.io/library/alpine:3.20",
146 ),
147 // index.docker.io legacy alias.
148 (
149 "index.docker.io/library/alpine",
150 "docker.io/library/alpine:latest",
151 ),
152 // library/ without a registry prefix.
153 ("library/alpine", "docker.io/library/alpine:latest"),
154 // User-namespaced docker.io image — no extra library/ prefix.
155 ("myuser/myimage:v2", "docker.io/myuser/myimage:v2"),
156 // Non-docker.io registry.
157 ("ghcr.io/owner/repo", "ghcr.io/owner/repo:latest"),
158 ("ghcr.io/owner/repo:v1", "ghcr.io/owner/repo:v1"),
159 // Port in registry — colon-detection must not confuse port with tag.
160 ("localhost:5000/myimage:dev", "localhost:5000/myimage:dev"),
161 ];
162
163 for (input, expected) in cases {
164 assert_eq!(
165 normalize_image_ref(input),
166 *expected,
167 "normalize_image_ref({input:?})"
168 );
169 }
170 }
171
172 #[test]
173 fn test_normalize_digest_refs() {
174 let digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
175
176 // Digest without tag.
177 assert_eq!(
178 normalize_image_ref(&format!("alpine@{digest}")),
179 format!("docker.io/library/alpine@{digest}"),
180 );
181
182 // Digest with tag — tag is dropped, digest is authoritative.
183 assert_eq!(
184 normalize_image_ref(&format!("alpine:3.20@{digest}")),
185 format!("docker.io/library/alpine@{digest}"),
186 );
187 }
188
189 #[test]
190 fn test_image_repo() {
191 let digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
192
193 // Digest suffix is stripped.
194 assert_eq!(
195 image_repo(&format!("ghcr.io/owner/repo@{digest}")),
196 "ghcr.io/owner/repo",
197 );
198 // Tag suffix is stripped.
199 assert_eq!(image_repo("ghcr.io/owner/repo:v1"), "ghcr.io/owner/repo");
200 // No suffix — returned unchanged.
201 assert_eq!(image_repo("ghcr.io/owner/repo"), "ghcr.io/owner/repo");
202 // Port in registry — colon-detection must not confuse port with tag.
203 assert_eq!(
204 image_repo("localhost:5000/myimage:dev"),
205 "localhost:5000/myimage",
206 );
207 assert_eq!(
208 image_repo("localhost:5000/myimage"),
209 "localhost:5000/myimage"
210 );
211 }
212}