1use std::fmt;
31
32pub const MAX_RESOURCE_LEN: usize = 512;
36
37pub const MAX_PATH_SEGMENTS: usize = 21;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResourceError {
48 input: String,
49 kind: ResourceErrorKind,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum ResourceErrorKind {
56 Empty,
58 TooLong,
60 Whitespace,
62 NonAscii,
64 HasScheme,
66 MissingForgeHost,
68 InvalidHost,
70 HasPort,
72 MissingOwner,
74 EmptySegment,
76 DotSegment,
78 InvalidCharacter(char),
80 TooManySegments {
82 max: usize,
84 },
85}
86
87impl ResourceError {
88 fn new(input: &str, kind: ResourceErrorKind) -> Self {
89 let input = if input.len() > 80 {
92 let mut end = 80;
93 while !input.is_char_boundary(end) {
94 end -= 1;
95 }
96 format!("{}…", &input[..end])
97 } else {
98 input.to_string()
99 };
100 Self { input, kind }
101 }
102
103 pub fn kind(&self) -> &ResourceErrorKind {
105 &self.kind
106 }
107
108 pub fn input(&self) -> &str {
110 &self.input
111 }
112
113 pub fn describe(&self, what: &str) -> String {
117 let v = &self.input;
118 let e = "`<forge-host>/<owner>[/<repo>]`";
119 match &self.kind {
120 ResourceErrorKind::Empty => {
121 format!("{what} is empty; expected {e}, e.g. `github.com/acme/widgets`")
122 }
123 ResourceErrorKind::TooLong => {
124 format!("{what} `{v}` is longer than {MAX_RESOURCE_LEN} bytes")
125 }
126 ResourceErrorKind::Whitespace => format!(
127 "{what} `{v}` contains whitespace or a control character; a resource is {e} \
128 with no spaces"
129 ),
130 ResourceErrorKind::NonAscii => format!(
131 "{what} `{v}` contains a non-ASCII character; forge hosts are written in \
132 punycode and owner/repo names are ASCII"
133 ),
134 ResourceErrorKind::HasScheme => format!(
135 "{what} `{v}` is a URL, not a resource; did you mean `{}`?",
136 suggest_without_scheme(v)
137 ),
138 ResourceErrorKind::MissingForgeHost => {
139 let bare = v.trim_matches('/').to_ascii_lowercase();
140 format!(
141 "{what} `{v}` is not forge-qualified (expected {e}); prefix the forge host, \
142 e.g. `github.com/{bare}` or `codeberg.org/{bare}`"
143 )
144 }
145 ResourceErrorKind::InvalidHost => format!(
146 "{what} `{v}` starts with an invalid forge host; a host is dot-separated labels \
147 of [a-z0-9-] (e.g. `github.com`, `git.example.org`) or `localhost`"
148 ),
149 ResourceErrorKind::HasPort => format!(
150 "{what} `{v}` carries a port; a resource names the forge by host alone — did you \
151 mean `{}`?",
152 suggest_without_port(v)
153 ),
154 ResourceErrorKind::MissingOwner => {
155 let host = v.trim_end_matches('/').to_ascii_lowercase();
156 format!(
157 "{what} `{v}` names a forge but no owner; e.g. `{host}/acme` or \
158 `{host}/acme/widgets`"
159 )
160 }
161 ResourceErrorKind::EmptySegment => format!(
162 "{what} `{v}` has an empty path segment (a doubled, leading or trailing `/`); \
163 did you mean `{}`?",
164 suggest_collapsed(v)
165 ),
166 ResourceErrorKind::DotSegment => {
167 format!("{what} `{v}` has a `.` or `..` segment; name the owner and repo directly")
168 }
169 ResourceErrorKind::InvalidCharacter(c) => format!(
170 "{what} `{v}` contains `{}`; owner and repo segments may only contain letters, \
171 digits, `.`, `_` and `-`",
172 c.escape_default()
173 ),
174 ResourceErrorKind::TooManySegments { max } => format!(
175 "{what} `{v}` has too many segments for this forge: at most {max} after the host \
176 (`<forge-host>/<owner>/<repo>`)"
177 ),
178 }
179 }
180}
181
182impl fmt::Display for ResourceError {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.write_str(&self.describe("resource"))
185 }
186}
187
188impl std::error::Error for ResourceError {}
189
190pub fn normalize_resource(raw: &str) -> Result<String, ResourceError> {
196 normalize_resource_with_depth(raw, MAX_PATH_SEGMENTS)
197}
198
199pub fn normalize_resource_with_depth(
202 raw: &str,
203 max_path_segments: usize,
204) -> Result<String, ResourceError> {
205 let err = |kind| ResourceError::new(raw, kind);
206
207 if raw.trim().is_empty() {
208 return Err(err(ResourceErrorKind::Empty));
209 }
210 if raw.len() > MAX_RESOURCE_LEN {
211 return Err(err(ResourceErrorKind::TooLong));
212 }
213 if raw.chars().any(|c| c.is_whitespace() || c.is_control()) {
214 return Err(err(ResourceErrorKind::Whitespace));
215 }
216 if !raw.is_ascii() {
217 return Err(err(ResourceErrorKind::NonAscii));
218 }
219 if raw.contains("://") {
220 return Err(err(ResourceErrorKind::HasScheme));
221 }
222
223 let lowered = raw.to_ascii_lowercase();
224 let segments: Vec<&str> = lowered.split('/').collect();
225 let host = segments[0];
226
227 if host.is_empty() {
230 return Err(err(match segments.get(1) {
233 Some(next) if !next.is_empty() && !looks_like_host(next) => {
234 ResourceErrorKind::MissingForgeHost
235 }
236 _ => ResourceErrorKind::EmptySegment,
237 }));
238 }
239 if let Some((name, _port)) = host.split_once(':') {
240 if looks_like_host(name) {
241 return Err(err(ResourceErrorKind::HasPort));
242 }
243 return Err(err(ResourceErrorKind::InvalidHost));
244 }
245 if !looks_like_host(host) {
246 return Err(err(if host.is_empty() {
247 ResourceErrorKind::EmptySegment
248 } else {
249 ResourceErrorKind::MissingForgeHost
250 }));
251 }
252 if !is_valid_host(host) {
253 return Err(err(ResourceErrorKind::InvalidHost));
254 }
255
256 let path = &segments[1..];
257 if path.is_empty() || (path.len() == 1 && path[0].is_empty()) {
258 return Err(err(ResourceErrorKind::MissingOwner));
259 }
260 if path.iter().any(|s| s.is_empty()) {
261 return Err(err(ResourceErrorKind::EmptySegment));
262 }
263 if path.iter().any(|s| *s == "." || *s == "..") {
264 return Err(err(ResourceErrorKind::DotSegment));
265 }
266 if let Some(c) = path
267 .iter()
268 .flat_map(|s| s.chars())
269 .find(|c| !is_segment_char(*c))
270 {
271 return Err(err(ResourceErrorKind::InvalidCharacter(c)));
272 }
273 if path.len() > max_path_segments {
274 return Err(err(ResourceErrorKind::TooManySegments {
275 max: max_path_segments,
276 }));
277 }
278
279 Ok(lowered)
280}
281
282pub fn resource_contains(scope: &str, resource: &str) -> bool {
290 match resource.strip_prefix(scope) {
291 Some(rest) => rest.is_empty() || rest.starts_with('/'),
292 None => false,
293 }
294}
295
296fn looks_like_host(segment: &str) -> bool {
299 segment.contains('.') || segment == "localhost"
300}
301
302fn is_valid_host(host: &str) -> bool {
305 host.len() <= 253
306 && host.split('.').all(|label| {
307 !label.is_empty()
308 && label.len() <= 63
309 && !label.starts_with('-')
310 && !label.ends_with('-')
311 && label
312 .bytes()
313 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
314 })
315}
316
317fn is_segment_char(c: char) -> bool {
318 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
319}
320
321fn suggest_without_scheme(input: &str) -> String {
322 let rest = input.split_once("://").map_or(input, |(_, rest)| rest);
323 let rest = rest.trim_end_matches('/');
324 rest.strip_suffix(".git")
325 .unwrap_or(rest)
326 .to_ascii_lowercase()
327}
328
329fn suggest_without_port(input: &str) -> String {
330 let lowered = input.to_ascii_lowercase();
331 match lowered.split_once('/') {
332 Some((host, rest)) => {
333 let host = host.split_once(':').map_or(host, |(h, _)| h);
334 format!("{host}/{rest}")
335 }
336 None => lowered,
337 }
338}
339
340fn suggest_collapsed(input: &str) -> String {
341 input
342 .to_ascii_lowercase()
343 .split('/')
344 .filter(|s| !s.is_empty())
345 .collect::<Vec<_>>()
346 .join("/")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 fn kind(raw: &str) -> ResourceErrorKind {
354 normalize_resource_with_depth(raw, 2)
355 .expect_err(raw)
356 .kind()
357 .clone()
358 }
359
360 #[test]
361 fn canonical_resources_pass_through() {
362 for ok in [
363 "github.com/acme",
364 "github.com/acme/widgets",
365 "codeberg.org/acme/widgets",
366 "git.example.org/acme/.github",
367 "ghe.corp.example/team-a/repo_1.x",
368 "localhost/acme/widgets",
369 ] {
370 assert_eq!(normalize_resource_with_depth(ok, 2).as_deref(), Ok(ok));
371 }
372 }
373
374 #[test]
375 fn case_is_folded_and_nothing_else() {
376 assert_eq!(
377 normalize_resource("GitHub.com/Acme/Widgets").as_deref(),
378 Ok("github.com/acme/widgets")
379 );
380 }
381
382 #[test]
383 fn the_legacy_owner_repo_form_is_refused_with_a_suggestion() {
384 let err = normalize_resource_with_depth("Acme/Widgets", 2).unwrap_err();
385 assert_eq!(err.kind(), &ResourceErrorKind::MissingForgeHost);
386 assert!(err.to_string().contains("github.com/acme/widgets"), "{err}");
387 assert_eq!(kind("acme"), ResourceErrorKind::MissingForgeHost);
388 assert_eq!(kind("/acme/widgets"), ResourceErrorKind::MissingForgeHost);
389 }
390
391 #[test]
392 fn urls_are_refused_with_the_bare_form_suggested() {
393 let err =
394 normalize_resource_with_depth("https://github.com/Acme/widgets.git", 2).unwrap_err();
395 assert_eq!(err.kind(), &ResourceErrorKind::HasScheme);
396 assert!(
397 err.to_string().contains("`github.com/acme/widgets`"),
398 "{err}"
399 );
400 }
401
402 #[test]
403 fn empty_and_dot_segments_are_refused() {
404 assert_eq!(kind("github.com//widgets"), ResourceErrorKind::EmptySegment);
405 assert_eq!(kind("github.com/acme/"), ResourceErrorKind::EmptySegment);
406 assert_eq!(kind("/github.com/acme"), ResourceErrorKind::EmptySegment);
407 assert_eq!(kind("github.com/acme/.."), ResourceErrorKind::DotSegment);
408 assert_eq!(kind("github.com/./acme"), ResourceErrorKind::DotSegment);
409 let err = normalize_resource_with_depth("github.com//acme//x", 2).unwrap_err();
410 assert!(err.to_string().contains("`github.com/acme/x`"), "{err}");
411 }
412
413 #[test]
414 fn a_host_alone_asks_for_an_owner() {
415 assert_eq!(kind("github.com"), ResourceErrorKind::MissingOwner);
416 assert_eq!(kind("github.com/"), ResourceErrorKind::MissingOwner);
417 }
418
419 #[test]
420 fn hosts_are_checked() {
421 assert_eq!(kind("github.com:443/acme"), ResourceErrorKind::HasPort);
422 assert_eq!(kind("-bad.example/acme"), ResourceErrorKind::InvalidHost);
423 assert_eq!(kind("bad..example/acme"), ResourceErrorKind::InvalidHost);
424 assert_eq!(kind("git_hub.com/acme"), ResourceErrorKind::InvalidHost);
425 let err = normalize_resource_with_depth("GitHub.com:8443/acme/x", 2).unwrap_err();
426 assert!(err.to_string().contains("`github.com/acme/x`"), "{err}");
427 }
428
429 #[test]
430 fn odd_characters_are_refused() {
431 assert_eq!(kind("github.com/ac me"), ResourceErrorKind::Whitespace);
432 assert_eq!(kind(" github.com/acme"), ResourceErrorKind::Whitespace);
433 assert_eq!(kind("github.com/acmé"), ResourceErrorKind::NonAscii);
434 assert_eq!(
435 kind("github.com/acme/w%2e"),
436 ResourceErrorKind::InvalidCharacter('%')
437 );
438 assert_eq!(
439 kind("github.com/acme@x"),
440 ResourceErrorKind::InvalidCharacter('@')
441 );
442 assert_eq!(kind(""), ResourceErrorKind::Empty);
443 assert_eq!(
444 kind(&format!("github.com/{}", "a".repeat(600))),
445 ResourceErrorKind::TooLong
446 );
447 }
448
449 #[test]
450 fn depth_is_the_callers_bound() {
451 assert_eq!(
452 kind("gitlab.com/group/sub/project"),
453 ResourceErrorKind::TooManySegments { max: 2 }
454 );
455 assert_eq!(
456 normalize_resource("gitlab.com/Group/Sub/Project").as_deref(),
457 Ok("gitlab.com/group/sub/project")
458 );
459 }
460
461 #[test]
462 fn containment_is_by_whole_segment_and_never_crosses_forges() {
463 assert!(resource_contains("github.com/acme", "github.com/acme"));
464 assert!(resource_contains(
465 "github.com/acme",
466 "github.com/acme/widgets"
467 ));
468 assert!(!resource_contains(
469 "github.com/acme",
470 "github.com/acme-labs/x"
471 ));
472 assert!(!resource_contains(
473 "github.com/acme",
474 "codeberg.org/acme/widgets"
475 ));
476 assert!(!resource_contains(
477 "github.com/acme/widgets",
478 "github.com/acme"
479 ));
480 }
481
482 #[test]
483 fn long_hostile_inputs_are_truncated_in_errors() {
484 let err = normalize_resource(&"é".repeat(400)).unwrap_err();
485 assert!(err.input().len() < 100);
486 }
487}