firefly_meta/
validators.rs

1use core::fmt::Display;
2
3pub enum ValidationError {
4    TrailingMinus,
5    DoubleMinus,
6    Empty,
7    InvalidChar(u8),
8    InvalidFirstChar(u8),
9    TooLong,
10    TrailingSpace,
11    TrailingDot,
12}
13
14impl ValidationError {
15    #[must_use]
16    pub const fn as_str(&self) -> &str {
17        match self {
18            Self::TrailingMinus => "must not start or end with minus",
19            Self::DoubleMinus => "must not contain '--'",
20            Self::Empty => "must not be empty",
21            Self::InvalidChar(c) => match char::from_u32(*c as u32) {
22                Some(_) => "contains invalid character",
23                None => "must contain only valid ASCII characters",
24            },
25            Self::InvalidFirstChar(c) => match char::from_u32(*c as u32) {
26                Some(_) => "starts with invalid character",
27                None => "must start with an ASCII character",
28            },
29            Self::TooLong => "too long",
30            Self::TrailingSpace => "must not start or end with space",
31            Self::TrailingDot => "must not start or end with dot",
32        }
33    }
34}
35
36impl Display for ValidationError {
37    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
38        match self {
39            Self::TrailingMinus => write!(f, "must not start or end with minus"),
40            Self::DoubleMinus => write!(f, "must not contain '--'"),
41            Self::Empty => write!(f, "must not be empty"),
42            Self::InvalidChar(c) => match char::from_u32(u32::from(*c)) {
43                Some(c) => write!(f, "must not contain {c}"),
44                None => write!(f, "must contain only valid ASCII characters"),
45            },
46            Self::InvalidFirstChar(c) => match char::from_u32(u32::from(*c)) {
47                Some(c) => write!(f, "must not start with {c}"),
48                None => write!(f, "must start with an ASCII character"),
49            },
50            Self::TooLong => write!(f, "too long"),
51            Self::TrailingSpace => write!(f, "must not start or end with space"),
52            Self::TrailingDot => write!(f, "must not start or end with dot"),
53        }
54    }
55}
56
57/// Validate the author or the app ID.
58///
59/// The ID should have at least one character and may contain only
60/// ASCII lowercase letters, ASCII digits, and hyphen.
61///
62/// Without ID validation, an app may use a malformed ID (like "../../../")
63/// to gain access to arbitrary files of other apps, including secrets.
64///
65/// # Errors
66///
67/// Returns [`ValidationError`] if any of the validation checks fails.
68pub fn validate_id(s: &str) -> Result<(), ValidationError> {
69    if s.len() > 16 {
70        return Err(ValidationError::TooLong);
71    }
72    if s.starts_with('-') || s.ends_with('-') {
73        return Err(ValidationError::TrailingMinus);
74    }
75    if s.contains("--") {
76        return Err(ValidationError::DoubleMinus);
77    }
78    if s.is_empty() {
79        return Err(ValidationError::Empty);
80    }
81    for c in s.bytes() {
82        if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != b'-' {
83            return Err(ValidationError::InvalidChar(c));
84        }
85    }
86    Ok(())
87}
88
89/// Validate a name (app name, author name).
90///
91/// We currently force all text to be printable ASCII.
92/// Keep in mind that the validation DOES NOT ensure
93/// that the text is a safe file name.
94/// You need to sanitize it first to use in a file path.
95///
96/// # Errors
97///
98/// Returns [`ValidationError`] if any of the validation checks fails.
99pub fn validate_name(s: &str) -> Result<(), ValidationError> {
100    if s.len() > 40 {
101        return Err(ValidationError::TooLong);
102    }
103    if s.ends_with(' ') {
104        return Err(ValidationError::TrailingSpace);
105    }
106    let mut b = s.bytes();
107    match b.next() {
108        // Must start with a letter.
109        Some(c) => {
110            if !c.is_ascii_alphabetic() {
111                return Err(ValidationError::InvalidFirstChar(c));
112            }
113        }
114        // Must not be empty.
115        None => return Err(ValidationError::Empty),
116    }
117    for c in b {
118        if c.is_ascii_alphanumeric() {
119            continue;
120        }
121        if !c.is_ascii_punctuation() && c != b' ' {
122            return Err(ValidationError::InvalidChar(c));
123        }
124    }
125    Ok(())
126}
127
128/// Validate a path component (file or directory name).
129///
130/// # Errors
131///
132/// Returns [`ValidationError`] if any of the validation checks fails.
133pub fn validate_path_part(s: &str) -> Result<(), ValidationError> {
134    if s.starts_with('.') {
135        return Err(ValidationError::TrailingDot);
136    }
137    if s.is_empty() {
138        return Err(ValidationError::Empty);
139    }
140    for c in s.bytes() {
141        if c.is_ascii_alphanumeric() {
142            continue;
143        }
144        if c != b'.' && c != b'_' && c != b'-' {
145            return Err(ValidationError::InvalidChar(c));
146        }
147    }
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_valid_id() {
157        assert!(validate_id("app").is_ok());
158        assert!(validate_id("some-app").is_ok());
159        assert!(validate_id("some-app-13").is_ok());
160        assert!(validate_id("13app").is_ok());
161        assert!(validate_id("a").is_ok());
162        assert!(validate_id("a-bit-long-name").is_ok());
163    }
164
165    #[test]
166    fn test_invalid_id() {
167        assert!(validate_id("app.name").is_err());
168        assert!(validate_id("app--name").is_err());
169        assert!(validate_id("-appname").is_err());
170        assert!(validate_id("-app-name").is_err());
171        assert!(validate_id("-appname").is_err());
172        assert!(validate_id("app-name-").is_err());
173        assert!(validate_id("appname-").is_err());
174        assert!(validate_id("-appname-").is_err());
175        assert!(validate_id("app name").is_err());
176        assert!(validate_id("appname ").is_err());
177        assert!(validate_id(" appname").is_err());
178        assert!(validate_id("App").is_err());
179        assert!(validate_id("AppName").is_err());
180        assert!(validate_id("APPNAME").is_err());
181        assert!(validate_id("").is_err());
182        assert!(validate_id(" ").is_err());
183        assert!(validate_id("-").is_err());
184        assert!(validate_id("--").is_err());
185        assert!(validate_id("?hello").is_err());
186        assert!(validate_id("a-very-long-app-name").is_err());
187    }
188
189    #[test]
190    fn test_valid_name() {
191        assert!(validate_name("app").is_ok());
192        assert!(validate_name("a").is_ok());
193        assert!(validate_name("some-app").is_ok());
194        assert!(validate_name("App").is_ok());
195        assert!(validate_name("Some app").is_ok());
196        assert!(validate_name("Some App").is_ok());
197        assert!(validate_name("SOME APP").is_ok());
198        assert!(validate_name("Hello").is_ok());
199        assert!(validate_name("Hello?").is_ok());
200        assert!(validate_name("Yes? Yes!").is_ok());
201    }
202
203    #[test]
204    fn test_invalid_name() {
205        assert!(validate_name(" ").is_err());
206        assert!(validate_name("  ").is_err());
207        assert!(validate_name("").is_err());
208        assert!(validate_name(" abc").is_err());
209        assert!(validate_name("abc ").is_err());
210        assert!(validate_name("ab\tcd").is_err());
211        assert!(validate_name("ั‚ะตัั‚").is_err());
212        assert!(validate_name("?hello").is_err());
213    }
214
215    #[test]
216    fn test_valid_path_part() {
217        assert!(validate_path_part("app").is_ok());
218        assert!(validate_path_part("a").is_ok());
219        assert!(validate_path_part("some-app").is_ok());
220        assert!(validate_path_part("App").is_ok());
221        assert!(validate_path_part("file.wasm").is_ok());
222        assert!(validate_path_part("file_name.wasm").is_ok());
223        assert!(validate_path_part("FileName.wasm").is_ok());
224    }
225
226    #[test]
227    fn test_invalid_path_part() {
228        assert!(validate_path_part(".gitignore").is_err());
229        assert!(validate_path_part("..").is_err());
230        assert!(validate_path_part("/").is_err());
231        assert!(validate_path_part("./").is_err());
232        assert!(validate_path_part("???").is_err());
233        assert!(validate_path_part("file/../root").is_err());
234        assert!(validate_path_part("file name").is_err());
235        assert!(validate_path_part(" file").is_err());
236        assert!(validate_path_part("file ").is_err());
237        assert!(validate_path_part("").is_err());
238        assert!(validate_path_part(" ").is_err());
239    }
240}