Skip to main content

max / makenotwork

27.1 KB · 880 lines History Blame Raw
1 //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells.
2 use std::fmt::Write as _;
3
4 /// Group thousands with commas (US locale). Returns the input string unchanged
5 /// for values ≤999. Operates on a digit-only string so callers stay in i64
6 /// arithmetic territory and don't need `f64` formatting tricks.
7 fn group_thousands(n: u64) -> String {
8 let s = n.to_string();
9 let bytes = s.as_bytes();
10 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
11 for (i, &b) in bytes.iter().enumerate() {
12 if i > 0 && (bytes.len() - i).is_multiple_of(3) {
13 out.push(',');
14 }
15 out.push(b as char);
16 }
17 out
18 }
19
20 /// Format a price in cents as a human-readable dollar string or "Free".
21 pub fn format_price(cents: impl Into<i64>) -> String {
22 let cents: i64 = cents.into();
23 if cents == 0 {
24 return "Free".to_string();
25 }
26 let neg = cents < 0;
27 let abs = cents.unsigned_abs();
28 let dollars = group_thousands(abs / 100);
29 let frac = (abs % 100) as u32;
30 let sign = if neg { "-" } else { "" };
31 if frac == 0 {
32 format!("{sign}${dollars}")
33 } else {
34 format!("{sign}${dollars}.{frac:02}")
35 }
36 }
37
38 /// Format a revenue amount in cents as a dollar string (always shows decimals).
39 ///
40 /// Unlike [`format_price`], this never returns "Free": zero revenue is "$0.00".
41 pub fn format_revenue(cents: i64) -> String {
42 let neg = cents < 0;
43 let abs = cents.unsigned_abs();
44 let dollars = group_thousands(abs / 100);
45 let frac = (abs % 100) as u32;
46 let sign = if neg { "-" } else { "" };
47 format!("{sign}${dollars}.{frac:02}")
48 }
49
50 /// Format a price in cents as a plain decimal string: no currency symbol, no
51 /// thousands separators, always two decimal places (e.g. "9.99", "1234.50").
52 ///
53 /// For CSV cells and form `value=` attributes where a bare numeric is required
54 /// and the surrounding context (spreadsheet column, template `$` prefix)
55 /// supplies its own framing. For human-facing display use [`format_price`]
56 /// (shows "Free" / drops trailing `.00`) or [`format_revenue`] (always `$X.XX`).
57 ///
58 /// This is the canonical cents→decimal conversion: bypassing it with a raw
59 /// `as f64 / 100.0` is what the pricing-format drift chronic keeps re-finding,
60 /// so a grep-enforcing test (below) bans that idiom outside this module.
61 pub fn format_dollars_plain(cents: impl Into<i64>) -> String {
62 let cents: i64 = cents.into();
63 let neg = cents < 0;
64 let abs = cents.unsigned_abs();
65 let sign = if neg { "-" } else { "" };
66 format!("{sign}{}.{:02}", abs / 100, (abs % 100) as u32)
67 }
68
69 /// Format a byte count as a human-readable file size string.
70 /// Returns "N/A" for zero bytes (useful for optional file sizes).
71 pub fn format_file_size(bytes: i64) -> String {
72 if bytes == 0 {
73 return "N/A".to_string();
74 }
75 format_bytes(bytes)
76 }
77
78 /// Format a byte count as a compact human-readable string (e.g. "1.5 GB").
79 /// Returns "0 B" for zero bytes (useful for storage quota display).
80 pub fn format_bytes(bytes: i64) -> String {
81 let bytes = bytes.max(0) as u64;
82 if bytes < 1024 {
83 format!("{bytes} B")
84 } else if bytes < 1024 * 1024 {
85 format!("{:.1} KB", bytes as f64 / 1024.0)
86 } else if bytes < 1024 * 1024 * 1024 {
87 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
88 } else {
89 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
90 }
91 }
92
93 /// Extract up to two uppercase initials from a name for avatar display.
94 pub fn get_initials(name: &str) -> String {
95 name.split_whitespace()
96 .filter_map(|word| word.chars().next())
97 .take(2)
98 .collect::<String>()
99 .to_uppercase()
100 }
101
102 /// Generate a URL-safe slug from a title.
103 ///
104 /// Returns a `Slug` via `from_trusted`, the algorithm guarantees a valid slug.
105 pub fn slugify(title: &str) -> crate::db::Slug {
106 let slug: String = title
107 .to_lowercase()
108 .chars()
109 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
110 .collect();
111 let mut result = String::new();
112 let mut prev_hyphen = true;
113 for c in slug.chars() {
114 if c == '-' {
115 if !prev_hyphen {
116 result.push('-');
117 }
118 prev_hyphen = true;
119 } else {
120 result.push(c);
121 prev_hyphen = false;
122 }
123 }
124 if result.ends_with('-') {
125 result.pop();
126 }
127 // Cap slug length to prevent unbounded output from long titles
128 if result.len() > 128 {
129 result.truncate(128);
130 // Don't leave a trailing hyphen after truncation
131 while result.ends_with('-') {
132 result.pop();
133 }
134 }
135 if result.len() < 2 {
136 // The ASCII-only pass kept nothing usable, typically an all-non-Latin
137 // title (CJK/Cyrillic/Arabic/emoji). A constant "post" fallback made
138 // every such title collide on one slug, so a creator working in a
139 // non-Latin script hit a unique-violation on their second section and
140 // every blog post URL became `/post`. Derive a stable suffix from the
141 // title content so distinct titles get distinct slugs (hash-fallback;
142 // no transliteration dependency).
143 result = format!("post-{}", title_hash_suffix(title));
144 }
145 crate::db::Slug::from_trusted(result)
146 }
147
148 /// Short, stable, URL-safe suffix derived from a title's content. Used as the
149 /// slug fallback when ASCII slugification yields nothing usable, so two
150 /// different non-Latin titles produce two different slugs instead of colliding
151 /// on a constant. Deterministic: the same title always yields the same suffix.
152 fn title_hash_suffix(title: &str) -> String {
153 use sha2::{Digest, Sha256};
154 let digest = Sha256::digest(title.trim().as_bytes());
155 // 4 bytes -> 8 lowercase hex chars; ample to separate a creator's titles,
156 // and the section/blog dedup loop handles the astronomically rare clash.
157 let mut suffix = String::with_capacity(8);
158 for b in &digest[..4] {
159 write!(suffix, "{b:02x}").unwrap();
160 }
161 suffix
162 }
163
164 /// Sanitize a string for use as a CSV cell value.
165 ///
166 /// Prevents CSV injection by quoting cells and escaping values that start
167 /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`).
168 /// Also handles embedded commas, quotes, and newlines per RFC 4180.
169 pub fn sanitize_csv_cell(value: &str) -> String {
170 let needs_prefix = value
171 .chars()
172 .next()
173 .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r'));
174
175 let escaped = value.replace('"', "\"\"");
176
177 if needs_prefix {
178 format!("\"'{escaped}\"")
179 } else if value.contains(',') || value.contains('"') || value.contains('\n') {
180 format!("\"{escaped}\"")
181 } else {
182 escaped
183 }
184 }
185
186 #[cfg(test)]
187 mod tests {
188 use super::*;
189
190 // ── format_dollars_plain ──
191
192 #[test]
193 fn dollars_plain_basic() {
194 assert_eq!(format_dollars_plain(0), "0.00");
195 assert_eq!(format_dollars_plain(999), "9.99");
196 assert_eq!(format_dollars_plain(100), "1.00");
197 assert_eq!(format_dollars_plain(1), "0.01");
198 }
199
200 #[test]
201 fn dollars_plain_no_thousands_separator() {
202 // CSV cells and form values must stay machine-parseable, no commas.
203 assert_eq!(format_dollars_plain(123_456), "1234.56");
204 assert_eq!(format_dollars_plain(1_000_000), "10000.00");
205 }
206
207 #[test]
208 fn dollars_plain_negative() {
209 assert_eq!(format_dollars_plain(-999), "-9.99");
210 assert_eq!(format_dollars_plain(-5), "-0.05");
211 }
212
213 // ── format_price ──
214
215 #[test]
216 fn format_price_free() {
217 assert_eq!(format_price(0), "Free");
218 }
219
220 #[test]
221 fn format_price_whole_dollars() {
222 assert_eq!(format_price(500), "$5");
223 assert_eq!(format_price(100), "$1");
224 assert_eq!(format_price(10000), "$100");
225 }
226
227 #[test]
228 fn format_price_with_cents() {
229 assert_eq!(format_price(999), "$9.99");
230 assert_eq!(format_price(150), "$1.50");
231 assert_eq!(format_price(1), "$0.01");
232 }
233
234 #[test]
235 fn format_price_negative_whole() {
236 assert_eq!(format_price(-500i64), "-$5");
237 }
238
239 #[test]
240 fn format_price_negative_with_cents() {
241 assert_eq!(format_price(-999i64), "-$9.99");
242 }
243
244 #[test]
245 fn format_price_one_cent() {
246 assert_eq!(format_price(1), "$0.01");
247 }
248
249 #[test]
250 fn format_price_99_cents() {
251 assert_eq!(format_price(99), "$0.99");
252 }
253
254 // ── format_revenue ──
255
256 #[test]
257 fn format_revenue_zero() {
258 assert_eq!(format_revenue(0), "$0.00");
259 }
260
261 #[test]
262 fn format_revenue_whole_dollars() {
263 assert_eq!(format_revenue(500), "$5.00");
264 assert_eq!(format_revenue(10000), "$100.00");
265 }
266
267 #[test]
268 fn format_revenue_with_cents() {
269 assert_eq!(format_revenue(999), "$9.99");
270 assert_eq!(format_revenue(150), "$1.50");
271 assert_eq!(format_revenue(1), "$0.01");
272 }
273
274 #[test]
275 fn format_revenue_large_amount() {
276 assert_eq!(format_revenue(1_000_000), "$10,000.00");
277 }
278
279 #[test]
280 fn format_revenue_million_dollars() {
281 assert_eq!(format_revenue(100_000_000), "$1,000,000.00");
282 }
283
284 #[test]
285 fn format_price_thousands() {
286 assert_eq!(format_price(1_234_500), "$12,345");
287 assert_eq!(format_price(1_234_567), "$12,345.67");
288 }
289
290 #[test]
291 fn format_price_negative_thousands() {
292 assert_eq!(format_price(-1_234_567i64), "-$12,345.67");
293 }
294
295 #[test]
296 fn format_revenue_negative() {
297 assert_eq!(format_revenue(-500), "-$5.00");
298 }
299
300 // ── format_file_size ──
301
302 #[test]
303 fn format_file_size_zero() {
304 assert_eq!(format_file_size(0), "N/A");
305 }
306
307 #[test]
308 fn format_file_size_bytes() {
309 assert_eq!(format_file_size(512), "512 B");
310 assert_eq!(format_file_size(1), "1 B");
311 }
312
313 #[test]
314 fn format_file_size_kilobytes() {
315 assert_eq!(format_file_size(1024), "1.0 KB");
316 assert_eq!(format_file_size(1536), "1.5 KB");
317 }
318
319 #[test]
320 fn format_file_size_megabytes() {
321 assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
322 assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
323 }
324
325 #[test]
326 fn format_file_size_gigabytes() {
327 assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
328 assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB");
329 }
330
331 // ── format_bytes ──
332
333 #[test]
334 fn format_bytes_zero() {
335 assert_eq!(format_bytes(0), "0 B");
336 }
337
338 #[test]
339 fn format_bytes_small() {
340 assert_eq!(format_bytes(512), "512 B");
341 }
342
343 #[test]
344 fn format_bytes_megabytes() {
345 assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
346 }
347
348 #[test]
349 fn format_bytes_gigabytes() {
350 assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
351 }
352
353 #[test]
354 fn format_bytes_negative_clamped() {
355 assert_eq!(format_bytes(-100), "0 B");
356 }
357
358 #[test]
359 fn format_bytes_exact_kb_boundary() {
360 assert_eq!(format_bytes(1023), "1023 B");
361 assert_eq!(format_bytes(1024), "1.0 KB");
362 }
363
364 #[test]
365 fn format_bytes_exact_mb_boundary() {
366 assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB");
367 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
368 }
369
370 #[test]
371 fn format_bytes_exact_gb_boundary() {
372 assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB");
373 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
374 }
375
376 // ── get_initials ──
377
378 #[test]
379 fn initials_two_words() {
380 assert_eq!(get_initials("John Doe"), "JD");
381 }
382
383 #[test]
384 fn initials_single_word() {
385 assert_eq!(get_initials("Alice"), "A");
386 }
387
388 #[test]
389 fn initials_three_words_takes_two() {
390 assert_eq!(get_initials("John Michael Doe"), "JM");
391 }
392
393 #[test]
394 fn initials_empty() {
395 assert_eq!(get_initials(""), "");
396 }
397
398 #[test]
399 fn initials_lowercase_uppercased() {
400 assert_eq!(get_initials("bob smith"), "BS");
401 }
402
403 #[test]
404 fn initials_extra_whitespace() {
405 assert_eq!(get_initials(" John Doe "), "JD");
406 }
407
408 #[test]
409 fn initials_unicode() {
410 assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z");
411 }
412
413 // ── slugify ──
414
415 #[test]
416 fn slugify_basic() {
417 assert_eq!(slugify("Hello World").as_str(), "hello-world");
418 }
419
420 #[test]
421 fn slugify_special_chars() {
422 assert_eq!(
423 slugify("My Song (feat. Artist)").as_str(),
424 "my-song-feat-artist"
425 );
426 }
427
428 #[test]
429 fn slugify_multiple_spaces() {
430 assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces");
431 }
432
433 #[test]
434 fn slugify_leading_trailing_special() {
435 assert_eq!(slugify("---hello---").as_str(), "hello");
436 }
437
438 #[test]
439 fn slugify_unicode() {
440 let slug = slugify("café résumé");
441 assert!(slug.contains("caf"));
442 assert!(!slug.contains(' '));
443 }
444
445 #[test]
446 fn slugify_too_short_falls_back() {
447 // Fallback is now `post-<hash>` (was a constant "post"): still a valid
448 // slug, deterministic per input, and distinct across inputs.
449 for input in ["a", "", "---"] {
450 let s = slugify(input);
451 assert!(s.as_str().starts_with("post-"), "got {}", s.as_str());
452 assert!(crate::validation::validate_slug(s.as_str()).is_ok());
453 // Deterministic.
454 assert_eq!(slugify(input).as_str(), s.as_str());
455 }
456 // Distinct inputs -> distinct fallback slugs (the bug this fixes).
457 assert_ne!(slugify("a").as_str(), slugify("---").as_str());
458 }
459
460 #[test]
461 fn slugify_non_latin_titles_are_distinct() {
462 // The whole point of the hash-fallback: two different non-Latin titles
463 // must not collide on one slug (which caused a unique-violation 500 for
464 // the second section a non-Latin creator added).
465 let a = slugify("日本語の記事");
466 let b = slugify("Статья на русском");
467 assert!(a.as_str().starts_with("post-"));
468 assert!(b.as_str().starts_with("post-"));
469 assert_ne!(a.as_str(), b.as_str());
470 assert!(crate::validation::validate_slug(a.as_str()).is_ok());
471 assert!(crate::validation::validate_slug(b.as_str()).is_ok());
472 }
473
474 #[test]
475 fn slugify_numbers() {
476 assert_eq!(slugify("Version 2.0").as_str(), "version-2-0");
477 }
478
479 #[test]
480 fn slugify_all_special_chars() {
481 let s = slugify("!@#$%^&*()");
482 assert!(s.as_str().starts_with("post-"));
483 assert!(crate::validation::validate_slug(s.as_str()).is_ok());
484 }
485
486 #[test]
487 fn slugify_single_valid_char() {
488 let s = slugify("x");
489 assert!(s.as_str().starts_with("post-"));
490 assert!(crate::validation::validate_slug(s.as_str()).is_ok());
491 }
492
493 #[test]
494 fn slugify_two_valid_chars() {
495 assert_eq!(slugify("ab").as_str(), "ab");
496 }
497
498 #[test]
499 fn slugify_mixed_unicode_and_ascii() {
500 let slug = slugify("café");
501 assert_eq!(slug.as_str(), "caf");
502 }
503
504 // ── sanitize_csv_cell ──
505
506 #[test]
507 fn csv_cell_plain_text() {
508 assert_eq!(sanitize_csv_cell("Hello World"), "Hello World");
509 }
510
511 #[test]
512 fn csv_cell_formula_prefix_equals() {
513 assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\"");
514 }
515
516 #[test]
517 fn csv_cell_formula_prefix_plus() {
518 assert_eq!(
519 sanitize_csv_cell("+cmd|' /C calc'!A0"),
520 "\"'+cmd|' /C calc'!A0\""
521 );
522 }
523
524 #[test]
525 fn csv_cell_formula_prefix_minus() {
526 assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\"");
527 }
528
529 #[test]
530 fn csv_cell_formula_prefix_at() {
531 assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\"");
532 }
533
534 #[test]
535 fn csv_cell_with_comma() {
536 assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\"");
537 }
538
539 #[test]
540 fn csv_cell_with_quotes() {
541 assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\"");
542 }
543
544 #[test]
545 fn csv_cell_empty() {
546 assert_eq!(sanitize_csv_cell(""), "");
547 }
548
549 #[test]
550 fn csv_cell_with_newline() {
551 assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\"");
552 }
553
554 #[test]
555 fn csv_cell_tab_prefix() {
556 let result = sanitize_csv_cell("\tcmd");
557 assert!(
558 result.starts_with("\"'"),
559 "Tab prefix should be neutralized: {result}"
560 );
561 }
562
563 #[test]
564 fn csv_cell_cr_prefix() {
565 let result = sanitize_csv_cell("\rcmd");
566 assert!(
567 result.starts_with("\"'"),
568 "CR prefix should be neutralized: {result}"
569 );
570 }
571
572 // ── Edge cases (test-fuzz) ──
573
574 #[test]
575 fn format_price_negative_one_cent() {
576 assert_eq!(format_price(-1i64), "-$0.01");
577 }
578
579 #[test]
580 fn format_price_negative_whole_dollar() {
581 assert_eq!(format_price(-100i64), "-$1");
582 }
583
584 #[test]
585 fn format_price_large_value() {
586 // $1 billion in cents
587 assert_eq!(format_price(100_000_000_000i64), "$1,000,000,000");
588 }
589
590 #[test]
591 fn format_revenue_one_cent() {
592 assert_eq!(format_revenue(1), "$0.01");
593 }
594
595 #[test]
596 fn format_revenue_negative_one_cent() {
597 assert_eq!(format_revenue(-1), "-$0.01");
598 }
599
600 #[test]
601 fn format_file_size_negative() {
602 // Negative byte count: only == 0 returns "N/A"; negatives go through
603 // format_bytes which clamps to 0 via .max(0) → "0 B"
604 assert_eq!(format_file_size(-100), "0 B");
605 }
606
607 #[test]
608 fn format_file_size_one_byte() {
609 assert_eq!(format_file_size(1), "1 B");
610 }
611
612 #[test]
613 fn slugify_truncation_trailing_hyphen() {
614 // 130 chars of "a-" pattern → after truncation at 128, trailing hyphen stripped
615 let input = "a-".repeat(65); // 130 chars
616 let slug = slugify(&input);
617 assert!(slug.len() <= 128);
618 assert!(
619 !slug.ends_with('-'),
620 "Slug should not end with hyphen after truncation"
621 );
622 }
623
624 #[test]
625 fn slugify_emoji_input() {
626 // Emoji are non-ASCII → become hyphens → collapsed to nothing, so the
627 // hash-fallback kicks in (was a constant "post").
628 let slug = slugify("\u{1f600}\u{1f600}\u{1f600}");
629 assert!(slug.as_str().starts_with("post-"));
630 assert!(crate::validation::validate_slug(slug.as_str()).is_ok());
631 }
632
633 #[test]
634 fn slugify_mixed_valid_after_truncation() {
635 // 127 a's + special char → truncation shouldn't break it
636 let input = format!("{}-z", "a".repeat(127));
637 let slug = slugify(&input);
638 assert!(slug.len() <= 128);
639 assert!(slug.len() >= 2);
640 }
641
642 #[test]
643 fn csv_cell_formula_with_embedded_quotes() {
644 // Formula prefix + embedded quotes = both escapes apply
645 let result = sanitize_csv_cell("=SUM(\"A1\")");
646 assert!(
647 result.starts_with("\"'="),
648 "Formula prefix not neutralized: {result}"
649 );
650 assert!(
651 result.contains("\"\""),
652 "Embedded quotes should be escaped: {result}"
653 );
654 }
655
656 #[test]
657 fn csv_cell_newline_with_formula_prefix() {
658 // Newline AND formula prefix, both protections should apply
659 let result = sanitize_csv_cell("=cmd\ninjection");
660 assert!(
661 result.starts_with("\"'="),
662 "Formula prefix not neutralized: {result}"
663 );
664 }
665
666 #[test]
667 fn csv_cell_very_long_value() {
668 let long = "x".repeat(100_000);
669 let result = sanitize_csv_cell(&long);
670 // Plain text, no special chars → returned as-is
671 assert_eq!(result.len(), 100_000);
672 }
673
674 #[test]
675 fn initials_emoji_name() {
676 // Emoji as first char of name, still takes 2 initials
677 let result = get_initials("\u{1f600} Robot");
678 assert_eq!(result.chars().count(), 2); // emoji char + 'R'
679 }
680
681 #[test]
682 fn initials_single_char_name() {
683 assert_eq!(get_initials("A"), "A");
684 }
685
686 // ── Adversarial ──
687
688 #[test]
689 fn adversarial_csv_injection_dde() {
690 let result = sanitize_csv_cell("=cmd|'/C calc'!A0");
691 assert!(
692 result.starts_with("\"'="),
693 "DDE payload not neutralized: {result}"
694 );
695 }
696
697 #[test]
698 fn adversarial_csv_cell_null_bytes() {
699 let result = sanitize_csv_cell("hello\0world");
700 assert!(!result.is_empty());
701 }
702
703 #[test]
704 fn adversarial_slugify_xss_attempt() {
705 let slug = slugify("<script>alert('xss')</script>");
706 assert!(!slug.contains('<'));
707 assert!(!slug.contains('>'));
708 }
709
710 #[test]
711 fn adversarial_slugify_very_long_input() {
712 let long = "a".repeat(10_000);
713 let slug = slugify(&long);
714 assert!(
715 slug.len() <= 128,
716 "slug should be capped at 128 chars, got {}",
717 slug.len()
718 );
719 }
720
721 #[test]
722 fn adversarial_csv_rtl_override() {
723 // Right-to-left override character, could disguise cell content
724 let result = sanitize_csv_cell("normal\u{202e}evil");
725 assert!(!result.is_empty());
726 }
727
728 #[test]
729 fn adversarial_csv_zero_width_chars() {
730 // Zero-width space and zero-width joiner
731 let result = sanitize_csv_cell("=\u{200b}SUM(A1)");
732 // Starts with '=' so formula prefix should be applied
733 assert!(
734 result.starts_with("\"'="),
735 "Formula prefix not applied with ZWS: {result}"
736 );
737 }
738
739 #[test]
740 fn adversarial_slugify_path_traversal() {
741 let slug = slugify("../../../etc/passwd");
742 assert!(!slug.contains('.'));
743 assert!(!slug.contains('/'));
744 }
745
746 #[test]
747 fn adversarial_slugify_null_bytes() {
748 let slug = slugify("hello\0world");
749 assert!(!slug.contains('\0'));
750 assert!(slug.len() >= 2);
751 }
752
753 // ── Property-based tests ──
754
755 proptest::proptest! {
756 #[test]
757 fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) {
758 let result = format_price(cents);
759 proptest::prop_assert!(!result.is_empty());
760 match cents.cmp(&0) {
761 std::cmp::Ordering::Equal => {
762 proptest::prop_assert_eq!(result, "Free");
763 }
764 std::cmp::Ordering::Less => {
765 proptest::prop_assert!(result.starts_with("-$"),
766 "Negative price should start with -$: {}", result);
767 }
768 std::cmp::Ordering::Greater => {
769 proptest::prop_assert!(result.starts_with('$'),
770 "Positive price should start with $: {}", result);
771 }
772 }
773 }
774
775 #[test]
776 fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) {
777 let result = format_revenue(cents);
778 proptest::prop_assert!(result.starts_with('$') || result.starts_with("-$"),
779 "Revenue should start with $ or -$: {}", result);
780 }
781
782 #[test]
783 fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) {
784 let result = format_bytes(bytes);
785 proptest::prop_assert!(!result.is_empty());
786 }
787
788 #[test]
789 fn prop_slugify_never_panics(input in ".*") {
790 let slug = slugify(&input);
791 proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
792 "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str());
793 proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str());
794 }
795 }
796 }
797
798 /// Seal for the pricing-format drift chronic.
799 ///
800 /// The audit kept re-finding ad-hoc `cents as f64 / 100.0` conversions scattered
801 /// across routes, exports, and admin tooling, each one a place where a future
802 /// edit could silently reintroduce a rounding or display inconsistency. This
803 /// test fails the build if that idiom appears anywhere under `src/` outside this
804 /// module, forcing every cents→dollars conversion through [`format_price`],
805 /// [`format_revenue`], or [`format_dollars_plain`]. The seal is resolved when the
806 /// drifted variant can no longer compile-and-pass.
807 #[cfg(test)]
808 mod pricing_format_seal_guard {
809 use std::path::Path;
810
811 #[test]
812 fn no_ad_hoc_cents_to_dollars_conversion() {
813 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
814 let this_file = root.join("src/formatting.rs");
815 let mut offenders = Vec::new();
816
817 // The float cents→dollars idiom (rounding-risk) in ANY form, in Rust,
818 // the raw cast `as f64 / 100.0` AND the newtype accessor `.as_f64() / 100`
819 // that slipped past the earlier `as f64` pattern (the seal's own blind
820 // spot; `src/bin/` and the exports used it). Integer `cents / 100` manual
821 // formatting is a separate style item, not this float-drift seal.
822 let mut rust_check = |path: &Path, contents: &str| {
823 if path == this_file {
824 return;
825 }
826 for (i, line) in contents.lines().enumerate() {
827 if line.trim_start().starts_with("//") {
828 continue;
829 }
830 let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
831 if squished.contains("asf64/100") || squished.contains("as_f64()/100") {
832 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
833 }
834 }
835 };
836 walk(&root.join("src"), "rs", &mut rust_check);
837
838 // Templates must never do cents math, prices arrive pre-formatted from
839 // Rust. A `{{ price_cents / 100 }}` bypasses the centralized formatters in
840 // the one surface the original seal never scanned.
841 let mut template_check = |path: &Path, contents: &str| {
842 for (i, line) in contents.lines().enumerate() {
843 if line.trim_start().starts_with("{#") {
844 continue;
845 }
846 let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
847 if squished.contains("cents/100") || squished.contains("asf64/100") {
848 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
849 }
850 }
851 };
852 walk(&root.join("templates"), "html", &mut template_check);
853
854 assert!(
855 offenders.is_empty(),
856 "pricing-format seal violated, convert cents to dollars via \
857 formatting::format_price / format_revenue / format_dollars_plain (never a raw \
858 `as f64 / 100.0` or `.as_f64() / 100` in Rust, never cents math in a template). \
859 Offending lines:\n{}",
860 offenders.join("\n")
861 );
862 }
863
864 fn walk(dir: &Path, ext: &str, f: &mut impl FnMut(&Path, &str)) {
865 let Ok(entries) = std::fs::read_dir(dir) else {
866 return;
867 };
868 for entry in entries.flatten() {
869 let path = entry.path();
870 if path.is_dir() {
871 walk(&path, ext, f);
872 } else if path.extension().is_some_and(|e| e == ext)
873 && let Ok(contents) = std::fs::read_to_string(&path)
874 {
875 f(&path, &contents);
876 }
877 }
878 }
879 }
880