//! Owner and repository names (hub spec ยง3.2) and the top-level path //! segments no owner may take. use std::fmt; /// `owner/name`, both validated. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct RepoName { pub owner: String, pub name: String, } /// Top-level path segments reserved for the hub itself. pub const RESERVED: &[&str] = &[ "api", "static", "login", "logout", "register", "settings", "explore", "search", "notifications", "new", "orgs", "admin", "style.css", "favicon.ico", "favicon.svg", "health", "avatar", "help", "projects", "assets", ".well-known", // Live pages, CSP reports and the security pages (oxygenhub#204, #222, #224, #230). "live", "csp-report", "security.txt", "security-contact", // The transparency log, the metrics and Insights (oxygenhub#238, #212, #257). "log", "metrics", "insights", // Drops: `/drops` is the viewer's own (oxygenhub#279). "drops", ]; /// The repository name no repository may take: `/{owner}/drops` is the /// owner's Drops page, and `/{owner}/drops/{id}` a drop (oxygenhub#279). pub const DROPS: &str = "drops"; /// A drop's id: lowercase hex, 16 to 40 characters (they are made with /// 24, 96 random bits, so a secret drop's address cannot be guessed). pub fn valid_drop_id(s: &str) -> bool { (16..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } /// A repository name: a segment, not `drops`; or a drop's, `drops/ID`, /// which no repository name can be, since it holds a slash. pub fn valid_repo_name(s: &str) -> bool { match s.split_once('/') { Some((DROPS, id)) => valid_drop_id(id), Some(_) => false, None => valid_segment(s) && !s.eq_ignore_ascii_case(DROPS), } } /// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, not `.`/`..`, not ending in `.git`. pub fn valid_segment(s: &str) -> bool { let mut chars = s.chars(); let Some(first) = chars.next() else { return false }; if !first.is_ascii_alphanumeric() || s.len() > 64 { return false; } if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { return false; } s != "." && s != ".." && !s.ends_with(".git") } pub fn valid_owner(s: &str) -> bool { valid_segment(s) && !RESERVED.contains(&s) } impl RepoName { pub fn new(owner: &str, name: &str) -> Option { if valid_owner(owner) && valid_repo_name(name) { Some(RepoName { owner: owner.into(), name: name.into() }) } else { None } } /// `owner/name`. pub fn parse(slug: &str) -> Option { let (owner, name) = slug.split_once('/')?; RepoName::new(owner, name) } pub fn url(&self) -> String { format!("/{}/{}", self.owner, self.name) } /// A drop's repository, `owner/drops/ID`. pub fn drop(owner: &str, id: &str) -> Option { RepoName::new(owner, &format!("{DROPS}/{id}")) } /// The drop's id, when this is a drop's repository. pub fn drop_id(&self) -> Option<&str> { self.name.strip_prefix(DROPS)?.strip_prefix('/') } } impl fmt::Display for RepoName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}/{}", self.owner, self.name) } } #[cfg(test)] mod tests { use super::*; #[test] fn names() { assert!(RepoName::parse("andy/oxygen").is_some()); assert!(RepoName::parse("andy/oxy.gen-2_x").is_some()); assert!(RepoName::parse("api/x").is_none(), "reserved owner"); assert!(RepoName::parse("andy/x.git").is_none(), "mirror URL"); assert!(RepoName::parse("andy/..").is_none()); assert!(RepoName::parse(".andy/x").is_none()); assert!(RepoName::parse("andy/a b").is_none()); assert!(RepoName::parse("andy").is_none()); assert!(RepoName::parse(&format!("andy/{}", "a".repeat(65))).is_none()); assert_eq!(RepoName::parse("andy/oxygen").unwrap().url(), "/andy/oxygen"); // `drops` is the Drops page; a drop is `drops/ID` (#279). assert!(RepoName::parse("andy/drops").is_none() && RepoName::parse("andy/Drops").is_none()); assert!(RepoName::parse("drops/x").is_none(), "reserved owner"); let d = RepoName::parse("andy/drops/0123456789abcdef01234567").unwrap(); assert_eq!(d.drop_id(), Some("0123456789abcdef01234567")); assert_eq!(d.url(), "/andy/drops/0123456789abcdef01234567"); assert_eq!(RepoName::drop("andy", "0123456789abcdef01234567"), Some(d)); assert!(RepoName::parse("andy/drops/short").is_none()); assert!(RepoName::parse("andy/drops/0123456789ABCDEF01234567").is_none()); assert!(RepoName::parse("andy/x/0123456789abcdef01234567").is_none()); assert_eq!(RepoName::parse("andy/oxygen").unwrap().drop_id(), None); } }