Skip to main content

max / makenotwork

15.0 KB · 437 lines History Blame Raw
1 //! Input validation utilities
2 //!
3 //! Provides validation functions for user input with consistent error messages.
4
5 mod items;
6 mod payments;
7 mod projects;
8 mod users;
9
10 pub use items::*;
11 pub use payments::*;
12 pub use projects::*;
13 pub use users::*;
14
15 use crate::error::AppError;
16
17 /// Maximum lengths for various fields
18 pub mod limits {
19 pub const DISPLAY_NAME_MAX: usize = 100;
20 pub const BIO_MAX: usize = 2000;
21 pub const LINK_URL_MAX: usize = 500;
22 pub const LINK_TITLE_MAX: usize = 100;
23 pub const ITEM_TITLE_MAX: usize = 200;
24 pub const ITEM_DESCRIPTION_MAX: usize = 5000;
25 pub const TAG_MAX: usize = 50;
26 pub const PROJECT_TITLE_MAX: usize = 200;
27 pub const PROJECT_DESCRIPTION_MAX: usize = 2000;
28 pub const PROJECT_SLUG_MAX: usize = 100;
29 pub const VERSION_NUMBER_MAX: usize = 50;
30 pub const CHANGELOG_MAX: usize = 10000;
31 pub const WAITLIST_PITCH_MIN: usize = 20;
32 pub const WAITLIST_PITCH_MAX: usize = 500;
33 pub const BLOG_POST_TITLE_MAX: usize = 200;
34 pub const BLOG_POST_SLUG_MAX: usize = 100;
35 pub const BLOG_POST_BODY_MAX: usize = 100_000;
36 pub const CHAPTER_TITLE_MAX: usize = 200;
37 pub const ITEM_TEXT_BODY_MAX: usize = 500_000;
38 pub const KEY_CODE_MAX: usize = 50;
39 pub const MACHINE_ID_MAX: usize = 255;
40 pub const ACTIVATION_LABEL_MAX: usize = 100;
41 // Subscriptions
42 pub const TIER_NAME_MAX: usize = 100;
43 pub const TIER_DESCRIPTION_MAX: usize = 2000;
44 // SyncKit
45 pub const SYNC_APP_NAME_MAX: usize = 100;
46 pub const SYNC_DEVICE_NAME_MAX: usize = 100;
47 pub const SYNC_TABLE_NAME_MAX: usize = 100;
48 pub const SYNC_ROW_ID_MAX: usize = 255;
49 pub const SYNC_BLOB_HASH_LEN: usize = 64; // SHA-256 hex
50 pub const SYNC_KEY_MAX: usize = 255;
51 // SSH keys
52 pub const SSH_KEY_LABEL_MAX: usize = 128;
53 // Git issues
54 pub const ISSUE_TITLE_MAX: usize = 200;
55 pub const ISSUE_BODY_MAX: usize = 50_000;
56 pub const ISSUE_COMMENT_BODY_MAX: usize = 50_000;
57 pub const ISSUE_LABEL_NAME_MAX: usize = 50;
58 // Git repo settings
59 pub const REPO_DESCRIPTION_MAX: usize = 500;
60 // Collections
61 pub const COLLECTION_TITLE_MAX: usize = 100;
62 pub const COLLECTION_DESCRIPTION_MAX: usize = 500;
63 // Item sections
64 pub const SECTION_TITLE_MAX: usize = 100;
65 pub const SECTION_BODY_MAX: usize = 100_000;
66 }
67
68 /// Validate a slug: 2-100 chars, alphanumeric + hyphens.
69 ///
70 /// Shared rule for project slugs, blog post slugs, and tag slugs.
71 /// Also used by the `Slug` newtype's `Deserialize` impl.
72 pub fn validate_slug(slug: &str) -> Result<(), AppError> {
73 let len = slug.chars().count();
74 if len < 2 {
75 return Err(AppError::validation(
76 "URL name must be at least 2 characters".to_string(),
77 ));
78 }
79 if len > limits::PROJECT_SLUG_MAX {
80 return Err(AppError::validation(format!(
81 "URL name must be {} characters or less",
82 limits::PROJECT_SLUG_MAX
83 )));
84 }
85 if !slug
86 .chars()
87 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
88 {
89 return Err(AppError::validation(
90 "URL name can only contain lowercase letters, numbers, and hyphens".to_string(),
91 ));
92 }
93 if !slug.chars().any(|c| c.is_ascii_alphanumeric()) {
94 return Err(AppError::validation(
95 "URL name must contain at least one letter or number".to_string(),
96 ));
97 }
98 Ok(())
99 }
100
101 // ── SyncKit validation ──
102
103 /// Validate a sync app name
104 pub fn validate_sync_app_name(name: &str) -> Result<(), AppError> {
105 if name.is_empty() {
106 return Err(AppError::validation("App name is required".to_string()));
107 }
108 if name.chars().count() > limits::SYNC_APP_NAME_MAX {
109 return Err(AppError::validation(format!(
110 "App name must be {} characters or less",
111 limits::SYNC_APP_NAME_MAX
112 )));
113 }
114 Ok(())
115 }
116
117 /// Validate a sync device name
118 pub fn validate_sync_device_name(name: &str) -> Result<(), AppError> {
119 if name.is_empty() {
120 return Err(AppError::validation("Device name is required".to_string()));
121 }
122 if name.chars().count() > limits::SYNC_DEVICE_NAME_MAX {
123 return Err(AppError::validation(format!(
124 "Device name must be {} characters or less",
125 limits::SYNC_DEVICE_NAME_MAX
126 )));
127 }
128 Ok(())
129 }
130
131 /// Validate a sync table name
132 pub fn validate_sync_table_name(name: &str) -> Result<(), AppError> {
133 if name.is_empty() {
134 return Err(AppError::validation("Table name is required".to_string()));
135 }
136 if name.chars().count() > limits::SYNC_TABLE_NAME_MAX {
137 return Err(AppError::validation(format!(
138 "Table name must be {} characters or less",
139 limits::SYNC_TABLE_NAME_MAX
140 )));
141 }
142 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
143 return Err(AppError::validation(
144 "Table name can only contain letters, numbers, and underscores".to_string(),
145 ));
146 }
147 Ok(())
148 }
149
150 /// Validate a sync row ID
151 pub fn validate_sync_row_id(row_id: &str) -> Result<(), AppError> {
152 if row_id.is_empty() {
153 return Err(AppError::validation("Row ID is required".to_string()));
154 }
155 if row_id.chars().count() > limits::SYNC_ROW_ID_MAX {
156 return Err(AppError::validation(format!(
157 "Row ID must be {} characters or less",
158 limits::SYNC_ROW_ID_MAX
159 )));
160 }
161 // Reject null bytes and control characters — these can cause issues in
162 // DB queries, file paths, and log output downstream.
163 if row_id.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) {
164 return Err(AppError::validation(
165 "Row ID contains invalid characters".to_string(),
166 ));
167 }
168 Ok(())
169 }
170
171 /// Validate a sync blob hash (must be exactly 64 lowercase hex characters)
172 pub fn validate_sync_blob_hash(hash: &str) -> Result<(), AppError> {
173 if hash.len() != limits::SYNC_BLOB_HASH_LEN {
174 return Err(AppError::validation(format!(
175 "Blob hash must be exactly {} hex characters",
176 limits::SYNC_BLOB_HASH_LEN
177 )));
178 }
179 if !hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
180 return Err(AppError::validation(
181 "Blob hash must be lowercase hexadecimal".to_string(),
182 ));
183 }
184 Ok(())
185 }
186
187 /// Validate a developer-defined SDK key. Opaque string identifying which
188 /// workspace/org/end-user a JWT session belongs to. Rejects empty, oversize,
189 /// null bytes, and control characters — same character rules as `validate_sync_row_id`
190 /// since downstream goes through SQL and log lines.
191 pub fn validate_synckit_key(key: &str) -> Result<(), AppError> {
192 if key.is_empty() {
193 return Err(AppError::validation("SDK key is required".to_string()));
194 }
195 if key.len() > limits::SYNC_KEY_MAX {
196 return Err(AppError::validation(format!(
197 "SDK key must be {} bytes or less",
198 limits::SYNC_KEY_MAX
199 )));
200 }
201 if key.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) {
202 return Err(AppError::validation(
203 "SDK key contains invalid characters".to_string(),
204 ));
205 }
206 Ok(())
207 }
208
209 #[cfg(test)]
210 mod tests {
211 use super::*;
212
213 #[test]
214 fn test_validate_sync_app_name() {
215 assert!(validate_sync_app_name("GoingsOn").is_ok());
216 assert!(validate_sync_app_name("").is_err()); // empty
217 assert!(validate_sync_app_name(&"a".repeat(100)).is_ok()); // at limit
218 assert!(validate_sync_app_name(&"a".repeat(101)).is_err()); // over limit
219 }
220
221 #[test]
222 fn test_validate_sync_device_name() {
223 assert!(validate_sync_device_name("Max's MacBook").is_ok());
224 assert!(validate_sync_device_name("").is_err());
225 assert!(validate_sync_device_name(&"a".repeat(101)).is_err());
226 }
227
228 #[test]
229 fn test_validate_sync_table_name() {
230 assert!(validate_sync_table_name("tasks").is_ok());
231 assert!(validate_sync_table_name("calendar_events").is_ok());
232 assert!(validate_sync_table_name("").is_err());
233 assert!(validate_sync_table_name("bad-name").is_err()); // hyphens
234 assert!(validate_sync_table_name("bad name").is_err()); // spaces
235 assert!(validate_sync_table_name(&"a".repeat(101)).is_err());
236 }
237
238 #[test]
239 fn test_validate_sync_row_id() {
240 assert!(validate_sync_row_id("uuid-123").is_ok());
241 assert!(validate_sync_row_id("").is_err());
242 assert!(validate_sync_row_id(&"a".repeat(255)).is_ok());
243 assert!(validate_sync_row_id(&"a".repeat(256)).is_err());
244 }
245
246 // ── Edge cases (test-fuzz) ──
247
248 #[test]
249 fn test_validate_slug_only_hyphens() {
250 // A slug with NO alphanumeric chars is rejected by the
251 // "must contain at least one letter or number" rule (added later).
252 assert!(validate_slug("--").is_err());
253 }
254
255 #[test]
256 fn test_validate_slug_leading_trailing_hyphens() {
257 assert!(validate_slug("-ab-").is_ok()); // hyphens at boundaries
258 }
259
260 #[test]
261 fn test_validate_slug_with_unicode() {
262 // Non-ASCII is rejected by is_ascii_alphanumeric
263 assert!(validate_slug("caf\u{00e9}").is_err());
264 }
265
266 #[test]
267 fn test_validate_sync_blob_hash_uppercase() {
268 let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
269 assert!(validate_sync_blob_hash(hash).is_err()); // must be lowercase
270 }
271
272 #[test]
273 fn test_validate_sync_blob_hash_mixed_case() {
274 let hash = "aAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaA";
275 assert!(validate_sync_blob_hash(hash).is_err());
276 }
277
278 #[test]
279 fn test_validate_sync_blob_hash_valid() {
280 let hash = "a".repeat(64);
281 assert!(validate_sync_blob_hash(&hash).is_ok());
282 }
283
284 #[test]
285 fn test_validate_sync_blob_hash_wrong_length() {
286 assert!(validate_sync_blob_hash(&"a".repeat(63)).is_err());
287 assert!(validate_sync_blob_hash(&"a".repeat(65)).is_err());
288 }
289
290 #[test]
291 fn test_validate_sync_table_name_with_unicode() {
292 assert!(validate_sync_table_name("\u{00e9}vents").is_err());
293 }
294
295 // ── Adversarial tests (test-fuzz) ──
296
297 #[test]
298 fn test_validate_slug_null_bytes() {
299 assert!(validate_slug("ab\0cd").is_err());
300 }
301
302 #[test]
303 fn test_validate_slug_zero_width_chars() {
304 // Zero-width space (U+200B) should be rejected
305 assert!(validate_slug("ab\u{200B}cd").is_err());
306 // Zero-width joiner
307 assert!(validate_slug("ab\u{200D}cd").is_err());
308 }
309
310 #[test]
311 fn test_validate_slug_rtl_override() {
312 // Right-to-left override (U+202E) should be rejected
313 assert!(validate_slug("ab\u{202E}cd").is_err());
314 }
315
316 #[test]
317 fn test_validate_slug_at_exact_max() {
318 assert!(validate_slug(&"a".repeat(100)).is_ok());
319 assert!(validate_slug(&"a".repeat(101)).is_err());
320 }
321
322 #[test]
323 fn test_validate_slug_single_char() {
324 assert!(validate_slug("a").is_err()); // min is 2
325 }
326
327 #[test]
328 fn test_validate_slug_empty() {
329 assert!(validate_slug("").is_err());
330 }
331
332 #[test]
333 fn test_validate_sync_blob_hash_non_hex() {
334 // 64 chars but contains 'g' which is not hex
335 let hash = format!("{}g", "a".repeat(63));
336 assert!(validate_sync_blob_hash(&hash).is_err());
337 }
338
339 #[test]
340 fn test_validate_sync_blob_hash_empty() {
341 assert!(validate_sync_blob_hash("").is_err());
342 }
343
344 #[test]
345 fn test_validate_sync_table_name_sql_injection() {
346 assert!(validate_sync_table_name("users; DROP TABLE users").is_err());
347 assert!(validate_sync_table_name("users'--").is_err());
348 }
349
350 #[test]
351 fn test_validate_sync_row_id_null_bytes() {
352 // Null bytes and control characters are rejected
353 assert!(validate_sync_row_id("a\0b").is_err());
354 assert!(validate_sync_row_id("a\x01b").is_err());
355 // Tabs are allowed (some ID schemes use them)
356 assert!(validate_sync_row_id("a\tb").is_ok());
357 // Normal IDs pass
358 assert!(validate_sync_row_id("row-123-abc").is_ok());
359 }
360
361 // ── SDK key validation (test-fuzz) ──
362
363 #[test]
364 fn test_validate_synckit_key_basic() {
365 assert!(validate_synckit_key("user-42").is_ok());
366 assert!(validate_synckit_key("workspace/team-1").is_ok());
367 assert!(validate_synckit_key("a").is_ok()); // single char is fine
368 assert!(validate_synckit_key("").is_err());
369 }
370
371 #[test]
372 fn test_validate_synckit_key_length_boundaries() {
373 let at_max = "k".repeat(limits::SYNC_KEY_MAX);
374 let over_max = "k".repeat(limits::SYNC_KEY_MAX + 1);
375 assert!(validate_synckit_key(&at_max).is_ok(), "exact max must pass");
376 assert!(validate_synckit_key(&over_max).is_err(), "over max must fail");
377 }
378
379 #[test]
380 fn test_validate_synckit_key_null_and_controls() {
381 assert!(validate_synckit_key("a\0b").is_err());
382 assert!(validate_synckit_key("a\x01b").is_err());
383 assert!(validate_synckit_key("a\x1Fb").is_err()); // unit separator
384 // Tabs are permitted, mirroring validate_sync_row_id.
385 assert!(validate_synckit_key("a\tb").is_ok());
386 }
387
388 #[test]
389 fn test_validate_synckit_key_unicode_allowed() {
390 // SDK keys are opaque — non-ASCII is fine as long as it's not a control char.
391 assert!(validate_synckit_key("café").is_ok());
392 assert!(validate_synckit_key("ユーザー1").is_ok());
393 }
394
395 #[test]
396 fn test_validate_synckit_key_oversize_uses_byte_length() {
397 // SYNC_KEY_MAX is in BYTES (key.len()), not chars. Multibyte chars eat
398 // more budget. A 100-char emoji string easily exceeds 255 bytes.
399 let many_emoji = "🦀".repeat(100); // 4 bytes per emoji → 400 bytes
400 assert!(validate_synckit_key(&many_emoji).is_err());
401 }
402
403 // ── Property-based tests (test-fuzz) ──
404
405 proptest::proptest! {
406 #[test]
407 fn prop_slug_valid_inputs_never_panic(s in "[a-z0-9\\-]{0,200}") {
408 let _ = validate_slug(&s);
409 }
410
411 #[test]
412 fn prop_slug_valid_always_accepted(s in "[a-z0-9\\-]{2,100}") {
413 // The regex doesn't ensure at least one alphanumeric char; the validator
414 // (correctly) rejects hyphen-only strings, so filter to inputs that meet
415 // both rules.
416 if s.chars().any(|c| c.is_ascii_alphanumeric()) {
417 proptest::prop_assert!(validate_slug(&s).is_ok(), "Valid slug rejected: {:?}", s);
418 }
419 }
420
421 #[test]
422 fn prop_slug_rejects_non_ascii(s in "[a-z]{2,10}\u{00e9}[a-z]{2,10}") {
423 proptest::prop_assert!(validate_slug(&s).is_err(), "Non-ASCII slug accepted: {:?}", s);
424 }
425
426 #[test]
427 fn prop_sync_table_name_valid_always_accepted(s in "[a-zA-Z_][a-zA-Z0-9_]{0,99}") {
428 proptest::prop_assert!(validate_sync_table_name(&s).is_ok(), "Valid table name rejected: {:?}", s);
429 }
430
431 #[test]
432 fn prop_blob_hash_valid_always_accepted(s in "[0-9a-f]{64}") {
433 proptest::prop_assert!(validate_sync_blob_hash(&s).is_ok(), "Valid hash rejected: {:?}", s);
434 }
435 }
436 }
437