//! Formatting utilities: prices, file sizes, initials, slugs, CSV cells. use std::fmt::Write as _; /// Group thousands with commas (US locale). Returns the input string unchanged /// for values ≤999. Operates on a digit-only string so callers stay in i64 /// arithmetic territory and don't need `f64` formatting tricks. fn group_thousands(n: u64) -> String { let s = n.to_string(); let bytes = s.as_bytes(); let mut out = String::with_capacity(bytes.len() + bytes.len() / 3); for (i, &b) in bytes.iter().enumerate() { if i > 0 && (bytes.len() - i).is_multiple_of(3) { out.push(','); } out.push(b as char); } out } /// Format a price in cents as a human-readable dollar string or "Free". pub fn format_price(cents: impl Into) -> String { let cents: i64 = cents.into(); if cents == 0 { return "Free".to_string(); } let neg = cents < 0; let abs = cents.unsigned_abs(); let dollars = group_thousands(abs / 100); let frac = (abs % 100) as u32; let sign = if neg { "-" } else { "" }; if frac == 0 { format!("{sign}${dollars}") } else { format!("{sign}${dollars}.{frac:02}") } } /// Format a revenue amount in cents as a dollar string (always shows decimals). /// /// Unlike [`format_price`], this never returns "Free": zero revenue is "$0.00". pub fn format_revenue(cents: i64) -> String { let neg = cents < 0; let abs = cents.unsigned_abs(); let dollars = group_thousands(abs / 100); let frac = (abs % 100) as u32; let sign = if neg { "-" } else { "" }; format!("{sign}${dollars}.{frac:02}") } /// Format a price in cents as a plain decimal string: no currency symbol, no /// thousands separators, always two decimal places (e.g. "9.99", "1234.50"). /// /// For CSV cells and form `value=` attributes where a bare numeric is required /// and the surrounding context (spreadsheet column, template `$` prefix) /// supplies its own framing. For human-facing display use [`format_price`] /// (shows "Free" / drops trailing `.00`) or [`format_revenue`] (always `$X.XX`). /// /// This is the canonical cents→decimal conversion: bypassing it with a raw /// `as f64 / 100.0` is what the pricing-format drift chronic keeps re-finding, /// so a grep-enforcing test (below) bans that idiom outside this module. pub fn format_dollars_plain(cents: impl Into) -> String { let cents: i64 = cents.into(); let neg = cents < 0; let abs = cents.unsigned_abs(); let sign = if neg { "-" } else { "" }; format!("{sign}{}.{:02}", abs / 100, (abs % 100) as u32) } /// Format a byte count as a human-readable file size string. /// Returns "N/A" for zero bytes (useful for optional file sizes). pub fn format_file_size(bytes: i64) -> String { if bytes == 0 { return "N/A".to_string(); } format_bytes(bytes) } /// Format a byte count as a compact human-readable string (e.g. "1.5 GB"). /// Returns "0 B" for zero bytes (useful for storage quota display). pub fn format_bytes(bytes: i64) -> String { let bytes = bytes.max(0) as u64; if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } /// Extract up to two uppercase initials from a name for avatar display. pub fn get_initials(name: &str) -> String { name.split_whitespace() .filter_map(|word| word.chars().next()) .take(2) .collect::() .to_uppercase() } /// Generate a URL-safe slug from a title. /// /// Returns a `Slug` via `from_trusted`, the algorithm guarantees a valid slug. pub fn slugify(title: &str) -> crate::db::Slug { let slug: String = title .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); let mut result = String::new(); let mut prev_hyphen = true; for c in slug.chars() { if c == '-' { if !prev_hyphen { result.push('-'); } prev_hyphen = true; } else { result.push(c); prev_hyphen = false; } } if result.ends_with('-') { result.pop(); } // Cap slug length to prevent unbounded output from long titles if result.len() > 128 { result.truncate(128); // Don't leave a trailing hyphen after truncation while result.ends_with('-') { result.pop(); } } if result.len() < 2 { // The ASCII-only pass kept nothing usable, typically an all-non-Latin // title (CJK/Cyrillic/Arabic/emoji). A constant "post" fallback made // every such title collide on one slug, so a creator working in a // non-Latin script hit a unique-violation on their second section and // every blog post URL became `/post`. Derive a stable suffix from the // title content so distinct titles get distinct slugs (hash-fallback; // no transliteration dependency). result = format!("post-{}", title_hash_suffix(title)); } crate::db::Slug::from_trusted(result) } /// Short, stable, URL-safe suffix derived from a title's content. Used as the /// slug fallback when ASCII slugification yields nothing usable, so two /// different non-Latin titles produce two different slugs instead of colliding /// on a constant. Deterministic: the same title always yields the same suffix. fn title_hash_suffix(title: &str) -> String { use sha2::{Digest, Sha256}; let digest = Sha256::digest(title.trim().as_bytes()); // 4 bytes -> 8 lowercase hex chars; ample to separate a creator's titles, // and the section/blog dedup loop handles the astronomically rare clash. let mut suffix = String::with_capacity(8); for b in &digest[..4] { write!(suffix, "{b:02x}").unwrap(); } suffix } /// Sanitize a string for use as a CSV cell value. /// /// Prevents CSV injection by quoting cells and escaping values that start /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`). /// Also handles embedded commas, quotes, and newlines per RFC 4180. pub fn sanitize_csv_cell(value: &str) -> String { let needs_prefix = value .chars() .next() .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r')); let escaped = value.replace('"', "\"\""); if needs_prefix { format!("\"'{escaped}\"") } else if value.contains(',') || value.contains('"') || value.contains('\n') { format!("\"{escaped}\"") } else { escaped } } #[cfg(test)] mod tests { use super::*; // ── format_dollars_plain ── #[test] fn dollars_plain_basic() { assert_eq!(format_dollars_plain(0), "0.00"); assert_eq!(format_dollars_plain(999), "9.99"); assert_eq!(format_dollars_plain(100), "1.00"); assert_eq!(format_dollars_plain(1), "0.01"); } #[test] fn dollars_plain_no_thousands_separator() { // CSV cells and form values must stay machine-parseable, no commas. assert_eq!(format_dollars_plain(123_456), "1234.56"); assert_eq!(format_dollars_plain(1_000_000), "10000.00"); } #[test] fn dollars_plain_negative() { assert_eq!(format_dollars_plain(-999), "-9.99"); assert_eq!(format_dollars_plain(-5), "-0.05"); } // ── format_price ── #[test] fn format_price_free() { assert_eq!(format_price(0), "Free"); } #[test] fn format_price_whole_dollars() { assert_eq!(format_price(500), "$5"); assert_eq!(format_price(100), "$1"); assert_eq!(format_price(10000), "$100"); } #[test] fn format_price_with_cents() { assert_eq!(format_price(999), "$9.99"); assert_eq!(format_price(150), "$1.50"); assert_eq!(format_price(1), "$0.01"); } #[test] fn format_price_negative_whole() { assert_eq!(format_price(-500i64), "-$5"); } #[test] fn format_price_negative_with_cents() { assert_eq!(format_price(-999i64), "-$9.99"); } #[test] fn format_price_one_cent() { assert_eq!(format_price(1), "$0.01"); } #[test] fn format_price_99_cents() { assert_eq!(format_price(99), "$0.99"); } // ── format_revenue ── #[test] fn format_revenue_zero() { assert_eq!(format_revenue(0), "$0.00"); } #[test] fn format_revenue_whole_dollars() { assert_eq!(format_revenue(500), "$5.00"); assert_eq!(format_revenue(10000), "$100.00"); } #[test] fn format_revenue_with_cents() { assert_eq!(format_revenue(999), "$9.99"); assert_eq!(format_revenue(150), "$1.50"); assert_eq!(format_revenue(1), "$0.01"); } #[test] fn format_revenue_large_amount() { assert_eq!(format_revenue(1_000_000), "$10,000.00"); } #[test] fn format_revenue_million_dollars() { assert_eq!(format_revenue(100_000_000), "$1,000,000.00"); } #[test] fn format_price_thousands() { assert_eq!(format_price(1_234_500), "$12,345"); assert_eq!(format_price(1_234_567), "$12,345.67"); } #[test] fn format_price_negative_thousands() { assert_eq!(format_price(-1_234_567i64), "-$12,345.67"); } #[test] fn format_revenue_negative() { assert_eq!(format_revenue(-500), "-$5.00"); } // ── format_file_size ── #[test] fn format_file_size_zero() { assert_eq!(format_file_size(0), "N/A"); } #[test] fn format_file_size_bytes() { assert_eq!(format_file_size(512), "512 B"); assert_eq!(format_file_size(1), "1 B"); } #[test] fn format_file_size_kilobytes() { assert_eq!(format_file_size(1024), "1.0 KB"); assert_eq!(format_file_size(1536), "1.5 KB"); } #[test] fn format_file_size_megabytes() { assert_eq!(format_file_size(1024 * 1024), "1.0 MB"); assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB"); } #[test] fn format_file_size_gigabytes() { assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB"); } // ── format_bytes ── #[test] fn format_bytes_zero() { assert_eq!(format_bytes(0), "0 B"); } #[test] fn format_bytes_small() { assert_eq!(format_bytes(512), "512 B"); } #[test] fn format_bytes_megabytes() { assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB"); } #[test] fn format_bytes_gigabytes() { assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB"); } #[test] fn format_bytes_negative_clamped() { assert_eq!(format_bytes(-100), "0 B"); } #[test] fn format_bytes_exact_kb_boundary() { assert_eq!(format_bytes(1023), "1023 B"); assert_eq!(format_bytes(1024), "1.0 KB"); } #[test] fn format_bytes_exact_mb_boundary() { assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB"); assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); } #[test] fn format_bytes_exact_gb_boundary() { assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB"); assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); } // ── get_initials ── #[test] fn initials_two_words() { assert_eq!(get_initials("John Doe"), "JD"); } #[test] fn initials_single_word() { assert_eq!(get_initials("Alice"), "A"); } #[test] fn initials_three_words_takes_two() { assert_eq!(get_initials("John Michael Doe"), "JM"); } #[test] fn initials_empty() { assert_eq!(get_initials(""), ""); } #[test] fn initials_lowercase_uppercased() { assert_eq!(get_initials("bob smith"), "BS"); } #[test] fn initials_extra_whitespace() { assert_eq!(get_initials(" John Doe "), "JD"); } #[test] fn initials_unicode() { assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z"); } // ── slugify ── #[test] fn slugify_basic() { assert_eq!(slugify("Hello World").as_str(), "hello-world"); } #[test] fn slugify_special_chars() { assert_eq!( slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist" ); } #[test] fn slugify_multiple_spaces() { assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces"); } #[test] fn slugify_leading_trailing_special() { assert_eq!(slugify("---hello---").as_str(), "hello"); } #[test] fn slugify_unicode() { let slug = slugify("café résumé"); assert!(slug.contains("caf")); assert!(!slug.contains(' ')); } #[test] fn slugify_too_short_falls_back() { // Fallback is now `post-` (was a constant "post"): still a valid // slug, deterministic per input, and distinct across inputs. for input in ["a", "", "---"] { let s = slugify(input); assert!(s.as_str().starts_with("post-"), "got {}", s.as_str()); assert!(crate::validation::validate_slug(s.as_str()).is_ok()); // Deterministic. assert_eq!(slugify(input).as_str(), s.as_str()); } // Distinct inputs -> distinct fallback slugs (the bug this fixes). assert_ne!(slugify("a").as_str(), slugify("---").as_str()); } #[test] fn slugify_non_latin_titles_are_distinct() { // The whole point of the hash-fallback: two different non-Latin titles // must not collide on one slug (which caused a unique-violation 500 for // the second section a non-Latin creator added). let a = slugify("日本語の記事"); let b = slugify("Статья на русском"); assert!(a.as_str().starts_with("post-")); assert!(b.as_str().starts_with("post-")); assert_ne!(a.as_str(), b.as_str()); assert!(crate::validation::validate_slug(a.as_str()).is_ok()); assert!(crate::validation::validate_slug(b.as_str()).is_ok()); } #[test] fn slugify_numbers() { assert_eq!(slugify("Version 2.0").as_str(), "version-2-0"); } #[test] fn slugify_all_special_chars() { let s = slugify("!@#$%^&*()"); assert!(s.as_str().starts_with("post-")); assert!(crate::validation::validate_slug(s.as_str()).is_ok()); } #[test] fn slugify_single_valid_char() { let s = slugify("x"); assert!(s.as_str().starts_with("post-")); assert!(crate::validation::validate_slug(s.as_str()).is_ok()); } #[test] fn slugify_two_valid_chars() { assert_eq!(slugify("ab").as_str(), "ab"); } #[test] fn slugify_mixed_unicode_and_ascii() { let slug = slugify("café"); assert_eq!(slug.as_str(), "caf"); } // ── sanitize_csv_cell ── #[test] fn csv_cell_plain_text() { assert_eq!(sanitize_csv_cell("Hello World"), "Hello World"); } #[test] fn csv_cell_formula_prefix_equals() { assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\""); } #[test] fn csv_cell_formula_prefix_plus() { assert_eq!( sanitize_csv_cell("+cmd|' /C calc'!A0"), "\"'+cmd|' /C calc'!A0\"" ); } #[test] fn csv_cell_formula_prefix_minus() { assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\""); } #[test] fn csv_cell_formula_prefix_at() { assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\""); } #[test] fn csv_cell_with_comma() { assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\""); } #[test] fn csv_cell_with_quotes() { assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\""); } #[test] fn csv_cell_empty() { assert_eq!(sanitize_csv_cell(""), ""); } #[test] fn csv_cell_with_newline() { assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\""); } #[test] fn csv_cell_tab_prefix() { let result = sanitize_csv_cell("\tcmd"); assert!( result.starts_with("\"'"), "Tab prefix should be neutralized: {result}" ); } #[test] fn csv_cell_cr_prefix() { let result = sanitize_csv_cell("\rcmd"); assert!( result.starts_with("\"'"), "CR prefix should be neutralized: {result}" ); } // ── Edge cases (test-fuzz) ── #[test] fn format_price_negative_one_cent() { assert_eq!(format_price(-1i64), "-$0.01"); } #[test] fn format_price_negative_whole_dollar() { assert_eq!(format_price(-100i64), "-$1"); } #[test] fn format_price_large_value() { // $1 billion in cents assert_eq!(format_price(100_000_000_000i64), "$1,000,000,000"); } #[test] fn format_revenue_one_cent() { assert_eq!(format_revenue(1), "$0.01"); } #[test] fn format_revenue_negative_one_cent() { assert_eq!(format_revenue(-1), "-$0.01"); } #[test] fn format_file_size_negative() { // Negative byte count: only == 0 returns "N/A"; negatives go through // format_bytes which clamps to 0 via .max(0) → "0 B" assert_eq!(format_file_size(-100), "0 B"); } #[test] fn format_file_size_one_byte() { assert_eq!(format_file_size(1), "1 B"); } #[test] fn slugify_truncation_trailing_hyphen() { // 130 chars of "a-" pattern → after truncation at 128, trailing hyphen stripped let input = "a-".repeat(65); // 130 chars let slug = slugify(&input); assert!(slug.len() <= 128); assert!( !slug.ends_with('-'), "Slug should not end with hyphen after truncation" ); } #[test] fn slugify_emoji_input() { // Emoji are non-ASCII → become hyphens → collapsed to nothing, so the // hash-fallback kicks in (was a constant "post"). let slug = slugify("\u{1f600}\u{1f600}\u{1f600}"); assert!(slug.as_str().starts_with("post-")); assert!(crate::validation::validate_slug(slug.as_str()).is_ok()); } #[test] fn slugify_mixed_valid_after_truncation() { // 127 a's + special char → truncation shouldn't break it let input = format!("{}-z", "a".repeat(127)); let slug = slugify(&input); assert!(slug.len() <= 128); assert!(slug.len() >= 2); } #[test] fn csv_cell_formula_with_embedded_quotes() { // Formula prefix + embedded quotes = both escapes apply let result = sanitize_csv_cell("=SUM(\"A1\")"); assert!( result.starts_with("\"'="), "Formula prefix not neutralized: {result}" ); assert!( result.contains("\"\""), "Embedded quotes should be escaped: {result}" ); } #[test] fn csv_cell_newline_with_formula_prefix() { // Newline AND formula prefix, both protections should apply let result = sanitize_csv_cell("=cmd\ninjection"); assert!( result.starts_with("\"'="), "Formula prefix not neutralized: {result}" ); } #[test] fn csv_cell_very_long_value() { let long = "x".repeat(100_000); let result = sanitize_csv_cell(&long); // Plain text, no special chars → returned as-is assert_eq!(result.len(), 100_000); } #[test] fn initials_emoji_name() { // Emoji as first char of name, still takes 2 initials let result = get_initials("\u{1f600} Robot"); assert_eq!(result.chars().count(), 2); // emoji char + 'R' } #[test] fn initials_single_char_name() { assert_eq!(get_initials("A"), "A"); } // ── Adversarial ── #[test] fn adversarial_csv_injection_dde() { let result = sanitize_csv_cell("=cmd|'/C calc'!A0"); assert!( result.starts_with("\"'="), "DDE payload not neutralized: {result}" ); } #[test] fn adversarial_csv_cell_null_bytes() { let result = sanitize_csv_cell("hello\0world"); assert!(!result.is_empty()); } #[test] fn adversarial_slugify_xss_attempt() { let slug = slugify(""); assert!(!slug.contains('<')); assert!(!slug.contains('>')); } #[test] fn adversarial_slugify_very_long_input() { let long = "a".repeat(10_000); let slug = slugify(&long); assert!( slug.len() <= 128, "slug should be capped at 128 chars, got {}", slug.len() ); } #[test] fn adversarial_csv_rtl_override() { // Right-to-left override character, could disguise cell content let result = sanitize_csv_cell("normal\u{202e}evil"); assert!(!result.is_empty()); } #[test] fn adversarial_csv_zero_width_chars() { // Zero-width space and zero-width joiner let result = sanitize_csv_cell("=\u{200b}SUM(A1)"); // Starts with '=' so formula prefix should be applied assert!( result.starts_with("\"'="), "Formula prefix not applied with ZWS: {result}" ); } #[test] fn adversarial_slugify_path_traversal() { let slug = slugify("../../../etc/passwd"); assert!(!slug.contains('.')); assert!(!slug.contains('/')); } #[test] fn adversarial_slugify_null_bytes() { let slug = slugify("hello\0world"); assert!(!slug.contains('\0')); assert!(slug.len() >= 2); } // ── Property-based tests ── proptest::proptest! { #[test] fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) { let result = format_price(cents); proptest::prop_assert!(!result.is_empty()); match cents.cmp(&0) { std::cmp::Ordering::Equal => { proptest::prop_assert_eq!(result, "Free"); } std::cmp::Ordering::Less => { proptest::prop_assert!(result.starts_with("-$"), "Negative price should start with -$: {}", result); } std::cmp::Ordering::Greater => { proptest::prop_assert!(result.starts_with('$'), "Positive price should start with $: {}", result); } } } #[test] fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) { let result = format_revenue(cents); proptest::prop_assert!(result.starts_with('$') || result.starts_with("-$"), "Revenue should start with $ or -$: {}", result); } #[test] fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) { let result = format_bytes(bytes); proptest::prop_assert!(!result.is_empty()); } #[test] fn prop_slugify_never_panics(input in ".*") { let slug = slugify(&input); proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'), "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str()); proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str()); } } } /// Seal for the pricing-format drift chronic. /// /// The audit kept re-finding ad-hoc `cents as f64 / 100.0` conversions scattered /// across routes, exports, and admin tooling, each one a place where a future /// edit could silently reintroduce a rounding or display inconsistency. This /// test fails the build if that idiom appears anywhere under `src/` outside this /// module, forcing every cents→dollars conversion through [`format_price`], /// [`format_revenue`], or [`format_dollars_plain`]. The seal is resolved when the /// drifted variant can no longer compile-and-pass. #[cfg(test)] mod pricing_format_seal_guard { use std::path::Path; #[test] fn no_ad_hoc_cents_to_dollars_conversion() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let this_file = root.join("src/formatting.rs"); let mut offenders = Vec::new(); // The float cents→dollars idiom (rounding-risk) in ANY form, in Rust, // the raw cast `as f64 / 100.0` AND the newtype accessor `.as_f64() / 100` // that slipped past the earlier `as f64` pattern (the seal's own blind // spot; `src/bin/` and the exports used it). Integer `cents / 100` manual // formatting is a separate style item, not this float-drift seal. let mut rust_check = |path: &Path, contents: &str| { if path == this_file { return; } for (i, line) in contents.lines().enumerate() { if line.trim_start().starts_with("//") { continue; } let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect(); if squished.contains("asf64/100") || squished.contains("as_f64()/100") { offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); } } }; walk(&root.join("src"), "rs", &mut rust_check); // Templates must never do cents math, prices arrive pre-formatted from // Rust. A `{{ price_cents / 100 }}` bypasses the centralized formatters in // the one surface the original seal never scanned. let mut template_check = |path: &Path, contents: &str| { for (i, line) in contents.lines().enumerate() { if line.trim_start().starts_with("{#") { continue; } let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect(); if squished.contains("cents/100") || squished.contains("asf64/100") { offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); } } }; walk(&root.join("templates"), "html", &mut template_check); assert!( offenders.is_empty(), "pricing-format seal violated, convert cents to dollars via \ formatting::format_price / format_revenue / format_dollars_plain (never a raw \ `as f64 / 100.0` or `.as_f64() / 100` in Rust, never cents math in a template). \ Offending lines:\n{}", offenders.join("\n") ); } fn walk(dir: &Path, ext: &str, f: &mut impl FnMut(&Path, &str)) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { walk(&path, ext, f); } else if path.extension().is_some_and(|e| e == ext) && let Ok(contents) = std::fs::read_to_string(&path) { f(&path, &contents); } } } }