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