Skip to main content

max / makenotwork

18.6 KB · 667 lines History Blame Raw
1 //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells.
2
3 /// Group thousands with commas (US locale). Returns the input string unchanged
4 /// for values ≤999. Operates on a digit-only string so callers stay in i64
5 /// arithmetic territory and don't need `f64` formatting tricks.
6 fn group_thousands(n: u64) -> String {
7 let s = n.to_string();
8 let bytes = s.as_bytes();
9 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
10 for (i, &b) in bytes.iter().enumerate() {
11 if i > 0 && (bytes.len() - i).is_multiple_of(3) {
12 out.push(',');
13 }
14 out.push(b as char);
15 }
16 out
17 }
18
19 /// Format a price in cents as a human-readable dollar string or "Free".
20 pub fn format_price(cents: impl Into<i64>) -> String {
21 let cents: i64 = cents.into();
22 if cents == 0 {
23 return "Free".to_string();
24 }
25 let neg = cents < 0;
26 let abs = cents.unsigned_abs();
27 let dollars = group_thousands(abs / 100);
28 let frac = (abs % 100) as u32;
29 let sign = if neg { "-" } else { "" };
30 if frac == 0 {
31 format!("{sign}${dollars}")
32 } else {
33 format!("{sign}${dollars}.{frac:02}")
34 }
35 }
36
37 /// Format a revenue amount in cents as a dollar string (always shows decimals).
38 ///
39 /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00".
40 pub fn format_revenue(cents: i64) -> String {
41 let neg = cents < 0;
42 let abs = cents.unsigned_abs();
43 let dollars = group_thousands(abs / 100);
44 let frac = (abs % 100) as u32;
45 let sign = if neg { "-" } else { "" };
46 format!("{sign}${dollars}.{frac:02}")
47 }
48
49 /// Format a byte count as a human-readable file size string.
50 /// Returns "N/A" for zero bytes (useful for optional file sizes).
51 pub fn format_file_size(bytes: i64) -> String {
52 if bytes == 0 {
53 return "N/A".to_string();
54 }
55 format_bytes(bytes)
56 }
57
58 /// Format a byte count as a compact human-readable string (e.g. "1.5 GB").
59 /// Returns "0 B" for zero bytes (useful for storage quota display).
60 pub fn format_bytes(bytes: i64) -> String {
61 let bytes = bytes.max(0) as u64;
62 if bytes < 1024 {
63 format!("{} B", bytes)
64 } else if bytes < 1024 * 1024 {
65 format!("{:.1} KB", bytes as f64 / 1024.0)
66 } else if bytes < 1024 * 1024 * 1024 {
67 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
68 } else {
69 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
70 }
71 }
72
73 /// Extract up to two uppercase initials from a name for avatar display.
74 pub fn get_initials(name: &str) -> String {
75 name.split_whitespace()
76 .filter_map(|word| word.chars().next())
77 .take(2)
78 .collect::<String>()
79 .to_uppercase()
80 }
81
82 /// Generate a URL-safe slug from a title.
83 ///
84 /// Returns a `Slug` via `from_trusted` — the algorithm guarantees a valid slug.
85 pub fn slugify(title: &str) -> crate::db::Slug {
86 let slug: String = title
87 .to_lowercase()
88 .chars()
89 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
90 .collect();
91 let mut result = String::new();
92 let mut prev_hyphen = true;
93 for c in slug.chars() {
94 if c == '-' {
95 if !prev_hyphen {
96 result.push('-');
97 }
98 prev_hyphen = true;
99 } else {
100 result.push(c);
101 prev_hyphen = false;
102 }
103 }
104 if result.ends_with('-') {
105 result.pop();
106 }
107 // Cap slug length to prevent unbounded output from long titles
108 if result.len() > 128 {
109 result.truncate(128);
110 // Don't leave a trailing hyphen after truncation
111 while result.ends_with('-') {
112 result.pop();
113 }
114 }
115 if result.len() < 2 {
116 result = "post".to_string();
117 }
118 crate::db::Slug::from_trusted(result)
119 }
120
121 /// Sanitize a string for use as a CSV cell value.
122 ///
123 /// Prevents CSV injection by quoting cells and escaping values that start
124 /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`).
125 /// Also handles embedded commas, quotes, and newlines per RFC 4180.
126 pub fn sanitize_csv_cell(value: &str) -> String {
127 let needs_prefix = value
128 .chars()
129 .next()
130 .map(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r'))
131 .unwrap_or(false);
132
133 let escaped = value.replace('"', "\"\"");
134
135 if needs_prefix {
136 format!("\"'{}\"", escaped)
137 } else if value.contains(',') || value.contains('"') || value.contains('\n') {
138 format!("\"{}\"", escaped)
139 } else {
140 escaped
141 }
142 }
143
144 #[cfg(test)]
145 mod tests {
146 use super::*;
147
148 // ── format_price ──
149
150 #[test]
151 fn format_price_free() {
152 assert_eq!(format_price(0), "Free");
153 }
154
155 #[test]
156 fn format_price_whole_dollars() {
157 assert_eq!(format_price(500), "$5");
158 assert_eq!(format_price(100), "$1");
159 assert_eq!(format_price(10000), "$100");
160 }
161
162 #[test]
163 fn format_price_with_cents() {
164 assert_eq!(format_price(999), "$9.99");
165 assert_eq!(format_price(150), "$1.50");
166 assert_eq!(format_price(1), "$0.01");
167 }
168
169 #[test]
170 fn format_price_negative_whole() {
171 assert_eq!(format_price(-500i64), "-$5");
172 }
173
174 #[test]
175 fn format_price_negative_with_cents() {
176 assert_eq!(format_price(-999i64), "-$9.99");
177 }
178
179 #[test]
180 fn format_price_one_cent() {
181 assert_eq!(format_price(1), "$0.01");
182 }
183
184 #[test]
185 fn format_price_99_cents() {
186 assert_eq!(format_price(99), "$0.99");
187 }
188
189 // ── format_revenue ──
190
191 #[test]
192 fn format_revenue_zero() {
193 assert_eq!(format_revenue(0), "$0.00");
194 }
195
196 #[test]
197 fn format_revenue_whole_dollars() {
198 assert_eq!(format_revenue(500), "$5.00");
199 assert_eq!(format_revenue(10000), "$100.00");
200 }
201
202 #[test]
203 fn format_revenue_with_cents() {
204 assert_eq!(format_revenue(999), "$9.99");
205 assert_eq!(format_revenue(150), "$1.50");
206 assert_eq!(format_revenue(1), "$0.01");
207 }
208
209 #[test]
210 fn format_revenue_large_amount() {
211 assert_eq!(format_revenue(1_000_000), "$10,000.00");
212 }
213
214 #[test]
215 fn format_revenue_million_dollars() {
216 assert_eq!(format_revenue(100_000_000), "$1,000,000.00");
217 }
218
219 #[test]
220 fn format_price_thousands() {
221 assert_eq!(format_price(1_234_500), "$12,345");
222 assert_eq!(format_price(1_234_567), "$12,345.67");
223 }
224
225 #[test]
226 fn format_price_negative_thousands() {
227 assert_eq!(format_price(-1_234_567i64), "-$12,345.67");
228 }
229
230 #[test]
231 fn format_revenue_negative() {
232 assert_eq!(format_revenue(-500), "-$5.00");
233 }
234
235 // ── format_file_size ──
236
237 #[test]
238 fn format_file_size_zero() {
239 assert_eq!(format_file_size(0), "N/A");
240 }
241
242 #[test]
243 fn format_file_size_bytes() {
244 assert_eq!(format_file_size(512), "512 B");
245 assert_eq!(format_file_size(1), "1 B");
246 }
247
248 #[test]
249 fn format_file_size_kilobytes() {
250 assert_eq!(format_file_size(1024), "1.0 KB");
251 assert_eq!(format_file_size(1536), "1.5 KB");
252 }
253
254 #[test]
255 fn format_file_size_megabytes() {
256 assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
257 assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
258 }
259
260 #[test]
261 fn format_file_size_gigabytes() {
262 assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
263 assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB");
264 }
265
266 // ── format_bytes ──
267
268 #[test]
269 fn format_bytes_zero() {
270 assert_eq!(format_bytes(0), "0 B");
271 }
272
273 #[test]
274 fn format_bytes_small() {
275 assert_eq!(format_bytes(512), "512 B");
276 }
277
278 #[test]
279 fn format_bytes_megabytes() {
280 assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
281 }
282
283 #[test]
284 fn format_bytes_gigabytes() {
285 assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
286 }
287
288 #[test]
289 fn format_bytes_negative_clamped() {
290 assert_eq!(format_bytes(-100), "0 B");
291 }
292
293 #[test]
294 fn format_bytes_exact_kb_boundary() {
295 assert_eq!(format_bytes(1023), "1023 B");
296 assert_eq!(format_bytes(1024), "1.0 KB");
297 }
298
299 #[test]
300 fn format_bytes_exact_mb_boundary() {
301 assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB");
302 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
303 }
304
305 #[test]
306 fn format_bytes_exact_gb_boundary() {
307 assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB");
308 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
309 }
310
311 // ── get_initials ──
312
313 #[test]
314 fn initials_two_words() {
315 assert_eq!(get_initials("John Doe"), "JD");
316 }
317
318 #[test]
319 fn initials_single_word() {
320 assert_eq!(get_initials("Alice"), "A");
321 }
322
323 #[test]
324 fn initials_three_words_takes_two() {
325 assert_eq!(get_initials("John Michael Doe"), "JM");
326 }
327
328 #[test]
329 fn initials_empty() {
330 assert_eq!(get_initials(""), "");
331 }
332
333 #[test]
334 fn initials_lowercase_uppercased() {
335 assert_eq!(get_initials("bob smith"), "BS");
336 }
337
338 #[test]
339 fn initials_extra_whitespace() {
340 assert_eq!(get_initials(" John Doe "), "JD");
341 }
342
343 #[test]
344 fn initials_unicode() {
345 assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z");
346 }
347
348 // ── slugify ──
349
350 #[test]
351 fn slugify_basic() {
352 assert_eq!(slugify("Hello World").as_str(), "hello-world");
353 }
354
355 #[test]
356 fn slugify_special_chars() {
357 assert_eq!(slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist");
358 }
359
360 #[test]
361 fn slugify_multiple_spaces() {
362 assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces");
363 }
364
365 #[test]
366 fn slugify_leading_trailing_special() {
367 assert_eq!(slugify("---hello---").as_str(), "hello");
368 }
369
370 #[test]
371 fn slugify_unicode() {
372 let slug = slugify("café résumé");
373 assert!(slug.contains("caf"));
374 assert!(!slug.contains(' '));
375 }
376
377 #[test]
378 fn slugify_too_short_falls_back() {
379 assert_eq!(slugify("a").as_str(), "post");
380 assert_eq!(slugify("").as_str(), "post");
381 assert_eq!(slugify("---").as_str(), "post");
382 }
383
384 #[test]
385 fn slugify_numbers() {
386 assert_eq!(slugify("Version 2.0").as_str(), "version-2-0");
387 }
388
389 #[test]
390 fn slugify_all_special_chars() {
391 assert_eq!(slugify("!@#$%^&*()").as_str(), "post");
392 }
393
394 #[test]
395 fn slugify_single_valid_char() {
396 assert_eq!(slugify("x").as_str(), "post");
397 }
398
399 #[test]
400 fn slugify_two_valid_chars() {
401 assert_eq!(slugify("ab").as_str(), "ab");
402 }
403
404 #[test]
405 fn slugify_mixed_unicode_and_ascii() {
406 let slug = slugify("café");
407 assert_eq!(slug.as_str(), "caf");
408 }
409
410 // ── sanitize_csv_cell ──
411
412 #[test]
413 fn csv_cell_plain_text() {
414 assert_eq!(sanitize_csv_cell("Hello World"), "Hello World");
415 }
416
417 #[test]
418 fn csv_cell_formula_prefix_equals() {
419 assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\"");
420 }
421
422 #[test]
423 fn csv_cell_formula_prefix_plus() {
424 assert_eq!(sanitize_csv_cell("+cmd|' /C calc'!A0"), "\"'+cmd|' /C calc'!A0\"");
425 }
426
427 #[test]
428 fn csv_cell_formula_prefix_minus() {
429 assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\"");
430 }
431
432 #[test]
433 fn csv_cell_formula_prefix_at() {
434 assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\"");
435 }
436
437 #[test]
438 fn csv_cell_with_comma() {
439 assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\"");
440 }
441
442 #[test]
443 fn csv_cell_with_quotes() {
444 assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\"");
445 }
446
447 #[test]
448 fn csv_cell_empty() {
449 assert_eq!(sanitize_csv_cell(""), "");
450 }
451
452 #[test]
453 fn csv_cell_with_newline() {
454 assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\"");
455 }
456
457 #[test]
458 fn csv_cell_tab_prefix() {
459 let result = sanitize_csv_cell("\tcmd");
460 assert!(result.starts_with("\"'"), "Tab prefix should be neutralized: {}", result);
461 }
462
463 #[test]
464 fn csv_cell_cr_prefix() {
465 let result = sanitize_csv_cell("\rcmd");
466 assert!(result.starts_with("\"'"), "CR prefix should be neutralized: {}", result);
467 }
468
469 // ── Edge cases (test-fuzz) ──
470
471 #[test]
472 fn format_price_negative_one_cent() {
473 assert_eq!(format_price(-1i64), "-$0.01");
474 }
475
476 #[test]
477 fn format_price_negative_whole_dollar() {
478 assert_eq!(format_price(-100i64), "-$1");
479 }
480
481 #[test]
482 fn format_price_large_value() {
483 // $1 billion in cents
484 assert_eq!(format_price(100_000_000_000i64), "$1,000,000,000");
485 }
486
487 #[test]
488 fn format_revenue_one_cent() {
489 assert_eq!(format_revenue(1), "$0.01");
490 }
491
492 #[test]
493 fn format_revenue_negative_one_cent() {
494 assert_eq!(format_revenue(-1), "-$0.01");
495 }
496
497 #[test]
498 fn format_file_size_negative() {
499 // Negative byte count: only == 0 returns "N/A"; negatives go through
500 // format_bytes which clamps to 0 via .max(0) → "0 B"
501 assert_eq!(format_file_size(-100), "0 B");
502 }
503
504 #[test]
505 fn format_file_size_one_byte() {
506 assert_eq!(format_file_size(1), "1 B");
507 }
508
509 #[test]
510 fn slugify_truncation_trailing_hyphen() {
511 // 130 chars of "a-" pattern → after truncation at 128, trailing hyphen stripped
512 let input = "a-".repeat(65); // 130 chars
513 let slug = slugify(&input);
514 assert!(slug.len() <= 128);
515 assert!(!slug.ends_with('-'), "Slug should not end with hyphen after truncation");
516 }
517
518 #[test]
519 fn slugify_emoji_input() {
520 // Emoji are non-ASCII → become hyphens → collapsed
521 let slug = slugify("\u{1f600}\u{1f600}\u{1f600}");
522 // All chars become hyphens, collapsed to nothing, falls back to "post"
523 assert_eq!(slug.as_str(), "post");
524 }
525
526 #[test]
527 fn slugify_mixed_valid_after_truncation() {
528 // 127 a's + special char → truncation shouldn't break it
529 let input = format!("{}-z", "a".repeat(127));
530 let slug = slugify(&input);
531 assert!(slug.len() <= 128);
532 assert!(slug.len() >= 2);
533 }
534
535 #[test]
536 fn csv_cell_formula_with_embedded_quotes() {
537 // Formula prefix + embedded quotes = both escapes apply
538 let result = sanitize_csv_cell("=SUM(\"A1\")");
539 assert!(result.starts_with("\"'="), "Formula prefix not neutralized: {}", result);
540 assert!(result.contains("\"\""), "Embedded quotes should be escaped: {}", result);
541 }
542
543 #[test]
544 fn csv_cell_newline_with_formula_prefix() {
545 // Newline AND formula prefix — both protections should apply
546 let result = sanitize_csv_cell("=cmd\ninjection");
547 assert!(result.starts_with("\"'="), "Formula prefix not neutralized: {}", result);
548 }
549
550 #[test]
551 fn csv_cell_very_long_value() {
552 let long = "x".repeat(100_000);
553 let result = sanitize_csv_cell(&long);
554 // Plain text, no special chars → returned as-is
555 assert_eq!(result.len(), 100_000);
556 }
557
558 #[test]
559 fn initials_emoji_name() {
560 // Emoji as first char of name — still takes 2 initials
561 let result = get_initials("\u{1f600} Robot");
562 assert_eq!(result.chars().count(), 2); // emoji char + 'R'
563 }
564
565 #[test]
566 fn initials_single_char_name() {
567 assert_eq!(get_initials("A"), "A");
568 }
569
570 // ── Adversarial ──
571
572 #[test]
573 fn adversarial_csv_injection_dde() {
574 let result = sanitize_csv_cell("=cmd|'/C calc'!A0");
575 assert!(result.starts_with("\"'="), "DDE payload not neutralized: {}", result);
576 }
577
578 #[test]
579 fn adversarial_csv_cell_null_bytes() {
580 let result = sanitize_csv_cell("hello\0world");
581 assert!(!result.is_empty());
582 }
583
584 #[test]
585 fn adversarial_slugify_xss_attempt() {
586 let slug = slugify("<script>alert('xss')</script>");
587 assert!(!slug.contains('<'));
588 assert!(!slug.contains('>'));
589 }
590
591 #[test]
592 fn adversarial_slugify_very_long_input() {
593 let long = "a".repeat(10_000);
594 let slug = slugify(&long);
595 assert!(slug.len() <= 128, "slug should be capped at 128 chars, got {}", slug.len());
596 }
597
598 #[test]
599 fn adversarial_csv_rtl_override() {
600 // Right-to-left override character — could disguise cell content
601 let result = sanitize_csv_cell("normal\u{202e}evil");
602 assert!(!result.is_empty());
603 }
604
605 #[test]
606 fn adversarial_csv_zero_width_chars() {
607 // Zero-width space and zero-width joiner
608 let result = sanitize_csv_cell("=\u{200b}SUM(A1)");
609 // Starts with '=' so formula prefix should be applied
610 assert!(result.starts_with("\"'="), "Formula prefix not applied with ZWS: {}", result);
611 }
612
613 #[test]
614 fn adversarial_slugify_path_traversal() {
615 let slug = slugify("../../../etc/passwd");
616 assert!(!slug.contains('.'));
617 assert!(!slug.contains('/'));
618 }
619
620 #[test]
621 fn adversarial_slugify_null_bytes() {
622 let slug = slugify("hello\0world");
623 assert!(!slug.contains('\0'));
624 assert!(slug.len() >= 2);
625 }
626
627 // ── Property-based tests ──
628
629 proptest::proptest! {
630 #[test]
631 fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) {
632 let result = format_price(cents);
633 proptest::prop_assert!(!result.is_empty());
634 if cents == 0 {
635 proptest::prop_assert_eq!(result, "Free");
636 } else if cents < 0 {
637 proptest::prop_assert!(result.starts_with("-$"),
638 "Negative price should start with -$: {}", result);
639 } else {
640 proptest::prop_assert!(result.starts_with('$'),
641 "Positive price should start with $: {}", result);
642 }
643 }
644
645 #[test]
646 fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) {
647 let result = format_revenue(cents);
648 proptest::prop_assert!(result.starts_with('$') || result.starts_with("-$"),
649 "Revenue should start with $ or -$: {}", result);
650 }
651
652 #[test]
653 fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) {
654 let result = format_bytes(bytes);
655 proptest::prop_assert!(!result.is_empty());
656 }
657
658 #[test]
659 fn prop_slugify_never_panics(input in ".*") {
660 let slug = slugify(&input);
661 proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
662 "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str());
663 proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str());
664 }
665 }
666 }
667