| 1 | //! Image addresses: `g1t.sh/<workspace>/<name>[:<tag>]`. |
| 2 | |
| 3 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 4 | pub 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 | |
| 12 | impl 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 | |
| 67 | impl 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)] |
| 74 | mod 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 | } |