Skip to main content

max / makenotwork

12.8 KB · 377 lines History Blame Raw
1 //! Validators for projects, labels, and git repositories.
2
3 use crate::error::AppError;
4 use super::limits;
5
6 /// Validate a project title
7 pub fn validate_project_title(title: &str) -> Result<(), AppError> {
8 if title.is_empty() {
9 return Err(AppError::validation("Project title is required".to_string()));
10 }
11 if title.chars().count() > limits::PROJECT_TITLE_MAX {
12 return Err(AppError::validation(format!(
13 "Project title must be {} characters or less",
14 limits::PROJECT_TITLE_MAX
15 )));
16 }
17 Ok(())
18 }
19
20 /// Validate a project description
21 pub fn validate_project_description(description: &str) -> Result<(), AppError> {
22 if description.chars().count() > limits::PROJECT_DESCRIPTION_MAX {
23 return Err(AppError::validation(format!(
24 "Project description must be {} characters or less",
25 limits::PROJECT_DESCRIPTION_MAX
26 )));
27 }
28 Ok(())
29 }
30
31 /// Validate a project slug (delegates to [`validate_slug`](super::validate_slug)).
32 pub fn validate_project_slug(slug: &str) -> Result<(), AppError> {
33 super::validate_slug(slug)
34 }
35
36 /// Validate an issue title
37 pub fn validate_issue_title(title: &str) -> Result<(), AppError> {
38 if title.is_empty() {
39 return Err(AppError::validation("Issue title is required".to_string()));
40 }
41 if title.chars().count() > limits::ISSUE_TITLE_MAX {
42 return Err(AppError::validation(format!(
43 "Issue title must be {} characters or less",
44 limits::ISSUE_TITLE_MAX
45 )));
46 }
47 Ok(())
48 }
49
50 /// Validate an issue body (markdown)
51 pub fn validate_issue_body(body: &str) -> Result<(), AppError> {
52 if body.chars().count() > limits::ISSUE_BODY_MAX {
53 return Err(AppError::validation(format!(
54 "Issue body must be {} characters or less",
55 limits::ISSUE_BODY_MAX
56 )));
57 }
58 Ok(())
59 }
60
61 /// Validate an issue comment body (markdown)
62 pub fn validate_issue_comment_body(body: &str) -> Result<(), AppError> {
63 if body.trim().is_empty() {
64 return Err(AppError::validation("Comment body is required".to_string()));
65 }
66 if body.chars().count() > limits::ISSUE_COMMENT_BODY_MAX {
67 return Err(AppError::validation(format!(
68 "Comment must be {} characters or less",
69 limits::ISSUE_COMMENT_BODY_MAX
70 )));
71 }
72 Ok(())
73 }
74
75 /// Validate an issue label name
76 pub fn validate_label_name(name: &str) -> Result<(), AppError> {
77 if name.trim().is_empty() {
78 return Err(AppError::validation("Label name is required".to_string()));
79 }
80 if name.chars().count() > limits::ISSUE_LABEL_NAME_MAX {
81 return Err(AppError::validation(format!(
82 "Label name must be {} characters or less",
83 limits::ISSUE_LABEL_NAME_MAX
84 )));
85 }
86 // Only allow printable characters (letters, numbers, punctuation, spaces).
87 // Rejects control characters, null bytes, and non-printable unicode.
88 if name.chars().any(|c| c.is_control()) {
89 return Err(AppError::validation(
90 "Label name cannot contain control characters".to_string(),
91 ));
92 }
93 Ok(())
94 }
95
96 /// Validate a label color (hex format: #RRGGBB)
97 pub fn validate_label_color(color: &str) -> Result<(), AppError> {
98 if color.len() != 7 || !color.starts_with('#') {
99 return Err(AppError::validation(
100 "Label color must be in #RRGGBB format".to_string(),
101 ));
102 }
103 if !color[1..].chars().all(|c| c.is_ascii_hexdigit()) {
104 return Err(AppError::validation(
105 "Label color must be a valid hex color".to_string(),
106 ));
107 }
108 Ok(())
109 }
110
111 /// Validate a git repo description (length check, trimmed input expected).
112 pub fn validate_repo_description(desc: &str) -> Result<(), AppError> {
113 if desc.chars().count() > limits::REPO_DESCRIPTION_MAX {
114 return Err(AppError::validation(format!(
115 "Repository description must be {} characters or less",
116 limits::REPO_DESCRIPTION_MAX
117 )));
118 }
119 Ok(())
120 }
121
122 /// Validate a git repository name: 1-64 chars, ASCII alphanumeric + hyphens/underscores/dots, no leading dot.
123 pub fn validate_git_repo_name(name: &str) -> Result<(), AppError> {
124 if name.is_empty() || name.len() > 64 {
125 return Err(AppError::validation(
126 "Git repo name must be 1-64 characters".to_string(),
127 ));
128 }
129 if name.starts_with('.') {
130 return Err(AppError::validation(
131 "Git repo name cannot start with a dot".to_string(),
132 ));
133 }
134 if !name
135 .chars()
136 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
137 {
138 return Err(AppError::validation(
139 "Git repo name can only contain letters, numbers, hyphens, underscores, and dots"
140 .to_string(),
141 ));
142 }
143 Ok(())
144 }
145
146 #[cfg(test)]
147 mod tests {
148 use super::*;
149
150 #[test]
151 fn test_validate_project_slug_valid() {
152 assert!(validate_project_slug("my-project").is_ok());
153 assert!(validate_project_slug("ab").is_ok());
154 assert!(validate_project_slug("project123").is_ok());
155 }
156
157 #[test]
158 fn test_validate_project_slug_invalid() {
159 assert!(validate_project_slug("a").is_err()); // too short
160 assert!(validate_project_slug("my_project").is_err()); // underscores
161 assert!(validate_project_slug("my project").is_err()); // spaces
162 assert!(validate_project_slug(&"a".repeat(101)).is_err()); // too long
163 }
164
165 #[test]
166 fn test_validate_project_title() {
167 assert!(validate_project_title("My Project").is_ok());
168 assert!(validate_project_title("").is_err()); // empty
169 assert!(validate_project_title(&"a".repeat(201)).is_err()); // too long
170 }
171
172 #[test]
173 fn test_validate_project_description() {
174 assert!(validate_project_description("A cool project").is_ok());
175 assert!(validate_project_description("").is_ok()); // empty is valid
176 assert!(validate_project_description(&"a".repeat(2001)).is_err()); // too long
177 }
178
179 #[test]
180 fn test_validate_issue_title() {
181 assert!(validate_issue_title("Bug report").is_ok());
182 assert!(validate_issue_title("").is_err()); // empty
183 assert!(validate_issue_title(&"a".repeat(200)).is_ok()); // at limit
184 assert!(validate_issue_title(&"a".repeat(201)).is_err()); // over limit
185 }
186
187 #[test]
188 fn test_validate_issue_body() {
189 assert!(validate_issue_body("Detailed description").is_ok());
190 assert!(validate_issue_body("").is_ok()); // empty is valid
191 assert!(validate_issue_body(&"a".repeat(50_000)).is_ok()); // at limit
192 assert!(validate_issue_body(&"a".repeat(50_001)).is_err()); // over limit
193 }
194
195 #[test]
196 fn test_validate_issue_comment_body() {
197 assert!(validate_issue_comment_body("Good point").is_ok());
198 assert!(validate_issue_comment_body("").is_err()); // empty
199 assert!(validate_issue_comment_body(&"a".repeat(50_000)).is_ok());
200 assert!(validate_issue_comment_body(&"a".repeat(50_001)).is_err());
201 }
202
203 #[test]
204 fn test_validate_label_name() {
205 assert!(validate_label_name("bug").is_ok());
206 assert!(validate_label_name("").is_err());
207 assert!(validate_label_name(&"a".repeat(50)).is_ok());
208 assert!(validate_label_name(&"a".repeat(51)).is_err());
209 }
210
211 #[test]
212 fn test_validate_label_color() {
213 assert!(validate_label_color("#ff0000").is_ok());
214 assert!(validate_label_color("#6c5ce7").is_ok());
215 assert!(validate_label_color("#AABBCC").is_ok());
216 assert!(validate_label_color("ff0000").is_err()); // no #
217 assert!(validate_label_color("#fff").is_err()); // too short
218 assert!(validate_label_color("#gggggg").is_err()); // invalid hex
219 assert!(validate_label_color("#12345678").is_err()); // too long
220 }
221
222 #[test]
223 fn test_validate_repo_description() {
224 assert!(validate_repo_description("").is_ok());
225 assert!(validate_repo_description("A short description").is_ok());
226 assert!(validate_repo_description(&"a".repeat(500)).is_ok());
227 assert!(validate_repo_description(&"a".repeat(501)).is_err());
228 }
229
230 #[test]
231 fn test_validate_git_repo_name() {
232 // Valid names
233 assert!(validate_git_repo_name("my-repo").is_ok());
234 assert!(validate_git_repo_name("my_repo").is_ok());
235 assert!(validate_git_repo_name("MyRepo123").is_ok());
236 assert!(validate_git_repo_name("repo.name").is_ok());
237 assert!(validate_git_repo_name("a").is_ok()); // single char
238 assert!(validate_git_repo_name(&"a".repeat(64)).is_ok()); // at limit
239
240 // Invalid: empty
241 assert!(validate_git_repo_name("").is_err());
242 // Invalid: too long
243 assert!(validate_git_repo_name(&"a".repeat(65)).is_err());
244 // Invalid: leading dot
245 assert!(validate_git_repo_name(".hidden").is_err());
246 // Invalid: spaces
247 assert!(validate_git_repo_name("my repo").is_err());
248 // Invalid: slashes
249 assert!(validate_git_repo_name("foo/bar").is_err());
250 // Invalid: special chars
251 assert!(validate_git_repo_name("repo@name").is_err());
252 assert!(validate_git_repo_name("repo!").is_err());
253 }
254
255 // ── Edge cases (test-fuzz) ──
256
257 #[test]
258 fn test_git_repo_name_path_traversal() {
259 // ".." could be dangerous for path traversal, but it starts with "."
260 assert!(validate_git_repo_name("..").is_err());
261 // "a.." is valid (doesn't start with dot)
262 assert!(validate_git_repo_name("a..").is_ok());
263 }
264
265 #[test]
266 fn test_git_repo_name_dot_git() {
267 assert!(validate_git_repo_name(".git").is_err()); // starts with dot
268 assert!(validate_git_repo_name("repo.git").is_ok()); // doesn't start with dot
269 }
270
271 #[test]
272 fn test_label_color_with_lowercase() {
273 assert!(validate_label_color("#aabbcc").is_ok());
274 }
275
276 #[test]
277 fn test_label_color_empty() {
278 assert!(validate_label_color("").is_err());
279 }
280
281 #[test]
282 fn test_label_color_just_hash() {
283 assert!(validate_label_color("#").is_err());
284 }
285
286 #[test]
287 fn test_validate_issue_body_empty_is_valid() {
288 // Issue body can be empty (unlike comment body)
289 assert!(validate_issue_body("").is_ok());
290 }
291
292 #[test]
293 fn test_validate_issue_comment_body_whitespace_only() {
294 // Whitespace-only comment is rejected (trim before empty check)
295 assert!(validate_issue_comment_body(" ").is_err());
296 }
297
298 #[test]
299 fn test_project_slug_exactly_two_chars() {
300 assert!(validate_project_slug("ab").is_ok());
301 }
302
303 #[test]
304 fn test_project_slug_one_char() {
305 assert!(validate_project_slug("a").is_err());
306 }
307
308 // ── Adversarial tests (test-fuzz) ──
309
310 #[test]
311 fn test_git_repo_name_null_bytes() {
312 assert!(validate_git_repo_name("repo\0name").is_err());
313 }
314
315 #[test]
316 fn test_git_repo_name_unicode() {
317 assert!(validate_git_repo_name("r\u{00e9}po").is_err());
318 }
319
320 #[test]
321 fn test_git_repo_name_path_separators() {
322 assert!(validate_git_repo_name("foo/bar").is_err());
323 assert!(validate_git_repo_name("foo\\bar").is_err());
324 }
325
326 #[test]
327 fn test_git_repo_name_ends_with_dot() {
328 // Ending with dot is valid (only leading dot is rejected)
329 assert!(validate_git_repo_name("repo.").is_ok());
330 }
331
332 #[test]
333 fn test_git_repo_name_all_dots() {
334 assert!(validate_git_repo_name(".").is_err()); // leading dot
335 assert!(validate_git_repo_name("..").is_err()); // leading dot
336 assert!(validate_git_repo_name("...").is_err()); // leading dot
337 }
338
339 #[test]
340 fn test_label_color_unicode_hex_digits() {
341 // Full-width digits shouldn't be accepted
342 assert!(validate_label_color("#\u{FF10}\u{FF11}\u{FF12}\u{FF13}\u{FF14}\u{FF15}").is_err());
343 }
344
345 #[test]
346 fn test_issue_comment_body_single_char() {
347 assert!(validate_issue_comment_body("x").is_ok());
348 }
349
350 #[test]
351 fn test_label_name_with_special_chars() {
352 // Label names have no character restrictions beyond length
353 assert!(validate_label_name("bug \u{1F41B}").is_ok());
354 assert!(validate_label_name("<script>").is_ok());
355 }
356
357 // ── Property-based tests (test-fuzz) ──
358
359 proptest::proptest! {
360 #[test]
361 fn prop_git_repo_name_valid_always_accepted(s in "[a-zA-Z][a-zA-Z0-9._\\-]{0,63}") {
362 proptest::prop_assert!(validate_git_repo_name(&s).is_ok(), "Valid repo name rejected: {:?}", s);
363 }
364
365 #[test]
366 fn prop_label_color_valid_always_accepted(hex in "[0-9a-fA-F]{6}") {
367 let color = format!("#{}", hex);
368 proptest::prop_assert!(validate_label_color(&color).is_ok(), "Valid color rejected: {:?}", color);
369 }
370
371 #[test]
372 fn prop_issue_title_never_panics(s in "\\PC{0,300}") {
373 let _ = validate_issue_title(&s);
374 }
375 }
376 }
377