Skip to main content

max / makenotwork

24.5 KB · 666 lines History Blame Raw
1 //! Validators for user profiles, credentials, and SSH keys.
2
3 use super::limits;
4 use crate::error::AppError;
5
6 /// True if the string contains zero-width or bidirectional-override characters.
7 ///
8 /// These render invisibly (zero-width: ZWSP/ZWNJ/ZWJ/BOM/word-joiner) or reorder
9 /// surrounding text (bidi: LRE/RLE/PDF/LRO/RLO, LRI/RLI/FSI/PDI, LRM/RLM), so a
10 /// name or title carrying them can spoof another identity or hide content in
11 /// member lists and emails ("Trojan Source"-class spoofing). Rejected on the
12 /// short, display-prominent fields (names/titles).
13 pub fn contains_deceptive_unicode(s: &str) -> bool {
14 s.chars().any(|c| {
15 matches!(c,
16 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{2060}' // zero-width
17 | '\u{200E}' | '\u{200F}' // LRM/RLM
18 | '\u{202A}'..='\u{202E}' // bidi embed/override
19 | '\u{2066}'..='\u{2069}' // bidi isolates
20 )
21 })
22 }
23
24 pub fn validate_display_name(name: &str) -> Result<(), AppError> {
25 if name.chars().count() > limits::DISPLAY_NAME_MAX {
26 return Err(AppError::validation(format!(
27 "Display name must be {} characters or less",
28 limits::DISPLAY_NAME_MAX
29 )));
30 }
31 // Reject control characters (ASCII 0-31 except space, plus DEL 0x7F)
32 // to prevent social engineering in plain-text emails.
33 if name.chars().any(char::is_control) {
34 return Err(AppError::validation(
35 "Display name cannot contain control characters".to_string(),
36 ));
37 }
38 if contains_deceptive_unicode(name) {
39 return Err(AppError::validation(
40 "Display name cannot contain zero-width or bidirectional characters".to_string(),
41 ));
42 }
43 Ok(())
44 }
45
46 pub fn validate_bio(bio: &str) -> Result<(), AppError> {
47 if bio.chars().count() > limits::BIO_MAX {
48 return Err(AppError::validation(format!(
49 "Bio must be {} characters or less",
50 limits::BIO_MAX
51 )));
52 }
53 // Reject control characters (NUL, escape, etc.) while allowing normal
54 // whitespace (newlines/tabs) so multi-line bios still work. Mirrors the
55 // display-name and sync-app-name validators.
56 if bio
57 .chars()
58 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
59 {
60 return Err(AppError::validation(
61 "Bio must not contain control characters".to_string(),
62 ));
63 }
64 Ok(())
65 }
66
67 pub fn validate_link_url(url_str: &str) -> Result<(), AppError> {
68 if url_str.chars().count() > limits::LINK_URL_MAX {
69 return Err(AppError::validation(format!(
70 "URL must be {} characters or less",
71 limits::LINK_URL_MAX
72 )));
73 }
74
75 // Parse URL properly to prevent malformed/malicious URLs
76 let parsed = url::Url::parse(url_str)
77 .map_err(|_| AppError::validation("Invalid URL format".to_string()))?;
78
79 // Only allow http and https schemes
80 match parsed.scheme() {
81 "http" | "https" => {}
82 _ => {
83 return Err(AppError::validation(
84 "URL must use http:// or https://".to_string(),
85 ));
86 }
87 }
88
89 // Must have a host
90 if parsed.host_str().is_none() {
91 return Err(AppError::validation("URL must have a host".to_string()));
92 }
93
94 // Reject embedded userinfo (user:pass@host), a display-spoof vector where
95 // the real host is hidden behind credentials on public profiles.
96 if !parsed.username().is_empty() || parsed.password().is_some() {
97 return Err(AppError::validation(
98 "URL cannot contain a username or password".to_string(),
99 ));
100 }
101
102 Ok(())
103 }
104
105 pub fn validate_link_title(title: &str) -> Result<(), AppError> {
106 if title.is_empty() {
107 return Err(AppError::validation("Link title is required".to_string()));
108 }
109 if title.chars().count() > limits::LINK_TITLE_MAX {
110 return Err(AppError::validation(format!(
111 "Link title must be {} characters or less",
112 limits::LINK_TITLE_MAX
113 )));
114 }
115 if contains_deceptive_unicode(title) {
116 return Err(AppError::validation(
117 "Link title cannot contain zero-width or bidirectional characters".to_string(),
118 ));
119 }
120 super::reject_control_chars("Link title", title)?;
121 Ok(())
122 }
123
124 /// Validate a username: 3-50 chars, alphanumeric + underscore.
125 ///
126 /// Also used by the `Username` newtype's `Deserialize` impl.
127 pub fn validate_username(username: &str) -> Result<(), AppError> {
128 let len = username.chars().count();
129 if len < 3 {
130 return Err(AppError::validation(
131 "Username must be at least 3 characters".to_string(),
132 ));
133 }
134 if len > 50 {
135 return Err(AppError::validation(
136 "Username must be 50 characters or less".to_string(),
137 ));
138 }
139 if !username
140 .chars()
141 .all(|c| c.is_ascii_alphanumeric() || c == '_')
142 {
143 return Err(AppError::validation(
144 "Username can only contain letters, numbers, and underscores".to_string(),
145 ));
146 }
147 Ok(())
148 }
149
150 pub fn validate_machine_id(machine_id: &str) -> Result<(), AppError> {
151 if machine_id.is_empty() {
152 return Err(AppError::validation("Machine ID is required".to_string()));
153 }
154 if machine_id.chars().count() > limits::MACHINE_ID_MAX {
155 return Err(AppError::validation(format!(
156 "Machine ID must be {} characters or less",
157 limits::MACHINE_ID_MAX
158 )));
159 }
160 super::reject_control_chars("Machine ID", machine_id)?;
161 Ok(())
162 }
163
164 pub fn validate_activation_label(label: &str) -> Result<(), AppError> {
165 if label.chars().count() > limits::ACTIVATION_LABEL_MAX {
166 return Err(AppError::validation(format!(
167 "Label must be {} characters or less",
168 limits::ACTIVATION_LABEL_MAX
169 )));
170 }
171 // Reject control characters (parity with display_name/bio), a label with
172 // embedded newlines/escapes would corrupt logs and admin views.
173 if label.chars().any(char::is_control) {
174 return Err(AppError::validation(
175 "Label cannot contain control characters".to_string(),
176 ));
177 }
178 Ok(())
179 }
180
181 // ── SSH key validation ──
182
183 /// Accepted SSH key type prefixes.
184 const SSH_KEY_TYPES: &[&str] = &[
185 "ssh-rsa",
186 "ssh-ed25519",
187 "ecdsa-sha2-nistp256",
188 "ecdsa-sha2-nistp384",
189 "ecdsa-sha2-nistp521",
190 ];
191
192 /// Extract the RSA modulus bit length from a decoded `ssh-rsa` key blob.
193 ///
194 /// Wire format (RFC 4253): a sequence of fields, each a 4-byte big-endian
195 /// length prefix followed by that many bytes, here `string(type)`, `mpint(e)`,
196 /// `mpint(n)`. The modulus `n` is the third field; its significant byte count
197 /// (after stripping the mpint sign-padding zero) gives the bit length. Returns
198 /// `None` if the blob is truncated or malformed.
199 fn ssh_rsa_modulus_bits(decoded: &[u8]) -> Option<usize> {
200 fn read_field<'a>(buf: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
201 let len = u32::from_be_bytes(buf.get(*pos..*pos + 4)?.try_into().ok()?) as usize;
202 *pos += 4;
203 let field = buf.get(*pos..*pos + len)?;
204 *pos += len;
205 Some(field)
206 }
207 let mut pos = 0;
208 let _type = read_field(decoded, &mut pos)?;
209 let _e = read_field(decoded, &mut pos)?;
210 let n = read_field(decoded, &mut pos)?;
211 let significant_bytes = n.iter().skip_while(|&&b| b == 0).count();
212 Some(significant_bytes * 8)
213 }
214
215 /// Validate and normalize an SSH public key, returning `(normalized_key, fingerprint)`.
216 ///
217 /// - Parses the `{type} {base64} [comment]` format
218 /// - Validates the key type is one of the accepted algorithms
219 /// - Decodes the base64 data to verify it's real key data
220 /// - Rejects RSA keys weaker than 2048 bits
221 /// - Computes the fingerprint as `SHA256:{base64(sha256(decoded_key_bytes))}`
222 /// - Returns the normalized key (type + base64, no comment) and fingerprint
223 pub fn validate_ssh_public_key(input: &str) -> std::result::Result<(String, String), AppError> {
224 let input = input.trim();
225
226 if input.is_empty() {
227 return Err(AppError::validation(
228 "SSH public key is required".to_string(),
229 ));
230 }
231
232 if input.len() > 8192 {
233 return Err(AppError::validation(
234 "SSH public key is too large".to_string(),
235 ));
236 }
237
238 let parts: Vec<&str> = input.split_whitespace().collect();
239 if parts.len() < 2 {
240 return Err(AppError::validation(
241 "Invalid SSH key format: expected '{type} {base64} [comment]'".to_string(),
242 ));
243 }
244
245 let key_type = parts[0];
246 let key_data = parts[1];
247
248 if !SSH_KEY_TYPES.contains(&key_type) {
249 return Err(AppError::validation(format!(
250 "Unsupported SSH key type '{key_type}'. Accepted: ssh-rsa, ssh-ed25519, ecdsa-sha2-*"
251 )));
252 }
253
254 // Decode base64 to verify it's valid key data
255 use base64::Engine;
256 let decoded = base64::engine::general_purpose::STANDARD
257 .decode(key_data)
258 .map_err(|_| AppError::validation("Invalid SSH key: bad base64 encoding".to_string()))?;
259
260 if decoded.len() < 16 {
261 return Err(AppError::validation(
262 "Invalid SSH key: data too short".to_string(),
263 ));
264 }
265
266 // Reject weak RSA keys. A bare `decoded.len() >= 16` would accept a
267 // deliberately-small 512/768/1024-bit modulus; require >= 2048 bits
268 // (ultra-fuzz Run 10 Sec M3).
269 if key_type == "ssh-rsa" {
270 match ssh_rsa_modulus_bits(&decoded) {
271 Some(bits) if bits >= 2048 => {}
272 Some(_) => {
273 return Err(AppError::validation(
274 "RSA SSH keys must be at least 2048 bits".to_string(),
275 ));
276 }
277 None => {
278 return Err(AppError::validation(
279 "Invalid SSH key: malformed RSA key data".to_string(),
280 ));
281 }
282 }
283 }
284
285 // Compute fingerprint: SHA256:{base64(sha256(decoded))} (same as ssh-keygen -lf)
286 use sha2::Digest;
287 let hash = sha2::Sha256::digest(&decoded);
288 let fingerprint = format!(
289 "SHA256:{}",
290 base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash)
291 );
292
293 // Normalized key: type + base64 (strip comment)
294 let normalized = format!("{key_type} {key_data}");
295
296 Ok((normalized, fingerprint))
297 }
298
299 pub fn validate_ssh_key_label(label: &str) -> std::result::Result<(), AppError> {
300 if label.chars().count() > limits::SSH_KEY_LABEL_MAX {
301 return Err(AppError::validation(format!(
302 "SSH key label must be {} characters or less",
303 limits::SSH_KEY_LABEL_MAX
304 )));
305 }
306 // Reject control characters (parity with display_name/bio).
307 if label.chars().any(char::is_control) {
308 return Err(AppError::validation(
309 "SSH key label cannot contain control characters".to_string(),
310 ));
311 }
312 Ok(())
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use super::*;
318
319 #[test]
320 fn test_validate_display_name() {
321 assert!(validate_display_name("John Doe").is_ok());
322 assert!(validate_display_name("").is_ok()); // Empty is valid
323 assert!(validate_display_name(&"a".repeat(100)).is_ok());
324 assert!(validate_display_name(&"a".repeat(101)).is_err());
325 }
326
327 #[test]
328 fn test_validate_display_name_rejects_control_chars() {
329 assert!(validate_display_name("Alice\nBob").is_err()); // newline
330 assert!(validate_display_name("Alice\rBob").is_err()); // carriage return
331 assert!(validate_display_name("Alice\0Bob").is_err()); // null
332 assert!(validate_display_name("Alice\x7FBob").is_err()); // DEL
333 assert!(validate_display_name("Alice\tBob").is_err()); // tab
334 assert!(validate_display_name("Alice Bob").is_ok()); // space is fine
335 }
336
337 #[test]
338 fn test_validate_names_reject_deceptive_unicode() {
339 // Zero-width and bidi-override chars are rejected on names and titles
340 // (Run 10 UX N1), but ordinary non-ASCII (accents, CJK) is still allowed.
341 assert!(validate_display_name("Alice\u{200B}Bob").is_err()); // ZWSP
342 assert!(validate_display_name("admin\u{202E}txet").is_err()); // RLO
343 assert!(validate_display_name("\u{FEFF}Mallory").is_err()); // BOM
344 assert!(validate_display_name("José Ñoño").is_ok()); // accents fine
345 assert!(validate_display_name("名前").is_ok()); // CJK fine
346 assert!(validate_link_title("My Site\u{200D}").is_err()); // ZWJ
347 assert!(validate_link_title("My Portfolio").is_ok());
348 assert!(!contains_deceptive_unicode("normal text"));
349 }
350
351 #[test]
352 fn test_activation_and_ssh_labels_reject_control_chars() {
353 assert!(validate_activation_label("Living Room").is_ok());
354 assert!(validate_activation_label("Living\nRoom").is_err());
355 assert!(validate_activation_label("tab\there").is_err());
356 assert!(validate_ssh_key_label("laptop").is_ok());
357 assert!(validate_ssh_key_label("laptop\0").is_err());
358 assert!(validate_ssh_key_label("esc\u{1b}ape").is_err());
359 }
360
361 #[test]
362 fn test_validate_bio() {
363 assert!(validate_bio("I make music").is_ok());
364 assert!(validate_bio("").is_ok());
365 assert!(validate_bio(&"a".repeat(2001)).is_err());
366 // Newlines/tabs are fine (multi-line bios); other control chars aren't.
367 assert!(validate_bio("line one\nline two\ttabbed").is_ok());
368 assert!(validate_bio("evil\u{0}nul").is_err());
369 assert!(validate_bio("esc\u{1b}ape").is_err());
370 }
371
372 #[test]
373 fn test_validate_link_url() {
374 assert!(validate_link_url("https://example.com").is_ok());
375 assert!(validate_link_url("http://example.com").is_ok());
376 assert!(validate_link_url("https://example.com/path?query=1").is_ok());
377 assert!(validate_link_url("ftp://example.com").is_err());
378 assert!(validate_link_url("example.com").is_err());
379 assert!(validate_link_url("javascript:alert(1)").is_err());
380 assert!(validate_link_url("data:text/html,<script>alert(1)</script>").is_err());
381 }
382
383 #[test]
384 fn test_validate_link_title() {
385 assert!(validate_link_title("My Website").is_ok());
386 assert!(validate_link_title("X").is_ok()); // single char is valid
387 assert!(validate_link_title("").is_err()); // empty
388 assert!(validate_link_title(&"a".repeat(100)).is_ok()); // at limit
389 assert!(validate_link_title(&"a".repeat(101)).is_err()); // over limit
390 }
391
392 #[test]
393 fn test_validate_machine_id() {
394 assert!(validate_machine_id("hw-abc123").is_ok());
395 assert!(validate_machine_id("a").is_ok()); // single char
396 assert!(validate_machine_id("").is_err()); // empty
397 assert!(validate_machine_id(&"a".repeat(255)).is_ok()); // at limit
398 assert!(validate_machine_id(&"a".repeat(256)).is_err()); // over limit
399 }
400
401 #[test]
402 fn test_validate_activation_label() {
403 assert!(validate_activation_label("Max's laptop").is_ok());
404 assert!(validate_activation_label("").is_ok()); // empty is valid
405 assert!(validate_activation_label(&"a".repeat(100)).is_ok()); // at limit
406 assert!(validate_activation_label(&"a".repeat(101)).is_err()); // over limit
407 }
408
409 #[test]
410 fn test_validate_ssh_public_key_ed25519() {
411 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq test@example.com";
412 let result = validate_ssh_public_key(key);
413 assert!(result.is_ok(), "ed25519 key should be valid: {result:?}");
414 let (normalized, fingerprint) = result.unwrap();
415 // Should strip comment
416 assert!(!normalized.contains("test@example.com"));
417 assert!(normalized.starts_with("ssh-ed25519 "));
418 // Fingerprint should be SHA256:...
419 assert!(
420 fingerprint.starts_with("SHA256:"),
421 "Fingerprint: {fingerprint}"
422 );
423 }
424
425 #[test]
426 fn test_validate_ssh_public_key_rejects_empty() {
427 assert!(validate_ssh_public_key("").is_err());
428 }
429
430 #[test]
431 fn test_validate_ssh_public_key_rejects_garbage() {
432 assert!(validate_ssh_public_key("not a key").is_err());
433 }
434
435 #[test]
436 fn test_validate_ssh_public_key_rejects_bad_type() {
437 assert!(validate_ssh_public_key("ssh-dss AAAAB3NzaC1kc3MAAAA").is_err());
438 }
439
440 #[test]
441 fn test_validate_ssh_public_key_rejects_bad_base64() {
442 assert!(validate_ssh_public_key("ssh-ed25519 not-valid-base64!!!").is_err());
443 }
444
445 #[test]
446 fn test_validate_ssh_public_key_same_key_same_fingerprint() {
447 let key_with_comment = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq comment";
448 let key_without_comment =
449 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq";
450 let (_, fp1) = validate_ssh_public_key(key_with_comment).unwrap();
451 let (_, fp2) = validate_ssh_public_key(key_without_comment).unwrap();
452 assert_eq!(fp1, fp2, "Same key data should produce same fingerprint");
453 }
454
455 /// Build a minimal `ssh-rsa` wire blob with a modulus of `n_bytes` bytes
456 /// (high bit clear, so no mpint sign byte is prepended).
457 fn build_ssh_rsa_blob(n_bytes: usize) -> Vec<u8> {
458 fn push_field(blob: &mut Vec<u8>, data: &[u8]) {
459 blob.extend_from_slice(&(data.len() as u32).to_be_bytes());
460 blob.extend_from_slice(data);
461 }
462 let mut blob = Vec::new();
463 push_field(&mut blob, b"ssh-rsa");
464 push_field(&mut blob, &[0x01, 0x00, 0x01]); // e = 65537
465 push_field(&mut blob, &vec![0x7f; n_bytes]); // modulus n
466 blob
467 }
468
469 #[test]
470 fn ssh_rsa_modulus_bits_reads_length() {
471 assert_eq!(ssh_rsa_modulus_bits(&build_ssh_rsa_blob(256)), Some(2048));
472 assert_eq!(ssh_rsa_modulus_bits(&build_ssh_rsa_blob(128)), Some(1024));
473 assert_eq!(ssh_rsa_modulus_bits(&[1, 2, 3]), None); // truncated blob
474 }
475
476 #[test]
477 fn weak_rsa_key_rejected_strong_accepted() {
478 use base64::Engine;
479 let enc = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
480 let weak = format!("ssh-rsa {} test@host", enc(&build_ssh_rsa_blob(128))); // 1024-bit
481 assert!(
482 validate_ssh_public_key(&weak).is_err(),
483 "1024-bit RSA must be rejected"
484 );
485 let strong = format!("ssh-rsa {} test@host", enc(&build_ssh_rsa_blob(256))); // 2048-bit
486 assert!(
487 validate_ssh_public_key(&strong).is_ok(),
488 "2048-bit RSA must be accepted"
489 );
490 }
491
492 #[test]
493 fn test_validate_ssh_key_label() {
494 assert!(validate_ssh_key_label("").is_ok()); // empty is valid
495 assert!(validate_ssh_key_label("laptop").is_ok());
496 assert!(validate_ssh_key_label(&"a".repeat(128)).is_ok()); // at limit
497 assert!(validate_ssh_key_label(&"a".repeat(129)).is_err()); // over limit
498 }
499
500 #[test]
501 fn test_multibyte_display_name() {
502 // CJK characters are 3 bytes each in UTF-8, but should count as 1 character
503 let cjk_at_limit: String = "\u{4e16}".repeat(100); // 100 chars
504 assert_eq!(cjk_at_limit.len(), 300); // 300 bytes
505 assert_eq!(cjk_at_limit.chars().count(), 100); // 100 characters
506 assert!(validate_display_name(&cjk_at_limit).is_ok());
507
508 let cjk_over_limit: String = "\u{4e16}".repeat(101);
509 assert_eq!(cjk_over_limit.chars().count(), 101);
510 assert!(validate_display_name(&cjk_over_limit).is_err());
511
512 let three_cjk = "\u{4e16}\u{754c}\u{597d}";
513 assert_eq!(three_cjk.len(), 9); // 9 bytes
514 assert_eq!(three_cjk.chars().count(), 3); // 3 characters
515 assert!(validate_display_name(three_cjk).is_ok());
516 }
517
518 // ── Edge cases (test-fuzz) ──
519
520 #[test]
521 fn test_validate_link_url_internal_ip() {
522 // Internal IPs are technically valid HTTP URLs, no SSRF protection at validation level
523 // (SSRF protection is at the request layer, not validation)
524 assert!(validate_link_url("http://127.0.0.1").is_ok());
525 assert!(validate_link_url("http://192.168.1.1").is_ok());
526 assert!(validate_link_url("http://10.0.0.1").is_ok());
527 }
528
529 #[test]
530 fn test_validate_link_url_with_port() {
531 assert!(validate_link_url("https://example.com:8080/path").is_ok());
532 }
533
534 #[test]
535 fn test_validate_link_url_with_auth() {
536 // URLs with userinfo (user:pass@host) are rejected, a display-spoof
537 // vector where the real host hides behind credentials on public profiles.
538 assert!(validate_link_url("https://user:pass@example.com").is_err());
539 assert!(validate_link_url("https://user@example.com").is_err());
540 // Legitimate URLs without userinfo still pass.
541 assert!(validate_link_url("https://example.com").is_ok());
542 assert!(validate_link_url("https://example.com/path?q=1").is_ok());
543 }
544
545 #[test]
546 fn test_validate_link_url_file_scheme() {
547 assert!(validate_link_url("file:///etc/passwd").is_err());
548 }
549
550 #[test]
551 fn test_validate_ssh_key_too_large() {
552 let big_key = format!("ssh-ed25519 {} comment", "A".repeat(8193));
553 assert!(validate_ssh_public_key(&big_key).is_err());
554 }
555
556 #[test]
557 fn test_validate_ssh_key_whitespace_only() {
558 assert!(validate_ssh_public_key(" ").is_err());
559 }
560
561 #[test]
562 fn test_validate_username_all_underscores() {
563 assert!(validate_username("___").is_ok()); // 3 underscores is technically valid
564 }
565
566 #[test]
567 fn test_validate_username_all_numbers() {
568 assert!(validate_username("123").is_ok());
569 }
570
571 #[test]
572 fn test_validate_username_unicode_rejected() {
573 assert!(validate_username("\u{00e9}mile").is_err()); // non-ASCII
574 }
575
576 // ── Adversarial tests (test-fuzz) ──
577
578 #[test]
579 fn test_validate_username_null_bytes() {
580 assert!(validate_username("use\0r").is_err());
581 }
582
583 #[test]
584 fn test_validate_username_zero_width() {
585 assert!(validate_username("use\u{200B}r").is_err()); // zero-width space
586 }
587
588 #[test]
589 fn test_validate_username_at_boundaries() {
590 assert!(validate_username("ab").is_err()); // 2 chars, min is 3
591 assert!(validate_username("abc").is_ok()); // exactly 3
592 assert!(validate_username(&"a".repeat(50)).is_ok()); // exactly 50
593 assert!(validate_username(&"a".repeat(51)).is_err()); // 51
594 }
595
596 #[test]
597 fn test_validate_username_hyphen_rejected() {
598 // Hyphens are NOT allowed in usernames (only slugs)
599 assert!(validate_username("my-user").is_err());
600 }
601
602 #[test]
603 fn test_validate_link_url_javascript_variations() {
604 assert!(validate_link_url("javascript:alert(1)").is_err());
605 // url::Url::parse should reject these too
606 assert!(validate_link_url("JAVASCRIPT:alert(1)").is_err());
607 assert!(validate_link_url("jAvAsCrIpT:alert(1)").is_err());
608 }
609
610 #[test]
611 fn test_validate_link_url_at_max_length() {
612 let long_url = format!("https://example.com/{}", "a".repeat(475));
613 assert!(long_url.chars().count() <= 500);
614 assert!(validate_link_url(&long_url).is_ok());
615
616 let too_long = format!("https://example.com/{}", "a".repeat(481));
617 assert!(too_long.chars().count() > 500);
618 assert!(validate_link_url(&too_long).is_err());
619 }
620
621 #[test]
622 fn test_validate_link_url_no_host() {
623 // Scheme-only URLs (no host)
624 assert!(validate_link_url("http://").is_err());
625 assert!(validate_link_url("https://").is_err());
626 }
627
628 #[test]
629 fn test_validate_ssh_key_with_multiple_spaces() {
630 // Split_whitespace handles multiple spaces between parts
631 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq comment";
632 assert!(validate_ssh_public_key(key).is_ok());
633 }
634
635 #[test]
636 fn test_validate_ssh_key_with_tabs() {
637 let key =
638 "ssh-ed25519\tAAAAC3NzaC1lZDI1NTE5AAAAIGrJSsFMsNzFqLOsNjMoVMtQ3fMM4JhPmLPWVOmBsBzq";
639 assert!(validate_ssh_public_key(key).is_ok());
640 }
641
642 // ── Property-based tests (test-fuzz) ──
643
644 proptest::proptest! {
645 #[test]
646 fn prop_username_valid_always_accepted(s in "[a-zA-Z0-9_]{3,50}") {
647 proptest::prop_assert!(validate_username(&s).is_ok(), "Valid username rejected: {:?}", s);
648 }
649
650 #[test]
651 fn prop_username_short_always_rejected(s in "[a-zA-Z0-9_]{1,2}") {
652 proptest::prop_assert!(validate_username(&s).is_err(), "Short username accepted: {:?}", s);
653 }
654
655 #[test]
656 fn prop_display_name_never_panics(s in "\\PC{0,200}") {
657 let _ = validate_display_name(&s);
658 }
659
660 #[test]
661 fn prop_bio_never_panics(s in "\\PC{0,3000}") {
662 let _ = validate_bio(&s);
663 }
664 }
665 }
666