| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use std::fmt; |
| 5 | |
|
| 6 | |
|
| 7 | | #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] |
| 8 | | pub struct RepoName { |
| 9 | | pub owner: String, |
| 10 | | pub name: String, |
| 11 | | } |
| 12 | |
|
| 13 | |
|
| 14 | | pub const RESERVED: &[&str] = &[ |
| 15 | | "api", |
| 16 | | "static", |
| 17 | | "login", |
| 18 | | "logout", |
| 19 | | "register", |
| 20 | | "settings", |
| 21 | | "explore", |
| 22 | | "search", |
| 23 | | "notifications", |
| 24 | | "new", |
| 25 | | "orgs", |
| 26 | | "admin", |
| 27 | | "style.css", |
| 28 | | "favicon.ico", |
| 29 | | "favicon.svg", |
| 30 | | "health", |
| 31 | | "avatar", |
| 32 | | "help", |
| 33 | | "projects", |
| 34 | | "assets", |
| 35 | | ".well-known", |
| 36 | | |
| 37 | | "live", |
| 38 | | "csp-report", |
| 39 | | "security.txt", |
| 40 | | "security-contact", |
| 41 | | |
| 42 | | "log", |
| 43 | | "metrics", |
| 44 | | "insights", |
| 45 | | |
| 46 | | "drops", |
| 47 | | ]; |
| 48 | |
|
| 49 | |
|
| 50 | |
|
| 51 | | pub const DROPS: &str = "drops"; |
| 52 | |
|
| 53 | |
|
| 54 | |
|
| 55 | 332× | pub fn valid_drop_id(s: &str) -> bool { |
| 56 | 7931× | (16..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) |
| 57 | 332× | } |
| 58 | |
|
| 59 | |
|
| 60 | |
|
| 61 | 11428× | pub fn valid_repo_name(s: &str) -> bool { |
| 62 | 11428× | match s.split_once('/') { |
| 63 | 321× | Some((DROPS, id)) => valid_drop_id(id), |
| 64 | 1× | Some(_) => false, |
| 65 | 11107× | None => valid_segment(s) && !s.eq_ignore_ascii_case(DROPS), |
| 66 | | } |
| 67 | 11428× | } |
| 68 | |
|
| 69 | |
|
| 70 | 22714× | pub fn valid_segment(s: &str) -> bool { |
| 71 | 22714× | let mut chars = s.chars(); |
| 72 | 22714× | let Some(first) = chars.next() else { return false }; |
| 73 | 22714× | if !first.is_ascii_alphanumeric() || s.len() > 64 { |
| 74 | 3× | return false; |
| 75 | 22711× | } |
| 76 | 69718× | if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { |
| 77 | 1× | return false; |
| 78 | 22710× | } |
| 79 | 22710× | s != "." && s != ".." && !s.ends_with(".git") |
| 80 | 22714× | } |
| 81 | |
|
| 82 | 11592× | pub fn valid_owner(s: &str) -> bool { |
| 83 | 11592× | valid_segment(s) && !RESERVED.contains(&s) |
| 84 | 11592× | } |
| 85 | |
|
| 86 | | impl RepoName { |
| 87 | 11431× | pub fn new(owner: &str, name: &str) -> Option<RepoName> { |
| 88 | 11431× | if valid_owner(owner) && valid_repo_name(name) { |
| 89 | 11418× | Some(RepoName { owner: owner.into(), name: name.into() }) |
| 90 | | } else { |
| 91 | 13× | None |
| 92 | | } |
| 93 | 11431× | } |
| 94 | |
|
| 95 | | |
| 96 | 4159× | pub fn parse(slug: &str) -> Option<RepoName> { |
| 97 | 4159× | let (owner, name) = slug.split_once('/')?; |
| 98 | 4158× | RepoName::new(owner, name) |
| 99 | 4159× | } |
| 100 | |
|
| 101 | 19× | pub fn url(&self) -> String { |
| 102 | 19× | format!("/{}/{}", self.owner, self.name) |
| 103 | 19× | } |
| 104 | |
|
| 105 | | |
| 106 | 87× | pub fn drop(owner: &str, id: &str) -> Option<RepoName> { |
| 107 | 87× | RepoName::new(owner, &format!("{DROPS}/{id}")) |
| 108 | 87× | } |
| 109 | |
|
| 110 | | |
| 111 | 8× | pub fn drop_id(&self) -> Option<&str> { |
| 112 | 8× | self.name.strip_prefix(DROPS)?.strip_prefix('/') |
| 113 | 8× | } |
| 114 | | } |
| 115 | |
|
| 116 | | impl fmt::Display for RepoName { |
| 117 | 5417× | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 118 | 5417× | write!(f, "{}/{}", self.owner, self.name) |
| 119 | 5417× | } |
| 120 | | } |
| 121 | |
|
| 122 | | #[cfg(test)] |
| 123 | | mod tests { |
| 124 | | use super::*; |
| 125 | |
|
| 126 | | #[test] |
| 127 | 1× | fn names() { |
| 128 | 1× | assert!(RepoName::parse("andy/oxygen").is_some()); |
| 129 | 1× | assert!(RepoName::parse("andy/oxy.gen-2_x").is_some()); |
| 130 | 1× | assert!(RepoName::parse("api/x").is_none(), "reserved owner"); |
| 131 | 1× | assert!(RepoName::parse("andy/x.git").is_none(), "mirror URL"); |
| 132 | 1× | assert!(RepoName::parse("andy/..").is_none()); |
| 133 | 1× | assert!(RepoName::parse(".andy/x").is_none()); |
| 134 | 1× | assert!(RepoName::parse("andy/a b").is_none()); |
| 135 | 1× | assert!(RepoName::parse("andy").is_none()); |
| 136 | 1× | assert!(RepoName::parse(&format!("andy/{}", "a".repeat(65))).is_none()); |
| 137 | 1× | assert_eq!(RepoName::parse("andy/oxygen").unwrap().url(), "/andy/oxygen"); |
| 138 | | |
| 139 | 1× | assert!(RepoName::parse("andy/drops").is_none() && RepoName::parse("andy/Drops").is_none()); |
| 140 | 1× | assert!(RepoName::parse("drops/x").is_none(), "reserved owner"); |
| 141 | 1× | let d = RepoName::parse("andy/drops/0123456789abcdef01234567").unwrap(); |
| 142 | 1× | assert_eq!(d.drop_id(), Some("0123456789abcdef01234567")); |
| 143 | 1× | assert_eq!(d.url(), "/andy/drops/0123456789abcdef01234567"); |
| 144 | 1× | assert_eq!(RepoName::drop("andy", "0123456789abcdef01234567"), Some(d)); |
| 145 | 1× | assert!(RepoName::parse("andy/drops/short").is_none()); |
| 146 | 1× | assert!(RepoName::parse("andy/drops/0123456789ABCDEF01234567").is_none()); |
| 147 | 1× | assert!(RepoName::parse("andy/x/0123456789abcdef01234567").is_none()); |
| 148 | 1× | assert_eq!(RepoName::parse("andy/oxygen").unwrap().drop_id(), None); |
| 149 | 1× | } |
| 150 | | } |