g1t/services/packages/src/names.rs

252 lines9,386 bytesCodeBlame
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
8use crate::digest::Digest;
9
10/// The longest name a client may give, workspace included.
11const MAX_NAME: usize = 255;
12/// The longest tag the spec allows.
13const MAX_TAG: usize = 128;
14
15/// An image's name, split into its workspace and the rest.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ImageName {
18 pub workspace: String,
19 /// Without the workspace: `web/api` for `acme/web/api`.
20 pub name: String,
21}
22
23impl 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 `-`.
38pub 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.
64fn 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.
74pub 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 `-`.
96pub 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)]
108pub enum Reference {
109 Tag(String),
110 Digest(Digest),
111}
112
113impl 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)]
127pub 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.
142pub 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)]
181mod 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}