Skip to content

g1t/crates/g1t/src/reference.rs

120 lines4,237 bytesCodeBlame
1//! Image addresses: `g1t.sh/<workspace>/<name>[:<tag>]`.
2
3#[derive(Clone, Debug, PartialEq, Eq)]
4pub struct Reference {
5 /// The registry, `g1t.sh` or a host of your own (`localhost:8790`).
6 pub host: String,
7 /// The image's name in the registry, `<workspace>/<name>`.
8 pub name: String,
9 pub tag: String,
10}
11
12impl Reference {
13 /// Reads an address that names its registry. `None` when the first part
14 /// is not a host (a local name like `web:1.0`).
15 pub fn parse(text: &str) -> Result<Option<Reference>, String> {
16 let text = text.trim();
17 if text.contains('@') {
18 return Err(format!("`{text}`: push to a tag, not a digest."));
19 }
20 let Some((host, rest)) = text.split_once('/') else {
21 return Ok(None);
22 };
23 if !(host.contains('.') || host.contains(':') || host == "localhost") {
24 return Ok(None);
25 }
26 // A colon after the last slash starts the tag.
27 let (name, tag) = match rest.rsplit_once(':') {
28 Some((name, tag)) if !tag.contains('/') => (name, tag),
29 _ => (rest, "latest"),
30 };
31 if name.is_empty() || tag.is_empty() {
32 return Err(format!(
33 "`{text}` is not an image address, like g1t.sh/<workspace>/<name>:<tag>."
34 ));
35 }
36 if !name.contains('/') && host == "g1t.sh" {
37 return Err(format!(
38 "`{text}`: images on g1t.sh start with a workspace, like g1t.sh/<workspace>/<name>:<tag>."
39 ));
40 }
41 let valid = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-/".contains(c);
42 if !name.chars().all(valid) {
43 return Err(format!(
44 "`{name}`: image names are lowercase letters and digits, separated by `.`, `_`, `-` or `/`."
45 ));
46 }
47 let valid_tag = |c: char| c.is_ascii_alphanumeric() || "._-".contains(c);
48 if tag.len() > 128 || !tag.chars().all(valid_tag) {
49 return Err(format!(
50 "`{tag}` is not a tag: letters, digits, `.`, `_` and `-`, at most 128."
51 ));
52 }
53 Ok(Some(Reference {
54 host: host.to_owned(),
55 name: name.to_owned(),
56 tag: tag.to_owned(),
57 }))
58 }
59
60 /// Where the registry answers: plain HTTP on this machine, HTTPS elsewhere.
61 pub fn base_url(&self) -> String {
62 let local = self.host.starts_with("localhost") || self.host.starts_with("127.0.0.1");
63 format!("{}://{}", if local { "http" } else { "https" }, self.host)
64 }
65}
66
67impl std::fmt::Display for Reference {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 write!(f, "{}/{}:{}", self.host, self.name, self.tag)
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 fn parse(text: &str) -> Reference {
78 Reference::parse(text).unwrap().unwrap()
79 }
80
81 #[test]
82 fn addresses_split_into_host_name_and_tag() {
83 let r = parse("g1t.sh/acme/web:1.4.0");
84 assert_eq!(
85 (r.host.as_str(), r.name.as_str(), r.tag.as_str()),
86 ("g1t.sh", "acme/web", "1.4.0")
87 );
88 assert_eq!(r.base_url(), "https://g1t.sh");
89 assert_eq!(r.to_string(), "g1t.sh/acme/web:1.4.0");
90 }
91
92 #[test]
93 fn the_tag_defaults_to_latest() {
94 assert_eq!(parse("g1t.sh/acme/tools/web").tag, "latest");
95 }
96
97 #[test]
98 fn a_host_with_a_port_is_not_a_tag() {
99 let r = parse("localhost:8790/acme/web");
100 assert_eq!(
101 (r.host.as_str(), r.name.as_str(), r.tag.as_str()),
102 ("localhost:8790", "acme/web", "latest")
103 );
104 assert_eq!(r.base_url(), "http://localhost:8790");
105 }
106
107 #[test]
108 fn local_names_have_no_registry() {
109 assert_eq!(Reference::parse("web:1.0"), Ok(None));
110 assert_eq!(Reference::parse("library/alpine:3.20"), Ok(None));
111 }
112
113 #[test]
114 fn bad_addresses_are_refused() {
115 assert!(Reference::parse("g1t.sh/web:1").is_err());
116 assert!(Reference::parse("g1t.sh/Acme/Web:1").is_err());
117 assert!(Reference::parse("g1t.sh/acme/web@sha256:00").is_err());
118 assert!(Reference::parse("g1t.sh/acme/web:bad tag").is_err());
119 }
120}