Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1 | //! Container image names and registry paths. |
| 2 | //! | |
| 3 | //! An image is `g1t.sh/<workspace>/<name>`, where `<name>` may hold `/`. | |
| 4 | //! Each part follows the OCI Distribution rules for path components, and | |
| 5 | //! the first is a workspace's slug. The routes are the ones the | |
| 6 | //! Distribution 1.1 spec lists, under `/v2/`. | |
| 7 | ||
| 8 | use crate::digest::Digest; | |
| 9 | ||
| 10 | /// The longest name a client may give, workspace included. | |
| 11 | const MAX_NAME: usize = 255; | |
| 12 | /// The longest tag the spec allows. | |
| 13 | const MAX_TAG: usize = 128; | |
| 14 | ||
| 15 | /// An image's name, split into its workspace and the rest. | |
| 16 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 17 | pub struct ImageName { | |
| 18 | pub workspace: String, | |
| 19 | /// Without the workspace: `web/api` for `acme/web/api`. | |
| 20 | pub name: String, | |
| 21 | } | |
| 22 | ||
| 23 | impl ImageName { | |
| 24 | /// `workspace/name`, as clients write it. | |
| 25 | pub fn full(&self) -> String { | |
| 26 | format!("{}/{}", self.workspace, self.name) | |
| 27 | } | |
| 28 | ||
| 29 | /// The repository a package of this name links to on its first push: | |
| 30 | /// the name's first part, in the same workspace. | |
| 31 | pub fn repo_name(&self) -> &str { | |
| 32 | self.name.split('/').next().unwrap_or(&self.name) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | /// Lowercase letters and digits, joined by single `.` or `_`, a double | |
| 37 | /// `__`, or any run of `-`. | |
| 38 | pub fn valid_component(part: &str) -> bool { | |
| 39 | let bytes = part.as_bytes(); | |
| 40 | let alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); | |
| 41 | if bytes.is_empty() || !alnum(bytes[0]) || !alnum(bytes[bytes.len() - 1]) { | |
| 42 | return false; | |
| 43 | } | |
| 44 | let mut i = 0; | |
| 45 | while i < bytes.len() { | |
| 46 | if alnum(bytes[i]) { | |
| 47 | i += 1; | |
| 48 | continue; | |
| 49 | } | |
| 50 | let start = i; | |
| 51 | while i < bytes.len() && !alnum(bytes[i]) { | |
| 52 | i += 1; | |
| 53 | } | |
| 54 | let run = &part[start..i]; | |
| 55 | let dashes = run.bytes().all(|b| b == b'-'); | |
| 56 | if !(dashes || run == "." || run == "_" || run == "__") { | |
| 57 | return false; | |
| 58 | } | |
| 59 | } | |
| 60 | true | |
| 61 | } | |
| 62 | ||
| 63 | /// A workspace slug's shape: lowercase letters, digits and single hyphens. | |
| 64 | fn valid_workspace(slug: &str) -> bool { | |
| 65 | !slug.is_empty() | |
| 66 | && slug.len() <= 39 | |
| 67 | && slug.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') | |
| 68 | && !slug.starts_with('-') | |
| 69 | && !slug.ends_with('-') | |
| 70 | && !slug.contains("--") | |
| 71 | } | |
| 72 | ||
| 73 | /// Reads `workspace/name`, or says what is wrong with it. | |
| 74 | pub fn parse_name(full: &str) -> Result<ImageName, String> { | |
| 75 | if full.len() > MAX_NAME { | |
| 76 | return Err(format!("An image name is at most {MAX_NAME} characters.")); | |
| 77 | } | |
| 78 | let Some((workspace, name)) = full.split_once('/') else { | |
| 79 | return Err("An image name starts with its workspace: g1t.sh/<workspace>/<name>.".to_owned()); | |
| 80 | }; | |
| 81 | if !valid_workspace(workspace) { | |
| 82 | return Err(format!("{workspace} is not a workspace's name.")); | |
| 83 | } | |
| 84 | if name.split('/').any(|part| !valid_component(part)) { | |
| 85 | return Err(format!( | |
| 86 | "{full} is not a valid image name: lowercase letters and digits, separated by `.`, `_`, `__`, `-` or `/`." | |
| 87 | )); | |
| 88 | } | |
| 89 | Ok(ImageName { | |
| 90 | workspace: workspace.to_owned(), | |
| 91 | name: name.to_owned(), | |
| 92 | }) | |
| 93 | } | |
| 94 | ||
| 95 | /// A tag's shape: a letter, digit or `_`, then up to 127 of those, `.` and `-`. | |
| 96 | pub fn valid_tag(tag: &str) -> bool { | |
| 97 | let bytes = tag.as_bytes(); | |
| 98 | !bytes.is_empty() | |
| 99 | && bytes.len() <= MAX_TAG | |
| 100 | && (bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_') | |
| 101 | && bytes | |
| 102 | .iter() | |
| 103 | .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) | |
| 104 | } | |
| 105 | ||
| 106 | /// What a manifest is asked for by. | |
| 107 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 108 | pub enum Reference { | |
| 109 | Tag(String), | |
| 110 | Digest(Digest), | |
| 111 | } | |
| 112 | ||
| 113 | impl Reference { | |
| 114 | pub fn parse(text: &str) -> Option<Reference> { | |
| 115 | if text.contains(':') { | |
| 116 | Digest::parse(text).map(Reference::Digest) | |
| 117 | } else if valid_tag(text) { | |
| 118 | Some(Reference::Tag(text.to_owned())) | |
| 119 | } else { | |
| 120 | None | |
| 121 | } | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | /// One of the registry's endpoints, with the name it is for still unchecked. | |
| 126 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 127 | pub enum Route { | |
| 128 | /// `/v2/`: whether the client is signed in. | |
| 129 | Base, | |
| 130 | /// `/v2/token`: a bearer token for the scopes asked for. | |
| 131 | Token, | |
| 132 | Manifest { name: String, reference: String }, | |
| 133 | Blob { name: String, digest: String }, | |
| 134 | /// `/v2/<name>/blobs/uploads/`: starting an upload. | |
| 135 | Uploads { name: String }, | |
| 136 | Upload { name: String, id: String }, | |
| 137 | Tags { name: String }, | |
| 138 | Referrers { name: String, digest: String }, | |
| 139 | } | |
| 140 | ||
| 141 | /// Which endpoint a path is, if it is one. | |
| 142 | pub fn route(path: &str) -> Option<Route> { | |
| 143 | let rest = path.strip_prefix("/v2")?; | |
| 144 | if rest.is_empty() || rest == "/" { | |
| 145 | return Some(Route::Base); | |
| 146 | } | |
| 147 | let rest = rest.strip_prefix('/')?; | |
| 148 | if rest == "token" { | |
| 149 | return Some(Route::Token); | |
| 150 | } | |
| 151 | // The name may hold any of the words below as a part of its own, so | |
| 152 | // the last place one appears is where the name ends. | |
| 153 | let split = |marker: &str| rest.rfind(marker).map(|at| (&rest[..at], &rest[at + marker.len()..])); | |
| 154 | if let Some((name, tail)) = split("/blobs/uploads") { | |
| 155 | let tail = tail.trim_start_matches('/'); | |
| 156 | return Some(if tail.is_empty() { | |
| 157 | Route::Uploads { name: name.to_owned() } | |
| 158 | } else if !tail.contains('/') { | |
| 159 | Route::Upload { name: name.to_owned(), id: tail.to_owned() } | |
| 160 | } else { | |
| 161 | return None; | |
| 162 | }); | |
| 163 | } | |
| 164 | let one = |tail: &str| (!tail.is_empty() && !tail.contains('/')).then(|| tail.to_owned()); | |
| 165 | if let Some((name, tail)) = split("/manifests/") { | |
| 166 | return Some(Route::Manifest { name: name.to_owned(), reference: one(tail)? }); | |
| 167 | } | |
| 168 | if let Some((name, tail)) = split("/blobs/") { | |
| 169 | return Some(Route::Blob { name: name.to_owned(), digest: one(tail)? }); | |
| 170 | } | |
| 171 | if let Some((name, tail)) = split("/referrers/") { | |
| 172 | return Some(Route::Referrers { name: name.to_owned(), digest: one(tail)? }); | |
| 173 | } | |
| 174 | if let Some(name) = rest.strip_suffix("/tags/list") { | |
| 175 | return Some(Route::Tags { name: name.to_owned() }); | |
| 176 | } | |
| 177 | None | |
| 178 | } | |
| 179 | ||
| 180 | #[cfg(test)] | |
| 181 | mod tests { | |
| 182 | use super::*; | |
| 183 | ||
| 184 | #[test] | |
| 185 | fn components_follow_the_distribution_rules() { | |
| 186 | for good in ["web", "a", "web-api", "web--api", "web.api", "web_api", "web__api", "v1", "0"] { | |
| 187 | assert!(valid_component(good), "{good}"); | |
| 188 | } | |
| 189 | for bad in ["", "Web", "-web", "web-", "web..api", "web___api", "web._api", ".web", "web api", "wéb"] { | |
| 190 | assert!(!valid_component(bad), "{bad}"); | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | #[test] | |
| 195 | fn a_name_starts_with_its_workspace_and_may_have_more_parts() { | |
| 196 | let name = parse_name("acme/web/api").unwrap(); | |
| 197 | assert_eq!(name.workspace, "acme"); | |
| 198 | assert_eq!(name.name, "web/api"); | |
| 199 | assert_eq!(name.repo_name(), "web"); | |
| 200 | assert_eq!(name.full(), "acme/web/api"); | |
| 201 | assert!(parse_name("acme").unwrap_err().contains("workspace")); | |
| 202 | assert!(parse_name("-acme/web").is_err()); | |
| 203 | assert!(parse_name("acme/Web").is_err()); | |
| 204 | assert!(parse_name("acme/web/").is_err()); | |
| 205 | assert!(parse_name("acme//web").is_err()); | |
| 206 | assert!(parse_name(&format!("acme/{}", "a".repeat(260))).is_err()); | |
| 207 | } | |
| 208 | ||
| 209 | #[test] | |
| 210 | fn tags_and_digests_are_told_apart() { | |
| 211 | assert_eq!(Reference::parse("latest"), Some(Reference::Tag("latest".into()))); | |
| 212 | assert_eq!(Reference::parse("v1.2.3-rc_1"), Some(Reference::Tag("v1.2.3-rc_1".into()))); | |
| 213 | assert!(Reference::parse(".hidden").is_none()); | |
| 214 | assert!(Reference::parse(&"a".repeat(129)).is_none()); | |
| 215 | let digest = format!("sha256:{}", "a".repeat(64)); | |
| 216 | assert!(matches!(Reference::parse(&digest), Some(Reference::Digest(_)))); | |
| 217 | assert!(Reference::parse("sha256:short").is_none()); | |
| 218 | } | |
| 219 | ||
| 220 | #[test] | |
| 221 | fn every_endpoint_is_routed() { | |
| 222 | assert_eq!(route("/v2/"), Some(Route::Base)); | |
| 223 | assert_eq!(route("/v2"), Some(Route::Base)); | |
| 224 | assert_eq!(route("/v2/token"), Some(Route::Token)); | |
| 225 | assert_eq!( | |
| 226 | route("/v2/acme/web/manifests/latest"), | |
| 227 | Some(Route::Manifest { name: "acme/web".into(), reference: "latest".into() }) | |
| 228 | ); | |
| 229 | assert_eq!( | |
| 230 | route("/v2/acme/web/blobs/sha256:abc"), | |
| 231 | Some(Route::Blob { name: "acme/web".into(), digest: "sha256:abc".into() }) | |
| 232 | ); | |
| 233 | assert_eq!(route("/v2/acme/web/blobs/uploads/"), Some(Route::Uploads { name: "acme/web".into() })); | |
| 234 | assert_eq!(route("/v2/acme/web/blobs/uploads"), Some(Route::Uploads { name: "acme/web".into() })); | |
| 235 | assert_eq!( | |
| 236 | route("/v2/acme/web/blobs/uploads/upl_1"), | |
| 237 | Some(Route::Upload { name: "acme/web".into(), id: "upl_1".into() }) | |
| 238 | ); | |
| 239 | assert_eq!(route("/v2/acme/a/b/tags/list"), Some(Route::Tags { name: "acme/a/b".into() })); | |
| 240 | assert_eq!( | |
| 241 | route("/v2/acme/web/referrers/sha256:abc"), | |
| 242 | Some(Route::Referrers { name: "acme/web".into(), digest: "sha256:abc".into() }) | |
| 243 | ); | |
| 244 | // A name with a part that looks like an endpoint's word. | |
| 245 | assert_eq!( | |
| 246 | route("/v2/acme/manifests/manifests/v1"), | |
| 247 | Some(Route::Manifest { name: "acme/manifests".into(), reference: "v1".into() }) | |
| 248 | ); | |
| 249 | assert_eq!(route("/v2/acme/web/other"), None); | |
| 250 | assert_eq!(route("/acme/web.git/info/refs"), None); | |
| 251 | } | |
| 252 | } |