Skip to main content

max / makenotwork

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