Skip to main content

act_store/
reference.rs

1//! Component reference parsing. The shared source of truth for act-cli and
2//! act-toolserver. Ported from act-cli's resolve.rs (parsing half only).
3
4use 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    /// Helper — parse and unwrap (infallible).
79    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    /// Regression guard: a registry ref with a port is `Url::parse`-able with a
176    /// non-owned scheme (`localhost`) and must still resolve via the OCI regex.
177    #[test]
178    fn parse_oci_with_registry_port() {
179        assert!(matches!(parse("localhost:5000/foo:tag"), Ref::Oci(_)));
180    }
181}