OXYGENHUB

Coverage / crates/hub/src/repos/names.rs 100.0%

68 of 68 lines run, 24 of 25 functions · run #485 on main · run missed

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
    // Drops: `/drops` is the viewer's own (oxygenhub#279).
46
    "drops",
47
];
48
49
/// The repository name no repository may take: `/{owner}/drops` is the
50
/// owner's Drops page, and `/{owner}/drops/{id}` a drop (oxygenhub#279).
51
pub const DROPS: &str = "drops";
52
53
/// A drop's id: lowercase hex, 16 to 40 characters (they are made with
54
/// 24, 96 random bits, so a secret drop's address cannot be guessed).
55331×
pub fn valid_drop_id(s: &str) -> bool {
567907×
    (16..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
57331×
}
58
59
/// A repository name: a segment, not `drops`; or a drop's, `drops/ID`,
60
/// which no repository name can be, since it holds a slash.
6110823×
pub fn valid_repo_name(s: &str) -> bool {
6210823×
    match s.split_once('/') {
63320×
        Some((DROPS, id)) => valid_drop_id(id),
641×
        Some(_) => false,
6510503×
        None => valid_segment(s) && !s.eq_ignore_ascii_case(DROPS),
66
    }
6710823×
}
68
69
/// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, not `.`/`..`, not ending in `.git`.
7021495×
pub fn valid_segment(s: &str) -> bool {
7121495×
    let mut chars = s.chars();
7221495×
    let Some(first) = chars.next() else { return false };
7321495×
    if !first.is_ascii_alphanumeric() || s.len() > 64 {
743×
        return false;
7521492×
    }
7665650×
    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) {
771×
        return false;
7821491×
    }
7921491×
    s != "." && s != ".." && !s.ends_with(".git")
8021495×
}
81
8210981×
pub fn valid_owner(s: &str) -> bool {
8310981×
    valid_segment(s) && !RESERVED.contains(&s)
8410981×
}
85
86
impl RepoName {
8710826×
    pub fn new(owner: &str, name: &str) -> Option<RepoName> {
8810826×
        if valid_owner(owner) && valid_repo_name(name) {
8910813×
            Some(RepoName { owner: owner.into(), name: name.into() })
90
        } else {
9113×
            None
92
        }
9310826×
    }
94
95
    /// `owner/name`.
963800×
    pub fn parse(slug: &str) -> Option<RepoName> {
973800×
        let (owner, name) = slug.split_once('/')?;
983799×
        RepoName::new(owner, name)
993800×
    }
100
10119×
    pub fn url(&self) -> String {
10219×
        format!("/{}/{}", self.owner, self.name)
10319×
    }
104
105
    /// A drop's repository, `owner/drops/ID`.
10687×
    pub fn drop(owner: &str, id: &str) -> Option<RepoName> {
10787×
        RepoName::new(owner, &format!("{DROPS}/{id}"))
10887×
    }
109
110
    /// The drop's id, when this is a drop's repository.
1118×
    pub fn drop_id(&self) -> Option<&str> {
1128×
        self.name.strip_prefix(DROPS)?.strip_prefix('/')
1138×
    }
114
}
115
116
impl fmt::Display for RepoName {
1175226×
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1185226×
        write!(f, "{}/{}", self.owner, self.name)
1195226×
    }
120
}
121
122
#[cfg(test)]
123
mod tests {
124
    use super::*;
125
126
    #[test]
1271×
    fn names() {
1281×
        assert!(RepoName::parse("andy/oxygen").is_some());
1291×
        assert!(RepoName::parse("andy/oxy.gen-2_x").is_some());
1301×
        assert!(RepoName::parse("api/x").is_none(), "reserved owner");
1311×
        assert!(RepoName::parse("andy/x.git").is_none(), "mirror URL");
1321×
        assert!(RepoName::parse("andy/..").is_none());
1331×
        assert!(RepoName::parse(".andy/x").is_none());
1341×
        assert!(RepoName::parse("andy/a b").is_none());
1351×
        assert!(RepoName::parse("andy").is_none());
1361×
        assert!(RepoName::parse(&format!("andy/{}", "a".repeat(65))).is_none());
1371×
        assert_eq!(RepoName::parse("andy/oxygen").unwrap().url(), "/andy/oxygen");
138
        // `drops` is the Drops page; a drop is `drops/ID` (#279).
1391×
        assert!(RepoName::parse("andy/drops").is_none() && RepoName::parse("andy/Drops").is_none());
1401×
        assert!(RepoName::parse("drops/x").is_none(), "reserved owner");
1411×
        let d = RepoName::parse("andy/drops/0123456789abcdef01234567").unwrap();
1421×
        assert_eq!(d.drop_id(), Some("0123456789abcdef01234567"));
1431×
        assert_eq!(d.url(), "/andy/drops/0123456789abcdef01234567");
1441×
        assert_eq!(RepoName::drop("andy", "0123456789abcdef01234567"), Some(d));
1451×
        assert!(RepoName::parse("andy/drops/short").is_none());
1461×
        assert!(RepoName::parse("andy/drops/0123456789ABCDEF01234567").is_none());
1471×
        assert!(RepoName::parse("andy/x/0123456789abcdef01234567").is_none());
1481×
        assert_eq!(RepoName::parse("andy/oxygen").unwrap().drop_id(), None);
1491×
    }
150
}