Skip to main content

max / makenotwork

19.8 KB · 527 lines History Blame Raw
1 //! Validators for items, chapters, tags, blog posts, collections, and related content.
2
3 use super::limits;
4 use crate::error::AppError;
5
6 pub fn validate_item_title(title: &str) -> Result<(), AppError> {
7 if title.is_empty() {
8 return Err(AppError::validation("Title is required".to_string()));
9 }
10 if title.chars().count() > limits::ITEM_TITLE_MAX {
11 return Err(AppError::validation(format!(
12 "Title must be {} characters or less",
13 limits::ITEM_TITLE_MAX
14 )));
15 }
16 super::reject_control_chars("Title", title)?;
17 Ok(())
18 }
19
20 pub fn validate_item_description(description: &str) -> Result<(), AppError> {
21 if description.chars().count() > limits::ITEM_DESCRIPTION_MAX {
22 return Err(AppError::validation(format!(
23 "Description must be {} characters or less",
24 limits::ITEM_DESCRIPTION_MAX
25 )));
26 }
27 super::reject_control_chars_multiline("Description", description)?;
28 Ok(())
29 }
30
31 pub fn validate_chapter_title(title: &str) -> Result<(), AppError> {
32 if title.is_empty() {
33 return Err(AppError::validation(
34 "Chapter title is required".to_string(),
35 ));
36 }
37 if title.chars().count() > limits::CHAPTER_TITLE_MAX {
38 return Err(AppError::validation(format!(
39 "Chapter title must be {} characters or less",
40 limits::CHAPTER_TITLE_MAX
41 )));
42 }
43 super::reject_control_chars("Chapter title", title)?;
44 Ok(())
45 }
46
47 pub fn validate_item_text_body(body: &str) -> Result<(), AppError> {
48 if body.chars().count() > limits::ITEM_TEXT_BODY_MAX {
49 return Err(AppError::validation(format!(
50 "Text body must be {} characters or less",
51 limits::ITEM_TEXT_BODY_MAX
52 )));
53 }
54 super::reject_control_chars_multiline("Text body", body)?;
55 Ok(())
56 }
57
58 /// Validate a tag name (for admin tag creation).
59 ///
60 /// Regular users select tags from the taxonomy via typeahead search,
61 /// so this is only needed when creating new tags.
62 pub fn validate_tag_name(name: &str) -> Result<(), AppError> {
63 if name.is_empty() {
64 return Err(AppError::validation("Tag name cannot be empty".to_string()));
65 }
66 if name.chars().count() > limits::TAG_MAX {
67 return Err(AppError::validation(format!(
68 "Tag name must be {} characters or less",
69 limits::TAG_MAX
70 )));
71 }
72 // Tags can only contain alphanumeric characters, spaces, and hyphens
73 if !name
74 .chars()
75 .all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-')
76 {
77 return Err(AppError::validation(
78 "Tag names can only contain letters, numbers, spaces, and hyphens".to_string(),
79 ));
80 }
81 Ok(())
82 }
83
84 /// Validate a tag slug using the tagtree standard.
85 ///
86 /// Tag slugs follow a 3-level hierarchy: `type.category.value`
87 /// (e.g. `audio.genre.electronic`, `software.language.rust`).
88 /// `semantic_depth: 2` enforces at least 3 segments.
89 pub const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
90 max_depth: 5,
91 max_length: 100,
92 semantic_depth: 2,
93 };
94
95 pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> {
96 tagtree::validate_with(slug, &MNW_TAG_CONFIG)
97 .map_err(|e| AppError::validation(format!("Invalid tag: {e}")))
98 }
99
100 pub fn validate_version_number(version: &str) -> Result<(), AppError> {
101 if version.is_empty() {
102 return Err(AppError::validation(
103 "Version number is required".to_string(),
104 ));
105 }
106 if version.chars().count() > limits::VERSION_NUMBER_MAX {
107 return Err(AppError::validation(format!(
108 "Version number must be {} characters or less",
109 limits::VERSION_NUMBER_MAX
110 )));
111 }
112 super::reject_control_chars("Version number", version)?;
113 Ok(())
114 }
115
116 /// Validate changelog text
117 pub fn validate_changelog(changelog: &str) -> Result<(), AppError> {
118 if changelog.chars().count() > limits::CHANGELOG_MAX {
119 return Err(AppError::validation(format!(
120 "Changelog must be {} characters or less",
121 limits::CHANGELOG_MAX
122 )));
123 }
124 super::reject_control_chars_multiline("Changelog", changelog)?;
125 Ok(())
126 }
127
128 pub fn validate_waitlist_pitch(pitch: &str) -> Result<(), AppError> {
129 if pitch.chars().count() < limits::WAITLIST_PITCH_MIN {
130 return Err(AppError::validation(format!(
131 "Pitch must be at least {} characters",
132 limits::WAITLIST_PITCH_MIN
133 )));
134 }
135 if pitch.chars().count() > limits::WAITLIST_PITCH_MAX {
136 return Err(AppError::validation(format!(
137 "Pitch must be {} characters or less",
138 limits::WAITLIST_PITCH_MAX
139 )));
140 }
141 super::reject_control_chars_multiline("Pitch", pitch)?;
142 Ok(())
143 }
144
145 pub fn validate_blog_post_title(title: &str) -> Result<(), AppError> {
146 if title.is_empty() {
147 return Err(AppError::validation(
148 "Blog post title is required".to_string(),
149 ));
150 }
151 if title.chars().count() > limits::BLOG_POST_TITLE_MAX {
152 return Err(AppError::validation(format!(
153 "Blog post title must be {} characters or less",
154 limits::BLOG_POST_TITLE_MAX
155 )));
156 }
157 super::reject_control_chars("Blog post title", title)?;
158 Ok(())
159 }
160
161 /// Validate a blog post slug (delegates to [`validate_slug`](super::validate_slug)).
162 pub fn validate_blog_post_slug(slug: &str) -> Result<(), AppError> {
163 super::validate_slug(slug)
164 }
165
166 pub fn validate_blog_post_body(body: &str) -> Result<(), AppError> {
167 if body.chars().count() > limits::BLOG_POST_BODY_MAX {
168 return Err(AppError::validation(format!(
169 "Blog post body must be {} characters or less",
170 limits::BLOG_POST_BODY_MAX
171 )));
172 }
173 super::reject_control_chars_multiline("Blog post body", body)?;
174 Ok(())
175 }
176
177 /// Validate a license key code format: 5 or 6 hyphen-separated lowercase
178 /// ASCII words. The generator currently emits 6 (raised from 5 after the
179 /// birthday-collision review in `crypto.rs`); the validator accepts both so
180 /// keys already issued at 5 words continue to validate.
181 pub fn validate_key_code(code: &str) -> Result<(), AppError> {
182 if code.is_empty() {
183 return Err(AppError::validation("Key code is required".to_string()));
184 }
185 if code.chars().count() > limits::KEY_CODE_MAX {
186 return Err(AppError::validation(format!(
187 "Key code must be {} characters or less",
188 limits::KEY_CODE_MAX
189 )));
190 }
191 let parts: Vec<&str> = code.split('-').collect();
192 if !matches!(parts.len(), 5 | 6) {
193 return Err(AppError::validation("Invalid key code format".to_string()));
194 }
195 for part in &parts {
196 if part.is_empty() || !part.chars().all(|c| c.is_ascii_lowercase()) {
197 return Err(AppError::validation("Invalid key code format".to_string()));
198 }
199 }
200 Ok(())
201 }
202
203 pub fn validate_collection_title(title: &str) -> Result<(), AppError> {
204 if title.is_empty() {
205 return Err(AppError::validation(
206 "Collection title is required".to_string(),
207 ));
208 }
209 if title.chars().count() > limits::COLLECTION_TITLE_MAX {
210 return Err(AppError::validation(format!(
211 "Collection title must be {} characters or less",
212 limits::COLLECTION_TITLE_MAX
213 )));
214 }
215 super::reject_control_chars("Collection title", title)?;
216 Ok(())
217 }
218
219 pub fn validate_collection_description(description: &str) -> Result<(), AppError> {
220 if description.chars().count() > limits::COLLECTION_DESCRIPTION_MAX {
221 return Err(AppError::validation(format!(
222 "Collection description must be {} characters or less",
223 limits::COLLECTION_DESCRIPTION_MAX
224 )));
225 }
226 super::reject_control_chars_multiline("Collection description", description)?;
227 Ok(())
228 }
229
230 /// Validate an item section title
231 pub fn validate_section_title(title: &str) -> Result<(), AppError> {
232 let trimmed = title.trim();
233 if trimmed.is_empty() {
234 return Err(AppError::validation(
235 "Section title is required".to_string(),
236 ));
237 }
238 if trimmed.chars().count() > limits::SECTION_TITLE_MAX {
239 return Err(AppError::validation(format!(
240 "Section title must be {} characters or less",
241 limits::SECTION_TITLE_MAX
242 )));
243 }
244 super::reject_control_chars("Section title", trimmed)?;
245 Ok(())
246 }
247
248 /// Validate an item section body
249 pub fn validate_section_body(body: &str) -> Result<(), AppError> {
250 if body.chars().count() > limits::SECTION_BODY_MAX {
251 return Err(AppError::validation(format!(
252 "Section body must be {} characters or less",
253 limits::SECTION_BODY_MAX
254 )));
255 }
256 super::reject_control_chars_multiline("Section body", body)?;
257 Ok(())
258 }
259
260 // Price validation lives on the `PriceCents` newtype (`db::validated_types`),
261 // which every live write path constructs, the former free-standing
262 // `validate_price_cents` here was a dead second source of truth and was removed.
263
264 #[cfg(test)]
265 mod tests {
266 use super::*;
267
268 #[test]
269 fn multiline_bodies_reject_control_bytes_but_allow_newlines() {
270 // Multi-line fields keep newlines/tabs (legitimate formatting)...
271 assert!(validate_item_description("line one\nline two\twith tab").is_ok());
272 assert!(validate_item_text_body("para one\n\npara two").is_ok());
273 // ...but reject NUL and other non-whitespace control bytes.
274 assert!(validate_item_description("bad\0null").is_err());
275 assert!(validate_item_text_body("esc\u{1b}[31m").is_err());
276 assert!(validate_changelog("fixed\u{7f}del").is_err());
277 }
278
279 #[test]
280 fn test_validate_item_title() {
281 assert!(validate_item_title("My Song").is_ok());
282 assert!(validate_item_title("").is_err()); // empty
283 assert!(validate_item_title(&"a".repeat(201)).is_err()); // too long
284 // Single-line: no CR/LF/tab/control chars (would break email subjects).
285 assert!(validate_item_title("Song\r\nBcc: attacker@evil.com").is_err());
286 assert!(validate_item_title("Song\nInjection").is_err());
287 assert!(validate_item_title("Song\ttab").is_err());
288 assert!(validate_item_title("Song\0null").is_err());
289 }
290
291 #[test]
292 fn test_validate_item_description() {
293 assert!(validate_item_description("Great item").is_ok());
294 assert!(validate_item_description("").is_ok()); // empty is valid
295 assert!(validate_item_description(&"a".repeat(5001)).is_err()); // too long
296 }
297
298 #[test]
299 fn test_validate_chapter_title() {
300 assert!(validate_chapter_title("Introduction").is_ok());
301 assert!(validate_chapter_title("X").is_ok()); // single char
302 assert!(validate_chapter_title("").is_err()); // empty
303 assert!(validate_chapter_title(&"a".repeat(200)).is_ok()); // at limit
304 assert!(validate_chapter_title(&"a".repeat(201)).is_err()); // over limit
305 assert!(validate_chapter_title("Intro\r\nX").is_err()); // no line breaks
306 }
307
308 #[test]
309 fn test_validate_item_text_body() {
310 assert!(validate_item_text_body("Some content").is_ok());
311 assert!(validate_item_text_body("").is_ok()); // empty is valid
312 assert!(validate_item_text_body(&"a".repeat(500_000)).is_ok()); // at limit
313 assert!(validate_item_text_body(&"a".repeat(500_001)).is_err()); // over limit
314 }
315
316 #[test]
317 fn test_validate_tag_name() {
318 assert!(validate_tag_name("music").is_ok());
319 assert!(validate_tag_name("lo-fi").is_ok());
320 assert!(validate_tag_name("ambient music").is_ok());
321 assert!(validate_tag_name("").is_err());
322 assert!(validate_tag_name("tag@invalid").is_err());
323 }
324
325 #[test]
326 fn test_validate_version_number() {
327 assert!(validate_version_number("1.0.0").is_ok());
328 assert!(validate_version_number("v2").is_ok());
329 assert!(validate_version_number("").is_err()); // empty
330 assert!(validate_version_number(&"a".repeat(51)).is_err()); // too long
331 }
332
333 #[test]
334 fn test_validate_changelog() {
335 assert!(validate_changelog("Fixed bugs").is_ok());
336 assert!(validate_changelog("").is_ok()); // empty is valid
337 assert!(validate_changelog(&"a".repeat(10001)).is_err()); // too long
338 }
339
340 #[test]
341 fn test_validate_waitlist_pitch() {
342 assert!(validate_waitlist_pitch(&"a".repeat(20)).is_ok()); // minimum
343 assert!(validate_waitlist_pitch(&"a".repeat(500)).is_ok()); // maximum
344 assert!(validate_waitlist_pitch(&"a".repeat(19)).is_err()); // too short
345 assert!(validate_waitlist_pitch(&"a".repeat(501)).is_err()); // too long
346 }
347
348 #[test]
349 fn test_validate_blog_post_title() {
350 assert!(validate_blog_post_title("My First Post").is_ok());
351 assert!(validate_blog_post_title("").is_err()); // empty
352 assert!(validate_blog_post_title(&"a".repeat(200)).is_ok()); // at limit
353 assert!(validate_blog_post_title(&"a".repeat(201)).is_err()); // over limit
354 assert!(validate_blog_post_title("Post\r\nSubject-Injection").is_err());
355 }
356
357 #[test]
358 fn test_validate_collection_title_rejects_line_breaks() {
359 assert!(validate_collection_title("My Collection").is_ok());
360 assert!(validate_collection_title("Coll\r\nX").is_err());
361 }
362
363 #[test]
364 fn test_validate_section_title_rejects_line_breaks() {
365 assert!(validate_section_title("Overview").is_ok());
366 assert!(validate_section_title("Sec\ntion").is_err());
367 }
368
369 #[test]
370 fn test_validate_blog_post_slug() {
371 assert!(validate_blog_post_slug("my-post").is_ok());
372 assert!(validate_blog_post_slug("ab").is_ok()); // minimum length
373 assert!(validate_blog_post_slug("post123").is_ok());
374 assert!(validate_blog_post_slug("a").is_err()); // too short
375 assert!(validate_blog_post_slug("my_post").is_err()); // underscores
376 assert!(validate_blog_post_slug("my post").is_err()); // spaces
377 assert!(validate_blog_post_slug(&"a".repeat(100)).is_ok()); // at limit
378 assert!(validate_blog_post_slug(&"a".repeat(101)).is_err()); // over limit
379 }
380
381 #[test]
382 fn test_validate_blog_post_body() {
383 assert!(validate_blog_post_body("Some content").is_ok());
384 assert!(validate_blog_post_body("").is_ok()); // empty is valid
385 assert!(validate_blog_post_body(&"a".repeat(100_000)).is_ok()); // at limit
386 assert!(validate_blog_post_body(&"a".repeat(100_001)).is_err()); // over limit
387 }
388
389 #[test]
390 fn test_validate_key_code() {
391 assert!(validate_key_code("bright-castle-forest-river-falcon").is_ok());
392 assert!(validate_key_code("abc-def-ghi-jkl-mno").is_ok());
393 assert!(validate_key_code("").is_err()); // empty
394 assert!(validate_key_code("one-two-three").is_err()); // too few parts
395 assert!(validate_key_code("one-two-three-four-five-six").is_ok()); // 6 parts now accepted
396 assert!(validate_key_code("one-two-three-four-five-six-seven").is_err()); // too many parts
397 assert!(validate_key_code("ONE-TWO-THREE-FOUR-FIVE").is_err()); // uppercase
398 assert!(validate_key_code("one-tw0-three-four-five").is_err()); // digit
399 assert!(validate_key_code("----").is_err()); // empty segments
400 assert!(validate_key_code("a--b-c-d").is_err()); // empty middle segment
401 }
402
403 #[test]
404 fn test_validate_section_title() {
405 assert!(validate_section_title("Features").is_ok());
406 assert!(validate_section_title(" Features ").is_ok()); // trimmed
407 assert!(validate_section_title("").is_err()); // empty
408 assert!(validate_section_title(" ").is_err()); // whitespace only
409 assert!(validate_section_title(&"a".repeat(100)).is_ok()); // at limit
410 assert!(validate_section_title(&"a".repeat(101)).is_err()); // over limit
411 }
412
413 #[test]
414 fn test_validate_section_body() {
415 assert!(validate_section_body("Some markdown content").is_ok());
416 assert!(validate_section_body("").is_ok()); // empty is valid
417 assert!(validate_section_body(&"a".repeat(100_000)).is_ok()); // at limit
418 assert!(validate_section_body(&"a".repeat(100_001)).is_err()); // over limit
419 }
420
421 #[test]
422 fn test_multibyte_characters_counted_correctly() {
423 // CJK characters are 3 bytes each in UTF-8, but should count as 1 character
424 // Validate that item title handles multi-byte correctly
425 let cjk_title: String = "\u{6d4b}".repeat(200); // 200 CJK chars
426 assert_eq!(cjk_title.len(), 600); // 600 bytes
427 assert!(validate_item_title(&cjk_title).is_ok()); // 200 chars <= 200 max
428 let cjk_title_over: String = "\u{6d4b}".repeat(201);
429 assert!(validate_item_title(&cjk_title_over).is_err()); // 201 > 200
430
431 // Slug min-length with multi-byte: test waitlist pitch min instead,
432 // since it accepts any characters
433 let pitch_cjk: String = "\u{6587}".repeat(20); // 20 CJK chars
434 assert_eq!(pitch_cjk.len(), 60); // 60 bytes
435 assert!(validate_waitlist_pitch(&pitch_cjk).is_ok()); // 20 chars >= 20 min
436 }
437
438 // ── Adversarial tests (test-fuzz) ──
439
440 #[test]
441 fn test_validate_tag_name_unicode_rejected() {
442 // Tags only allow ASCII alphanumeric + spaces + hyphens
443 assert!(validate_tag_name("\u{00e9}lectronic").is_err());
444 assert!(validate_tag_name("lo\u{2010}fi").is_err()); // Unicode hyphen U+2010
445 }
446
447 #[test]
448 fn test_validate_tag_name_null_bytes() {
449 assert!(validate_tag_name("music\0").is_err());
450 }
451
452 #[test]
453 fn test_validate_tag_name_at_max() {
454 assert!(validate_tag_name(&"a".repeat(50)).is_ok());
455 assert!(validate_tag_name(&"a".repeat(51)).is_err());
456 }
457
458 #[test]
459 fn test_validate_key_code_with_unicode_words() {
460 assert!(validate_key_code("\u{00e9}-two-three-four-five").is_err());
461 }
462
463 #[test]
464 fn test_validate_key_code_null_bytes() {
465 assert!(validate_key_code("one\0-two-three-four-five").is_err());
466 }
467
468 #[test]
469 fn test_validate_key_code_single_char_words() {
470 assert!(validate_key_code("a-b-c-d-e").is_ok());
471 }
472
473 #[test]
474 fn test_validate_section_title_only_whitespace_padded() {
475 // Leading/trailing whitespace with content in the middle should pass
476 assert!(validate_section_title(" Features ").is_ok());
477 // Tab-only should fail (trimmed to empty)
478 assert!(validate_section_title("\t\t").is_err());
479 // Newline-only should fail
480 assert!(validate_section_title("\n").is_err());
481 }
482
483 #[test]
484 fn test_validate_collection_title_at_boundary() {
485 assert!(validate_collection_title(&"a".repeat(100)).is_ok());
486 assert!(validate_collection_title(&"a".repeat(101)).is_err());
487 }
488
489 #[test]
490 fn test_validate_collection_description_at_boundary() {
491 assert!(validate_collection_description(&"a".repeat(500)).is_ok());
492 assert!(validate_collection_description(&"a".repeat(501)).is_err());
493 }
494
495 #[test]
496 fn test_validate_waitlist_pitch_boundary_multibyte() {
497 // 19 CJK chars = 57 bytes but only 19 characters, should fail min check
498 let pitch_short: String = "\u{6587}".repeat(19);
499 assert_eq!(pitch_short.chars().count(), 19);
500 assert!(validate_waitlist_pitch(&pitch_short).is_err());
501 }
502
503 // ── Property-based tests (test-fuzz) ──
504
505 proptest::proptest! {
506 #[test]
507 fn prop_tag_name_valid_always_accepted(s in "[a-zA-Z0-9 \\-]{1,50}") {
508 proptest::prop_assert!(validate_tag_name(&s).is_ok(), "Valid tag rejected: {:?}", s);
509 }
510
511 #[test]
512 fn prop_key_code_valid_always_accepted(
513 a in "[a-z]{1,8}",
514 b in "[a-z]{1,8}",
515 c in "[a-z]{1,8}",
516 d in "[a-z]{1,8}",
517 e in "[a-z]{1,8}",
518 ) {
519 let code = format!("{a}-{b}-{c}-{d}-{e}");
520 if code.chars().count() <= 50 {
521 proptest::prop_assert!(validate_key_code(&code).is_ok(), "Valid key code rejected: {:?}", code);
522 }
523 }
524
525 }
526 }
527