1use std::path::PathBuf;
5use std::str::FromStr;
6use std::sync::LazyLock;
7
8use regex::Regex;
9use url::Url;
10
11#[derive(Debug, Clone)]
12pub enum Ref {
13 Local(PathBuf),
14 Http(Url),
15 Oci(String),
16 Name(String),
17}
18
19static OCI_RE: LazyLock<Regex> = LazyLock::new(|| {
20 Regex::new(
21 r"^(?:localhost(?::\d+)?|[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}(?::\d+)?)/[a-zA-Z0-9][\w./-]*(?::[\w][\w.-]*|@sha256:[a-fA-F0-9]+)?$"
22 ).unwrap()
23});
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ParseRefError(String);
27
28impl std::fmt::Display for ParseRefError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 f.write_str(&self.0)
31 }
32}
33impl std::error::Error for ParseRefError {}
34
35impl FromStr for Ref {
36 type Err = ParseRefError;
37 fn from_str(s: &str) -> Result<Self, Self::Err> {
38 if let Ok(url) = Url::parse(s) {
39 match url.scheme() {
40 "file" => {
41 let path = url.to_file_path().map_err(|()| {
42 ParseRefError(format!(
43 "invalid file:// URI: {s}\nuse an absolute path, e.g. file:///path/to/component.wasm"
44 ))
45 })?;
46 return Ok(Self::Local(path));
47 }
48 "oci" => return Ok(Self::Oci(s["oci://".len()..].to_string())),
49 "http" | "https" => return Ok(Self::Http(url)),
50 _ => {}
51 }
52 }
53 if OCI_RE.is_match(s) {
54 return Ok(Self::Oci(s.to_string()));
55 }
56 if s.contains('/') || s.contains('\\') || s.ends_with(".wasm") || s.starts_with('.') {
57 return Ok(Self::Local(PathBuf::from(s)));
58 }
59 Ok(Self::Name(s.to_string()))
60 }
61}
62
63impl std::fmt::Display for Ref {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 Self::Local(p) => write!(f, "{}", p.display()),
67 Self::Http(u) => write!(f, "{u}"),
68 Self::Oci(r) => write!(f, "{r}"),
69 Self::Name(n) => write!(f, "{n}"),
70 }
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 fn parse(s: &str) -> Ref {
80 s.parse().unwrap()
81 }
82
83 #[test]
84 fn parse_https_url() {
85 assert!(matches!(
86 parse("https://example.com/comp.wasm"),
87 Ref::Http(_)
88 ));
89 }
90
91 #[test]
92 fn parse_http_url() {
93 assert!(matches!(
94 parse("http://localhost:8080/comp.wasm"),
95 Ref::Http(_)
96 ));
97 }
98
99 #[test]
100 fn parse_explicit_oci() {
101 assert!(
102 matches!(parse("oci://ghcr.io/actcore/sqlite:latest"), Ref::Oci(r) if r == "ghcr.io/actcore/sqlite:latest")
103 );
104 }
105
106 #[test]
107 fn parse_oci_with_tag() {
108 assert!(matches!(
109 parse("ghcr.io/actcore/component-sqlite:latest"),
110 Ref::Oci(_)
111 ));
112 }
113
114 #[test]
115 fn parse_oci_with_digest() {
116 assert!(matches!(
117 parse("ghcr.io/actcore/sqlite@sha256:abc123"),
118 Ref::Oci(_)
119 ));
120 }
121
122 #[test]
123 fn parse_oci_no_tag() {
124 assert!(matches!(parse("ghcr.io/actcore/sqlite"), Ref::Oci(_)));
125 }
126
127 #[test]
128 fn parse_oci_semver_tag() {
129 assert!(matches!(parse("ghcr.io/actpkg/sqlite:0.1.0"), Ref::Oci(_)));
130 }
131
132 #[test]
133 fn parse_local_relative() {
134 assert!(matches!(parse("./component.wasm"), Ref::Local(_)));
135 }
136
137 #[test]
138 fn parse_local_absolute() {
139 assert!(matches!(parse("/tmp/component.wasm"), Ref::Local(_)));
140 }
141
142 #[test]
143 fn parse_local_wasm_extension() {
144 assert!(matches!(parse("component.wasm"), Ref::Local(_)));
145 }
146
147 #[test]
148 fn parse_bare_name() {
149 assert!(matches!(parse("component-sqlite"), Ref::Name(n) if n == "component-sqlite"));
150 }
151
152 #[test]
153 fn parse_bare_name_simple() {
154 assert!(matches!(parse("sqlite"), Ref::Name(n) if n == "sqlite"));
155 }
156
157 #[test]
158 fn parse_file_uri_absolute() {
159 match parse("file:///abs/x.wasm") {
160 Ref::Local(p) => assert_eq!(p, std::path::Path::new("/abs/x.wasm")),
161 other => panic!("expected Local, got {other:?}"),
162 }
163 }
164
165 #[test]
166 fn parse_file_uri_relative_errors() {
167 assert!("file://./x.wasm".parse::<Ref>().is_err());
168 }
169
170 #[test]
171 fn parse_file_uri_opaque_errors() {
172 assert!("file://x.wasm".parse::<Ref>().is_err());
173 }
174
175 #[test]
178 fn parse_oci_with_registry_port() {
179 assert!(matches!(parse("localhost:5000/foo:tag"), Ref::Oci(_)));
180 }
181}