git_ref_format_core/
check.rs

1// Copyright © 2021 The Radicle Link Contributors
2//
3// This file is part of radicle-link, distributed under the GPLv3 with Radicle
4// Linking Exception. For full terms see the included LICENSE file.
5
6use thiserror::Error;
7
8pub struct Options {
9    /// If `false`, the refname must contain at least one `/`.
10    pub allow_onelevel: bool,
11    /// If `true`, the refname may contain exactly one `*` character.
12    pub allow_pattern: bool,
13}
14
15#[derive(Debug, PartialEq, Eq, Error)]
16#[non_exhaustive]
17pub enum Error {
18    #[error("empty input")]
19    Empty,
20    #[error("lone '@' character")]
21    LoneAt,
22    #[error("consecutive or trailing slash")]
23    Slash,
24    #[error("ends with '.lock'")]
25    DotLock,
26    #[error("consecutive dots ('..')")]
27    DotDot,
28    #[error("at-open-brace ('@{{')")]
29    AtOpenBrace,
30    #[error("invalid character {0:?}")]
31    InvalidChar(char),
32    #[error("component starts with '.'")]
33    StartsDot,
34    #[error("component ends with '.'")]
35    EndsDot,
36    #[error("control character")]
37    Control,
38    #[error("whitespace")]
39    Space,
40    #[error("must contain at most one '*'")]
41    Pattern,
42    #[error("must contain at least one '/'")]
43    OneLevel,
44}
45
46/// Validate that a string slice is a valid refname.
47pub fn ref_format(opts: Options, s: &str) -> Result<(), Error> {
48    match s {
49        "" => Err(Error::Empty),
50        "@" => Err(Error::LoneAt),
51        "." => Err(Error::StartsDot),
52        _ => {
53            let mut globs = 0usize;
54            let mut parts = 0usize;
55
56            for x in s.split('/') {
57                if x.is_empty() {
58                    return Err(Error::Slash);
59                }
60
61                parts += 1;
62
63                if x.ends_with(".lock") {
64                    return Err(Error::DotLock);
65                }
66
67                let last_char = x.chars().count() - 1;
68                for (i, y) in x.chars().zip(x.chars().cycle().skip(1)).enumerate() {
69                    match y {
70                        ('.', '.') => return Err(Error::DotDot),
71                        ('@', '{') => return Err(Error::AtOpenBrace),
72
73                        ('\0', _) => return Err(Error::InvalidChar('\0')),
74                        ('\\', _) => return Err(Error::InvalidChar('\\')),
75                        ('~', _) => return Err(Error::InvalidChar('~')),
76                        ('^', _) => return Err(Error::InvalidChar('^')),
77                        (':', _) => return Err(Error::InvalidChar(':')),
78                        ('?', _) => return Err(Error::InvalidChar('?')),
79                        ('[', _) => return Err(Error::InvalidChar('[')),
80
81                        ('*', _) => globs += 1,
82
83                        ('.', _) if i == 0 => return Err(Error::StartsDot),
84                        ('.', _) if i == last_char => return Err(Error::EndsDot),
85
86                        (' ', _) => return Err(Error::Space),
87
88                        (z, _) if z.is_ascii_control() => return Err(Error::Control),
89
90                        _ => continue,
91                    }
92                }
93            }
94
95            if parts < 2 && !opts.allow_onelevel {
96                Err(Error::OneLevel)
97            } else if globs > 1 && opts.allow_pattern {
98                Err(Error::Pattern)
99            } else if globs > 0 && !opts.allow_pattern {
100                Err(Error::InvalidChar('*'))
101            } else {
102                Ok(())
103            }
104        }
105    }
106}