Skip to main content

pf_registry/
image_ref.rs

1// SPDX-License-Identifier: MIT
2//! URL-scheme parser for ProcessFork registry references.
3
4use std::path::PathBuf;
5
6/// One of the five supported registry URLs.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum ImageRef {
9    /// `file:///abs/path` — local-FS-backed test registry.
10    File { path: PathBuf },
11    /// `hf://<user>/<repo>[:<tag>]` — Hugging Face Hub.
12    Hf {
13        user: String,
14        repo: String,
15        tag: Option<String>,
16    },
17    /// `s3://<bucket>/<prefix>` — AWS S3 / R2 / MinIO.
18    S3 { bucket: String, prefix: String },
19    /// `ipfs://<CID>` — IPFS pinned manifest.
20    Ipfs { cid: String },
21    /// `oci://<host>:<port>/<repo>[:<tag>]` — local OCI Distribution.
22    Oci {
23        host: String,
24        port: Option<u16>,
25        repo: String,
26        tag: Option<String>,
27    },
28}
29
30#[derive(Debug, thiserror::Error)]
31pub enum ImageRefError {
32    #[error("unrecognised URL scheme in {0:?} (expected file/hf/s3/ipfs/oci://)")]
33    BadScheme(String),
34    #[error("malformed {scheme} URL: {url:?} ({reason})")]
35    Malformed {
36        scheme: &'static str,
37        url: String,
38        reason: &'static str,
39    },
40}
41
42impl ImageRef {
43    /// Parse a URL into the typed enum.
44    pub fn parse(url: &str) -> Result<Self, ImageRefError> {
45        if let Some(rest) = url.strip_prefix("file://") {
46            return Ok(Self::File {
47                path: PathBuf::from(rest),
48            });
49        }
50        if let Some(rest) = url.strip_prefix("hf://") {
51            let (path, tag) = split_tag(rest);
52            let parts: Vec<&str> = path.splitn(2, '/').collect();
53            if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
54                return Err(ImageRefError::Malformed {
55                    scheme: "hf",
56                    url: url.to_owned(),
57                    reason: "expected hf://<user>/<repo>[:<tag>]",
58                });
59            }
60            return Ok(Self::Hf {
61                user: parts[0].to_owned(),
62                repo: parts[1].to_owned(),
63                tag,
64            });
65        }
66        if let Some(rest) = url.strip_prefix("s3://") {
67            let parts: Vec<&str> = rest.splitn(2, '/').collect();
68            if parts.is_empty() || parts[0].is_empty() {
69                return Err(ImageRefError::Malformed {
70                    scheme: "s3",
71                    url: url.to_owned(),
72                    reason: "expected s3://<bucket>/<prefix>",
73                });
74            }
75            return Ok(Self::S3 {
76                bucket: parts[0].to_owned(),
77                prefix: parts.get(1).copied().unwrap_or("").to_owned(),
78            });
79        }
80        if let Some(rest) = url.strip_prefix("ipfs://") {
81            if rest.is_empty() {
82                return Err(ImageRefError::Malformed {
83                    scheme: "ipfs",
84                    url: url.to_owned(),
85                    reason: "expected ipfs://<CID>",
86                });
87            }
88            return Ok(Self::Ipfs {
89                cid: rest.to_owned(),
90            });
91        }
92        if let Some(rest) = url.strip_prefix("oci://") {
93            let (path, tag) = split_tag(rest);
94            // Split `host[:port]/repo` — the slash separator delimits the repo.
95            let (hostport, repo) = path.split_once('/').ok_or(ImageRefError::Malformed {
96                scheme: "oci",
97                url: url.to_owned(),
98                reason: "expected oci://<host>[:<port>]/<repo>[:<tag>]",
99            })?;
100            let (host, port) = if let Some((h, p)) = hostport.split_once(':') {
101                (
102                    h.to_owned(),
103                    Some(p.parse().map_err(|_| ImageRefError::Malformed {
104                        scheme: "oci",
105                        url: url.to_owned(),
106                        reason: "port is not a u16",
107                    })?),
108                )
109            } else {
110                (hostport.to_owned(), None)
111            };
112            return Ok(Self::Oci {
113                host,
114                port,
115                repo: repo.to_owned(),
116                tag,
117            });
118        }
119        Err(ImageRefError::BadScheme(url.to_owned()))
120    }
121}
122
123/// Helper: split `prefix[:tag]` so a tag containing `/` (illegal in
124/// HF / OCI tags) doesn't get accidentally captured.
125fn split_tag(s: &str) -> (&str, Option<String>) {
126    if let Some((path, tag)) = s.rsplit_once(':') {
127        // Make sure we didn't split on a `:` that's part of a host:port —
128        // HF / OCI tags can't contain `/`, so guard against that.
129        if !tag.contains('/') {
130            return (path, Some(tag.to_owned()));
131        }
132    }
133    (s, None)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn file_scheme() {
142        assert_eq!(
143            ImageRef::parse("file:///tmp/store").unwrap(),
144            ImageRef::File {
145                path: PathBuf::from("/tmp/store")
146            }
147        );
148    }
149
150    #[test]
151    fn hf_basic() {
152        assert_eq!(
153            ImageRef::parse("hf://alice/refactor-2026-05-05").unwrap(),
154            ImageRef::Hf {
155                user: "alice".into(),
156                repo: "refactor-2026-05-05".into(),
157                tag: None,
158            }
159        );
160    }
161
162    #[test]
163    fn hf_with_tag() {
164        assert_eq!(
165            ImageRef::parse("hf://alice/foo:v1").unwrap(),
166            ImageRef::Hf {
167                user: "alice".into(),
168                repo: "foo".into(),
169                tag: Some("v1".into()),
170            }
171        );
172    }
173
174    #[test]
175    fn s3_with_prefix() {
176        assert_eq!(
177            ImageRef::parse("s3://my-bucket/agents/refactor").unwrap(),
178            ImageRef::S3 {
179                bucket: "my-bucket".into(),
180                prefix: "agents/refactor".into(),
181            }
182        );
183    }
184
185    #[test]
186    fn ipfs_cid() {
187        assert_eq!(
188            ImageRef::parse("ipfs://bafyabc").unwrap(),
189            ImageRef::Ipfs {
190                cid: "bafyabc".into()
191            }
192        );
193    }
194
195    #[test]
196    fn oci_host_port_repo_tag() {
197        assert_eq!(
198            ImageRef::parse("oci://harbor.local:8080/myorg/img:v3").unwrap(),
199            ImageRef::Oci {
200                host: "harbor.local".into(),
201                port: Some(8080),
202                repo: "myorg/img".into(),
203                tag: Some("v3".into()),
204            }
205        );
206    }
207
208    #[test]
209    fn bad_scheme_errors() {
210        assert!(matches!(
211            ImageRef::parse("ftp://x").unwrap_err(),
212            ImageRefError::BadScheme(_)
213        ));
214    }
215
216    #[test]
217    fn hf_missing_repo() {
218        assert!(ImageRef::parse("hf://alice").is_err());
219    }
220}