OXYGENHUB
# a0a1601eb5a5 oxygenhub / crates / hub / src / repos / names.rs
2.8 KiB · Rust · last changed Insights: OxygenFlow's standard charts, for the site, an owner and a repository 18 days ago source blame history raw
1
//! Owner and repository names (hub spec §3.2) and the top-level path
2
//! segments no owner may take.
3
4
use std::fmt;
5
6
/// `owner/name`, both validated.
7
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
8
pub struct RepoName {
9
    pub owner: String,
10
    pub name: String,
11
}
12
13
/// Top-level path segments reserved for the hub itself.
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
    // Live pages, CSP reports and the security pages (oxygenhub#204, #222, #224, #230).
37
    "live",
38
    "csp-report",
39
    "security.txt",
40
    "security-contact",
41
    // The transparency log, the metrics and Insights (oxygenhub#238, #212, #257).
42
    "log",
43
    "metrics",
44
    "insights",
45
];
46
47
/// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, not `.`/`..`, not ending in `.git`.
48
pub fn valid_segment(s: &str) -> bool {
49
    let mut chars = s.chars();
50
    let Some(first) = chars.next() else { return false };
51
    if !first.is_ascii_alphanumeric() || s.len() > 64 {
52
        return false;
53
    }
54
    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) {
55
        return false;
56
    }
57
    s != "." && s != ".." && !s.ends_with(".git")
58
}
59
60
pub fn valid_owner(s: &str) -> bool {
61
    valid_segment(s) && !RESERVED.contains(&s)
62
}
63
64
impl RepoName {
65
    pub fn new(owner: &str, name: &str) -> Option<RepoName> {
66
        if valid_owner(owner) && valid_segment(name) {
67
            Some(RepoName { owner: owner.into(), name: name.into() })
68
        } else {
69
            None
70
        }
71
    }
72
73
    /// `owner/name`.
74
    pub fn parse(slug: &str) -> Option<RepoName> {
75
        let (owner, name) = slug.split_once('/')?;
76
        RepoName::new(owner, name)
77
    }
78
79
    pub fn url(&self) -> String {
80
        format!("/{}/{}", self.owner, self.name)
81
    }
82
}
83
84
impl fmt::Display for RepoName {
85
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86
        write!(f, "{}/{}", self.owner, self.name)
87
    }
88
}
89
90
#[cfg(test)]
91
mod tests {
92
    use super::*;
93
94
    #[test]
95
    fn names() {
96
        assert!(RepoName::parse("andy/oxygen").is_some());
97
        assert!(RepoName::parse("andy/oxy.gen-2_x").is_some());
98
        assert!(RepoName::parse("api/x").is_none(), "reserved owner");
99
        assert!(RepoName::parse("andy/x.git").is_none(), "mirror URL");
100
        assert!(RepoName::parse("andy/..").is_none());
101
        assert!(RepoName::parse(".andy/x").is_none());
102
        assert!(RepoName::parse("andy/a b").is_none());
103
        assert!(RepoName::parse("andy").is_none());
104
        assert!(RepoName::parse(&format!("andy/{}", "a".repeat(65))).is_none());
105
        assert_eq!(RepoName::parse("andy/oxygen").unwrap().url(), "/andy/oxygen");
106
    }
107
}