Skip to main content

mkit_git_bridge/
refname.rs

1//! git-side ref-name legality on top of the mkit grammar
2//! (SPEC-GIT-BRIDGE §12.1).
3//!
4//! mkit ref names (SPEC-REFS §3) are already restricted to
5//! `[0-9A-Za-z._-]` segments, no empty segments, no `.lock` suffix, and
6//! no segment starting with `.` (which also covers the exact `.`/`..`
7//! segments). That leaves two residual git-illegal-but-mkit-legal
8//! shapes, checked here: a segment ending in `.`, and a segment
9//! containing `..` anywhere. The leading-dot check below is kept as
10//! defense in depth for any future caller of `check_git_legal` on a
11//! string not already run through `validate_ref_name`. No escaping:
12//! illegal names are refused per-ref.
13
14use crate::error::Refusal;
15
16/// Check a full mkit ref name (e.g. `refs/heads/main`) for git-side
17/// legality. The input is assumed to already satisfy the mkit
18/// grammar; this only adds git's extra rules.
19pub fn check_git_legal(name: &str) -> Result<(), Refusal> {
20    for segment in name.split('/') {
21        if segment.starts_with('.') {
22            return Err(refusal(name, "segment begins with '.'"));
23        }
24        if segment.ends_with('.') {
25            return Err(refusal(name, "segment ends with '.'"));
26        }
27        if segment.contains("..") {
28            return Err(refusal(name, "segment contains '..'"));
29        }
30    }
31    Ok(())
32}
33
34/// Tag-object names ride in the git `tag` header (§7.1): they must
35/// satisfy the mkit ref grammar's byte set so the header line is
36/// well-formed, plus the git rules above.
37// `.lock` is a literal, case-sensitive ref-suffix rule (SPEC-REFS §3),
38// not a file-extension comparison.
39#[allow(clippy::case_sensitive_file_extension_comparisons)]
40pub fn check_tag_name(name: &[u8]) -> Result<(), &'static str> {
41    if name.is_empty() {
42        return Err("empty");
43    }
44    // SPEC-OBJECTS caps tag names (TAG_NAME_MAX_LEN); over-length
45    // names must refuse HERE (per-ref) — reaching serialization would
46    // turn one hostile tag into a whole-run abort.
47    if name.len() > usize::from(mkit_core::object::TAG_NAME_MAX_LEN) {
48        return Err("over the tag-name length cap");
49    }
50    let Ok(s) = std::str::from_utf8(name) else {
51        return Err("not UTF-8");
52    };
53    // SPEC-OBJECTS §6a forbids '/' in tag-object names outright; the
54    // remaining charset is the mkit ref-segment grammar.
55    for b in s.bytes() {
56        if !(b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-') {
57            return Err("byte outside the mkit ref-segment grammar");
58        }
59    }
60    if s == "." || s == ".." {
61        return Err("'.' or '..' name");
62    }
63    if s == "HEAD" {
64        return Err("'HEAD' is reserved");
65    }
66    if s.ends_with(".lock") {
67        return Err("'.lock' suffix");
68    }
69    check_git_legal(s).map_err(|_| "git-illegal dot placement")
70}
71
72fn refusal(name: &str, reason: &'static str) -> Refusal {
73    Refusal::RefName {
74        name: name.to_owned(),
75        reason,
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn plain_names_pass() {
85        for n in ["refs/heads/main", "refs/tags/v1.0.0", "refs/heads/a-b_c.d"] {
86            assert!(check_git_legal(n).is_ok(), "{n}");
87        }
88    }
89
90    #[test]
91    fn git_illegal_dot_shapes_refused() {
92        for n in [
93            "refs/heads/.hidden",
94            "refs/heads/trailing.",
95            "refs/heads/a..b",
96        ] {
97            assert!(check_git_legal(n).is_err(), "{n}");
98        }
99    }
100
101    #[test]
102    fn tag_names_checked() {
103        assert!(check_tag_name(b"v1.0.0").is_ok());
104        assert!(check_tag_name(b"with space").is_err());
105        assert!(check_tag_name(b".dot").is_err());
106        assert!(
107            check_tag_name(b"no/slash").is_err(),
108            "SPEC-OBJECTS 6a forbids '/'"
109        );
110        assert!(check_tag_name(b"HEAD").is_err());
111        assert!(check_tag_name(b"v1.lock").is_err());
112        assert!(check_tag_name("naïve".as_bytes()).is_err());
113    }
114}