max / makenotwork
5 files changed,
+743 insertions,
-425 deletions
| @@ -0,0 +1,166 @@ | |||
| 1 | + | //! Cryptographic utilities: constant-time comparison, key generation, feed signing. | |
| 2 | + | ||
| 3 | + | /// Constant-time string comparison to prevent timing attacks. | |
| 4 | + | /// | |
| 5 | + | /// Hashes both inputs with SHA-256 before comparing to avoid leaking | |
| 6 | + | /// the length of the expected value via early return. | |
| 7 | + | pub fn constant_time_compare(a: &str, b: &str) -> bool { | |
| 8 | + | use sha2::{Sha256, Digest}; | |
| 9 | + | ||
| 10 | + | let hash_a = Sha256::digest(a.as_bytes()); | |
| 11 | + | let hash_b = Sha256::digest(b.as_bytes()); | |
| 12 | + | ||
| 13 | + | let mut result = 0u8; | |
| 14 | + | for (x, y) in hash_a.iter().zip(hash_b.iter()) { | |
| 15 | + | result |= x ^ y; | |
| 16 | + | } | |
| 17 | + | result == 0 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// Generate a license key code in word-word-word-word-word format. | |
| 21 | + | /// | |
| 22 | + | /// Uses 5 random words from the 2048-word list (~55 bits of entropy). | |
| 23 | + | /// Returns a `KeyCode` via `from_trusted` — the wordlist guarantees validity. | |
| 24 | + | pub fn generate_key_code() -> crate::db::KeyCode { | |
| 25 | + | use rand::Rng; | |
| 26 | + | let mut rng = rand::rng(); | |
| 27 | + | let words: Vec<&str> = (0..5) | |
| 28 | + | .map(|_| { | |
| 29 | + | let idx = rng.random_range(0..crate::wordlist::WORDLIST.len()); | |
| 30 | + | crate::wordlist::WORDLIST[idx] | |
| 31 | + | }) | |
| 32 | + | .collect(); | |
| 33 | + | crate::db::KeyCode::from_trusted(words.join("-")) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Generate an HMAC-signed personal RSS feed URL for a user. | |
| 37 | + | /// | |
| 38 | + | /// The URL is permanent (no expiry) and tied to the signing secret. | |
| 39 | + | /// If the secret rotates, old URLs become invalid. | |
| 40 | + | pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String { | |
| 41 | + | use hmac::{Hmac, Mac}; | |
| 42 | + | use sha2::Sha256; | |
| 43 | + | ||
| 44 | + | let message = format!("feed:{}", user_id); | |
| 45 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 46 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 47 | + | mac.update(message.as_bytes()); | |
| 48 | + | let sig = hex::encode(mac.finalize().into_bytes()); | |
| 49 | + | ||
| 50 | + | format!("{}/feed/{}?sig={}", host_url, user_id, sig) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Verify a personal feed URL signature. | |
| 54 | + | pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool { | |
| 55 | + | use hmac::{Hmac, Mac}; | |
| 56 | + | use sha2::Sha256; | |
| 57 | + | ||
| 58 | + | let message = format!("feed:{}", user_id); | |
| 59 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 60 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 61 | + | mac.update(message.as_bytes()); | |
| 62 | + | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 63 | + | ||
| 64 | + | constant_time_compare(&expected, signature) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[cfg(test)] | |
| 68 | + | mod tests { | |
| 69 | + | use super::*; | |
| 70 | + | ||
| 71 | + | // ── constant_time_compare ── | |
| 72 | + | ||
| 73 | + | #[test] | |
| 74 | + | fn compare_equal_strings() { | |
| 75 | + | assert!(constant_time_compare("abc123", "abc123")); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn compare_different_strings() { | |
| 80 | + | assert!(!constant_time_compare("abc123", "abc124")); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | #[test] | |
| 84 | + | fn compare_different_lengths() { | |
| 85 | + | assert!(!constant_time_compare("short", "longer")); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn compare_empty_strings() { | |
| 90 | + | assert!(constant_time_compare("", "")); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[test] | |
| 94 | + | fn adversarial_timing_safety() { | |
| 95 | + | assert!(!constant_time_compare("a", "b")); | |
| 96 | + | assert!(!constant_time_compare("a", "aa")); | |
| 97 | + | assert!(!constant_time_compare("", "x")); | |
| 98 | + | assert!(constant_time_compare("same", "same")); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // ── generate_key_code ── | |
| 102 | + | ||
| 103 | + | #[test] | |
| 104 | + | fn key_code_format() { | |
| 105 | + | let code = generate_key_code(); | |
| 106 | + | let parts: Vec<&str> = code.split('-').collect(); | |
| 107 | + | assert_eq!(parts.len(), 5, "Key code should have 5 words"); | |
| 108 | + | for word in &parts { | |
| 109 | + | assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word); | |
| 110 | + | assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word); | |
| 111 | + | assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word); | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn key_code_uniqueness() { | |
| 117 | + | let codes: std::collections::HashSet<crate::db::KeyCode> = (0..100).map(|_| generate_key_code()).collect(); | |
| 118 | + | assert_eq!(codes.len(), 100, "100 generated key codes should all be unique"); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // ── feed URL signing ── | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn feed_url_round_trip() { | |
| 125 | + | let user_id = crate::db::UserId::new(); | |
| 126 | + | let url = generate_feed_url("https://makenot.work", user_id, "secret"); | |
| 127 | + | assert!(url.contains(&user_id.to_string())); | |
| 128 | + | assert!(url.contains("sig=")); | |
| 129 | + | let sig = url.split("sig=").nth(1).unwrap(); | |
| 130 | + | assert!(verify_feed_signature(user_id, sig, "secret")); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn feed_url_wrong_secret_rejected() { | |
| 135 | + | let user_id = crate::db::UserId::new(); | |
| 136 | + | let url = generate_feed_url("https://makenot.work", user_id, "secret"); | |
| 137 | + | let sig = url.split("sig=").nth(1).unwrap(); | |
| 138 | + | assert!(!verify_feed_signature(user_id, sig, "wrong-secret")); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn feed_url_wrong_user_rejected() { | |
| 143 | + | let user_id = crate::db::UserId::new(); | |
| 144 | + | let other_id = crate::db::UserId::new(); | |
| 145 | + | let url = generate_feed_url("https://makenot.work", user_id, "secret"); | |
| 146 | + | let sig = url.split("sig=").nth(1).unwrap(); | |
| 147 | + | assert!(!verify_feed_signature(other_id, sig, "secret")); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn feed_signature_empty_string_rejected() { | |
| 152 | + | let user_id = crate::db::UserId::new(); | |
| 153 | + | assert!(!verify_feed_signature(user_id, "", "secret")); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn feed_signature_tampered_rejected() { | |
| 158 | + | let user_id = crate::db::UserId::new(); | |
| 159 | + | let url = generate_feed_url("https://makenot.work", user_id, "secret"); | |
| 160 | + | let sig = url.split("sig=").nth(1).unwrap(); | |
| 161 | + | let mut tampered = sig.to_string(); | |
| 162 | + | let first = tampered.remove(0); | |
| 163 | + | tampered.insert(0, if first == '0' { '1' } else { '0' }); | |
| 164 | + | assert!(!verify_feed_signature(user_id, &tampered, "secret")); | |
| 165 | + | } | |
| 166 | + | } |
| @@ -0,0 +1,478 @@ | |||
| 1 | + | //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells. | |
| 2 | + | ||
| 3 | + | /// Format a price in cents as a human-readable dollar string or "Free". | |
| 4 | + | pub fn format_price(cents: impl Into<i64>) -> String { | |
| 5 | + | let cents: i64 = cents.into(); | |
| 6 | + | if cents == 0 { | |
| 7 | + | "Free".to_string() | |
| 8 | + | } else if cents % 100 == 0 { | |
| 9 | + | format!("${}", cents / 100) | |
| 10 | + | } else { | |
| 11 | + | format!("${:.2}", cents as f64 / 100.0) | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /// Format a revenue amount in cents as a dollar string (always shows decimals). | |
| 16 | + | /// | |
| 17 | + | /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00". | |
| 18 | + | pub fn format_revenue(cents: i64) -> String { | |
| 19 | + | format!("${:.2}", cents as f64 / 100.0) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// Format a byte count as a human-readable file size string. | |
| 23 | + | /// Returns "N/A" for zero bytes (useful for optional file sizes). | |
| 24 | + | pub fn format_file_size(bytes: i64) -> String { | |
| 25 | + | if bytes == 0 { | |
| 26 | + | return "N/A".to_string(); | |
| 27 | + | } | |
| 28 | + | format_bytes(bytes) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Format a byte count as a compact human-readable string (e.g. "1.5 GB"). | |
| 32 | + | /// Returns "0 B" for zero bytes (useful for storage quota display). | |
| 33 | + | pub fn format_bytes(bytes: i64) -> String { | |
| 34 | + | let bytes = bytes.max(0) as u64; | |
| 35 | + | if bytes < 1024 { | |
| 36 | + | format!("{} B", bytes) | |
| 37 | + | } else if bytes < 1024 * 1024 { | |
| 38 | + | format!("{:.1} KB", bytes as f64 / 1024.0) | |
| 39 | + | } else if bytes < 1024 * 1024 * 1024 { | |
| 40 | + | format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 41 | + | } else { | |
| 42 | + | format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Extract up to two uppercase initials from a name for avatar display. | |
| 47 | + | pub fn get_initials(name: &str) -> String { | |
| 48 | + | name.split_whitespace() | |
| 49 | + | .filter_map(|word| word.chars().next()) | |
| 50 | + | .take(2) | |
| 51 | + | .collect::<String>() | |
| 52 | + | .to_uppercase() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Generate a URL-safe slug from a title. | |
| 56 | + | /// | |
| 57 | + | /// Returns a `Slug` via `from_trusted` — the algorithm guarantees a valid slug. | |
| 58 | + | pub fn slugify(title: &str) -> crate::db::Slug { | |
| 59 | + | let slug: String = title | |
| 60 | + | .to_lowercase() | |
| 61 | + | .chars() | |
| 62 | + | .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) | |
| 63 | + | .collect(); | |
| 64 | + | let mut result = String::new(); | |
| 65 | + | let mut prev_hyphen = true; | |
| 66 | + | for c in slug.chars() { | |
| 67 | + | if c == '-' { | |
| 68 | + | if !prev_hyphen { | |
| 69 | + | result.push('-'); | |
| 70 | + | } | |
| 71 | + | prev_hyphen = true; | |
| 72 | + | } else { | |
| 73 | + | result.push(c); | |
| 74 | + | prev_hyphen = false; | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if result.ends_with('-') { | |
| 78 | + | result.pop(); | |
| 79 | + | } | |
| 80 | + | if result.len() < 2 { | |
| 81 | + | result = "post".to_string(); | |
| 82 | + | } | |
| 83 | + | crate::db::Slug::from_trusted(result) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Sanitize a string for use as a CSV cell value. | |
| 87 | + | /// | |
| 88 | + | /// Prevents CSV injection by quoting cells and escaping values that start | |
| 89 | + | /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`). | |
| 90 | + | /// Also handles embedded commas, quotes, and newlines per RFC 4180. | |
| 91 | + | pub fn sanitize_csv_cell(value: &str) -> String { | |
| 92 | + | let needs_prefix = value | |
| 93 | + | .chars() | |
| 94 | + | .next() | |
| 95 | + | .map(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r')) | |
| 96 | + | .unwrap_or(false); | |
| 97 | + | ||
| 98 | + | let escaped = value.replace('"', "\"\""); | |
| 99 | + | ||
| 100 | + | if needs_prefix { | |
| 101 | + | format!("\"'{}\"", escaped) | |
| 102 | + | } else if value.contains(',') || value.contains('"') || value.contains('\n') { | |
| 103 | + | format!("\"{}\"", escaped) | |
| 104 | + | } else { | |
| 105 | + | escaped | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[cfg(test)] | |
| 110 | + | mod tests { | |
| 111 | + | use super::*; | |
| 112 | + | ||
| 113 | + | // ── format_price ── | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn format_price_free() { | |
| 117 | + | assert_eq!(format_price(0), "Free"); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn format_price_whole_dollars() { | |
| 122 | + | assert_eq!(format_price(500), "$5"); | |
| 123 | + | assert_eq!(format_price(100), "$1"); | |
| 124 | + | assert_eq!(format_price(10000), "$100"); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[test] | |
| 128 | + | fn format_price_with_cents() { | |
| 129 | + | assert_eq!(format_price(999), "$9.99"); | |
| 130 | + | assert_eq!(format_price(150), "$1.50"); | |
| 131 | + | assert_eq!(format_price(1), "$0.01"); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[test] | |
| 135 | + | fn format_price_negative_cents() { | |
| 136 | + | let result = format_price(-500i64); | |
| 137 | + | assert!(result.contains("-"), "Negative price should show negative sign: {}", result); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn format_price_one_cent() { | |
| 142 | + | assert_eq!(format_price(1), "$0.01"); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[test] | |
| 146 | + | fn format_price_99_cents() { | |
| 147 | + | assert_eq!(format_price(99), "$0.99"); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // ── format_revenue ── | |
| 151 | + | ||
| 152 | + | #[test] | |
| 153 | + | fn format_revenue_zero() { | |
| 154 | + | assert_eq!(format_revenue(0), "$0.00"); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn format_revenue_whole_dollars() { | |
| 159 | + | assert_eq!(format_revenue(500), "$5.00"); | |
| 160 | + | assert_eq!(format_revenue(10000), "$100.00"); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn format_revenue_with_cents() { | |
| 165 | + | assert_eq!(format_revenue(999), "$9.99"); | |
| 166 | + | assert_eq!(format_revenue(150), "$1.50"); | |
| 167 | + | assert_eq!(format_revenue(1), "$0.01"); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn format_revenue_large_amount() { | |
| 172 | + | assert_eq!(format_revenue(1_000_000), "$10000.00"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 176 | + | fn format_revenue_negative() { | |
| 177 | + | assert_eq!(format_revenue(-500), "$-5.00"); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // ── format_file_size ── | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn format_file_size_zero() { | |
| 184 | + | assert_eq!(format_file_size(0), "N/A"); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn format_file_size_bytes() { | |
| 189 | + | assert_eq!(format_file_size(512), "512 B"); | |
| 190 | + | assert_eq!(format_file_size(1), "1 B"); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[test] | |
| 194 | + | fn format_file_size_kilobytes() { | |
| 195 | + | assert_eq!(format_file_size(1024), "1.0 KB"); | |
| 196 | + | assert_eq!(format_file_size(1536), "1.5 KB"); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[test] | |
| 200 | + | fn format_file_size_megabytes() { | |
| 201 | + | assert_eq!(format_file_size(1024 * 1024), "1.0 MB"); | |
| 202 | + | assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB"); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | #[test] | |
| 206 | + | fn format_file_size_gigabytes() { | |
| 207 | + | assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); | |
| 208 | + | assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB"); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // ── format_bytes ── | |
| 212 | + | ||
| 213 | + | #[test] | |
| 214 | + | fn format_bytes_zero() { | |
| 215 | + | assert_eq!(format_bytes(0), "0 B"); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[test] | |
| 219 | + | fn format_bytes_small() { | |
| 220 | + | assert_eq!(format_bytes(512), "512 B"); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[test] | |
| 224 | + | fn format_bytes_megabytes() { | |
| 225 | + | assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB"); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn format_bytes_gigabytes() { | |
| 230 | + | assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB"); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn format_bytes_negative_clamped() { | |
| 235 | + | assert_eq!(format_bytes(-100), "0 B"); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[test] | |
| 239 | + | fn format_bytes_exact_kb_boundary() { | |
| 240 | + | assert_eq!(format_bytes(1023), "1023 B"); | |
| 241 | + | assert_eq!(format_bytes(1024), "1.0 KB"); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn format_bytes_exact_mb_boundary() { | |
| 246 | + | assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB"); | |
| 247 | + | assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn format_bytes_exact_gb_boundary() { | |
| 252 | + | assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB"); | |
| 253 | + | assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // ── get_initials ── | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn initials_two_words() { | |
| 260 | + | assert_eq!(get_initials("John Doe"), "JD"); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[test] | |
| 264 | + | fn initials_single_word() { | |
| 265 | + | assert_eq!(get_initials("Alice"), "A"); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn initials_three_words_takes_two() { | |
| 270 | + | assert_eq!(get_initials("John Michael Doe"), "JM"); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn initials_empty() { | |
| 275 | + | assert_eq!(get_initials(""), ""); | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | #[test] | |
| 279 | + | fn initials_lowercase_uppercased() { | |
| 280 | + | assert_eq!(get_initials("bob smith"), "BS"); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn initials_extra_whitespace() { | |
| 285 | + | assert_eq!(get_initials(" John Doe "), "JD"); | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | #[test] | |
| 289 | + | fn initials_unicode() { | |
| 290 | + | assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z"); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // ── slugify ── | |
| 294 | + | ||
| 295 | + | #[test] | |
| 296 | + | fn slugify_basic() { | |
| 297 | + | assert_eq!(slugify("Hello World").as_str(), "hello-world"); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn slugify_special_chars() { | |
| 302 | + | assert_eq!(slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist"); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | #[test] | |
| 306 | + | fn slugify_multiple_spaces() { | |
| 307 | + | assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces"); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[test] | |
| 311 | + | fn slugify_leading_trailing_special() { | |
| 312 | + | assert_eq!(slugify("---hello---").as_str(), "hello"); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | #[test] | |
| 316 | + | fn slugify_unicode() { | |
| 317 | + | let slug = slugify("café résumé"); | |
| 318 | + | assert!(slug.contains("caf")); | |
| 319 | + | assert!(!slug.contains(' ')); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn slugify_too_short_falls_back() { | |
| 324 | + | assert_eq!(slugify("a").as_str(), "post"); | |
| 325 | + | assert_eq!(slugify("").as_str(), "post"); | |
| 326 | + | assert_eq!(slugify("---").as_str(), "post"); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[test] | |
| 330 | + | fn slugify_numbers() { | |
| 331 | + | assert_eq!(slugify("Version 2.0").as_str(), "version-2-0"); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn slugify_all_special_chars() { | |
| 336 | + | assert_eq!(slugify("!@#$%^&*()").as_str(), "post"); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | #[test] | |
| 340 | + | fn slugify_single_valid_char() { | |
| 341 | + | assert_eq!(slugify("x").as_str(), "post"); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[test] | |
| 345 | + | fn slugify_two_valid_chars() { | |
| 346 | + | assert_eq!(slugify("ab").as_str(), "ab"); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[test] | |
| 350 | + | fn slugify_mixed_unicode_and_ascii() { | |
| 351 | + | let slug = slugify("café"); | |
| 352 | + | assert_eq!(slug.as_str(), "caf"); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // ── sanitize_csv_cell ── | |
| 356 | + | ||
| 357 | + | #[test] | |
| 358 | + | fn csv_cell_plain_text() { | |
| 359 | + | assert_eq!(sanitize_csv_cell("Hello World"), "Hello World"); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | #[test] | |
| 363 | + | fn csv_cell_formula_prefix_equals() { | |
| 364 | + | assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\""); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[test] | |
| 368 | + | fn csv_cell_formula_prefix_plus() { | |
| 369 | + | assert_eq!(sanitize_csv_cell("+cmd|' /C calc'!A0"), "\"'+cmd|' /C calc'!A0\""); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn csv_cell_formula_prefix_minus() { | |
| 374 | + | assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\""); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[test] | |
| 378 | + | fn csv_cell_formula_prefix_at() { | |
| 379 | + | assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\""); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn csv_cell_with_comma() { | |
| 384 | + | assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\""); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn csv_cell_with_quotes() { | |
| 389 | + | assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\""); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | #[test] | |
| 393 | + | fn csv_cell_empty() { | |
| 394 | + | assert_eq!(sanitize_csv_cell(""), ""); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn csv_cell_with_newline() { | |
| 399 | + | assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\""); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn csv_cell_tab_prefix() { | |
| 404 | + | let result = sanitize_csv_cell("\tcmd"); | |
| 405 | + | assert!(result.starts_with("\"'"), "Tab prefix should be neutralized: {}", result); | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn csv_cell_cr_prefix() { | |
| 410 | + | let result = sanitize_csv_cell("\rcmd"); | |
| 411 | + | assert!(result.starts_with("\"'"), "CR prefix should be neutralized: {}", result); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // ── Adversarial ── | |
| 415 | + | ||
| 416 | + | #[test] | |
| 417 | + | fn adversarial_csv_injection_dde() { | |
| 418 | + | let result = sanitize_csv_cell("=cmd|'/C calc'!A0"); | |
| 419 | + | assert!(result.starts_with("\"'="), "DDE payload not neutralized: {}", result); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[test] | |
| 423 | + | fn adversarial_csv_cell_null_bytes() { | |
| 424 | + | let result = sanitize_csv_cell("hello\0world"); | |
| 425 | + | assert!(!result.is_empty()); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn adversarial_slugify_xss_attempt() { | |
| 430 | + | let slug = slugify("<script>alert('xss')</script>"); | |
| 431 | + | assert!(!slug.contains('<')); | |
| 432 | + | assert!(!slug.contains('>')); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn adversarial_slugify_very_long_input() { | |
| 437 | + | let long = "a".repeat(10_000); | |
| 438 | + | let slug = slugify(&long); | |
| 439 | + | assert_eq!(slug.len(), 10_000); | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // ── Property-based tests ── | |
| 443 | + | ||
| 444 | + | proptest::proptest! { | |
| 445 | + | #[test] | |
| 446 | + | fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) { | |
| 447 | + | let result = format_price(cents); | |
| 448 | + | proptest::prop_assert!(!result.is_empty()); | |
| 449 | + | if cents == 0 { | |
| 450 | + | proptest::prop_assert_eq!(result, "Free"); | |
| 451 | + | } else { | |
| 452 | + | proptest::prop_assert!(result.starts_with('$') || result.starts_with("$-"), | |
| 453 | + | "Non-zero price should start with $: {}", result); | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) { | |
| 459 | + | let result = format_revenue(cents); | |
| 460 | + | proptest::prop_assert!(result.starts_with('$') || result.starts_with("$-"), | |
| 461 | + | "Revenue should start with $: {}", result); | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | #[test] | |
| 465 | + | fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) { | |
| 466 | + | let result = format_bytes(bytes); | |
| 467 | + | proptest::prop_assert!(!result.is_empty()); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | #[test] | |
| 471 | + | fn prop_slugify_never_panics(input in ".*") { | |
| 472 | + | let slug = slugify(&input); | |
| 473 | + | proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'), | |
| 474 | + | "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str()); | |
| 475 | + | proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str()); | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | } |
| @@ -1,4 +1,7 @@ | |||
| 1 | - | //! Shared utility functions used across routes and modules | |
| 1 | + | //! Shared utility functions used across routes and modules. | |
| 2 | + | //! | |
| 3 | + | //! Formatting, crypto, and rate limiting live in their own modules. | |
| 4 | + | //! Re-exported here for backward compatibility with existing `crate::helpers::*` imports. | |
| 2 | 5 | ||
| 3 | 6 | use axum::http::header::HeaderMap; | |
| 4 | 7 | use axum::http::HeaderValue; | |
| @@ -8,6 +11,18 @@ use tower_sessions::Session; | |||
| 8 | 11 | ||
| 9 | 12 | use crate::AppState; | |
| 10 | 13 | ||
| 14 | + | // Re-export from focused modules so existing `crate::helpers::X` paths still work. | |
| 15 | + | pub use crate::formatting::{ | |
| 16 | + | format_bytes, format_file_size, format_price, format_revenue, | |
| 17 | + | get_initials, sanitize_csv_cell, slugify, | |
| 18 | + | }; | |
| 19 | + | pub use crate::crypto::{ | |
| 20 | + | constant_time_compare, generate_feed_url, generate_key_code, verify_feed_signature, | |
| 21 | + | }; | |
| 22 | + | pub use crate::rate_limit::{ | |
| 23 | + | rate_limiter_ms, rate_limiter_per_sec, CloudflareIpKeyExtractor, | |
| 24 | + | }; | |
| 25 | + | ||
| 11 | 26 | /// Extract the client IP from request headers. | |
| 12 | 27 | /// | |
| 13 | 28 | /// Prefers `CF-Connecting-IP` (set by Cloudflare, trusted) over `X-Forwarded-For`. | |
| @@ -27,7 +42,6 @@ pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> { | |||
| 27 | 42 | /// Uses a simple hash to map arbitrary IP strings to the i64 keyspace. | |
| 28 | 43 | pub fn ip_advisory_lock_key(ip: &str) -> i64 { | |
| 29 | 44 | use std::hash::{Hash, Hasher}; | |
| 30 | - | // Namespace prefix to avoid collisions with other advisory lock users. | |
| 31 | 45 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); | |
| 32 | 46 | "sandbox_ip_cap".hash(&mut hasher); | |
| 33 | 47 | ip.hash(&mut hasher); | |
| @@ -78,101 +92,6 @@ pub async fn get_csrf_token(session: &Session) -> Option<String> { | |||
| 78 | 92 | crate::csrf::get_or_create_token(session).await.ok() | |
| 79 | 93 | } | |
| 80 | 94 | ||
| 81 | - | /// Constant-time string comparison to prevent timing attacks. | |
| 82 | - | /// | |
| 83 | - | /// Hashes both inputs with SHA-256 before comparing to avoid leaking | |
| 84 | - | /// the length of the expected value via early return. | |
| 85 | - | pub fn constant_time_compare(a: &str, b: &str) -> bool { | |
| 86 | - | use sha2::{Sha256, Digest}; | |
| 87 | - | ||
| 88 | - | let hash_a = Sha256::digest(a.as_bytes()); | |
| 89 | - | let hash_b = Sha256::digest(b.as_bytes()); | |
| 90 | - | ||
| 91 | - | let mut result = 0u8; | |
| 92 | - | for (x, y) in hash_a.iter().zip(hash_b.iter()) { | |
| 93 | - | result |= x ^ y; | |
| 94 | - | } | |
| 95 | - | result == 0 | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | /// Generate a URL-safe slug from a title. | |
| 99 | - | /// | |
| 100 | - | /// Returns a `Slug` via `from_trusted` — the algorithm guarantees a valid slug. | |
| 101 | - | pub fn slugify(title: &str) -> crate::db::Slug { | |
| 102 | - | let slug: String = title | |
| 103 | - | .to_lowercase() | |
| 104 | - | .chars() | |
| 105 | - | .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) | |
| 106 | - | .collect(); | |
| 107 | - | // Collapse multiple hyphens, trim from ends | |
| 108 | - | let mut result = String::new(); | |
| 109 | - | let mut prev_hyphen = true; // start true to trim leading hyphens | |
| 110 | - | for c in slug.chars() { | |
| 111 | - | if c == '-' { | |
| 112 | - | if !prev_hyphen { | |
| 113 | - | result.push('-'); | |
| 114 | - | } | |
| 115 | - | prev_hyphen = true; | |
| 116 | - | } else { | |
| 117 | - | result.push(c); | |
| 118 | - | prev_hyphen = false; | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | // Trim trailing hyphen | |
| 122 | - | if result.ends_with('-') { | |
| 123 | - | result.pop(); | |
| 124 | - | } | |
| 125 | - | if result.len() < 2 { | |
| 126 | - | result = "post".to_string(); | |
| 127 | - | } | |
| 128 | - | crate::db::Slug::from_trusted(result) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | /// Format a price in cents as a human-readable dollar string or "Free". | |
| 132 | - | pub fn format_price(cents: impl Into<i64>) -> String { | |
| 133 | - | let cents: i64 = cents.into(); | |
| 134 | - | if cents == 0 { | |
| 135 | - | "Free".to_string() | |
| 136 | - | } else if cents % 100 == 0 { | |
| 137 | - | // Whole dollar amount - no decimals | |
| 138 | - | format!("${}", cents / 100) | |
| 139 | - | } else { | |
| 140 | - | // Has cents - show decimals | |
| 141 | - | format!("${:.2}", cents as f64 / 100.0) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | /// Format a revenue amount in cents as a dollar string (always shows decimals). | |
| 146 | - | /// | |
| 147 | - | /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00". | |
| 148 | - | pub fn format_revenue(cents: i64) -> String { | |
| 149 | - | format!("${:.2}", cents as f64 / 100.0) | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | /// Format a byte count as a human-readable file size string. | |
| 153 | - | /// Returns "N/A" for zero bytes (useful for optional file sizes). | |
| 154 | - | pub fn format_file_size(bytes: i64) -> String { | |
| 155 | - | if bytes == 0 { | |
| 156 | - | return "N/A".to_string(); | |
| 157 | - | } | |
| 158 | - | format_bytes(bytes) | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | /// Format a byte count as a compact human-readable string (e.g. "1.5 GB"). | |
| 162 | - | /// Returns "0 B" for zero bytes (useful for storage quota display). | |
| 163 | - | pub fn format_bytes(bytes: i64) -> String { | |
| 164 | - | let bytes = bytes.max(0) as u64; | |
| 165 | - | if bytes < 1024 { | |
| 166 | - | format!("{} B", bytes) | |
| 167 | - | } else if bytes < 1024 * 1024 { | |
| 168 | - | format!("{:.1} KB", bytes as f64 / 1024.0) | |
| 169 | - | } else if bytes < 1024 * 1024 * 1024 { | |
| 170 | - | format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 171 | - | } else { | |
| 172 | - | format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 173 | - | } | |
| 174 | - | } | |
| 175 | - | ||
| 176 | 95 | /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now. | |
| 177 | 96 | pub fn stripe_timestamp(ts: i64) -> chrono::DateTime<chrono::Utc> { | |
| 178 | 97 | chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now) | |
| @@ -199,74 +118,18 @@ pub fn parse_schedule_datetime(s: Option<&str>) -> Option<Option<chrono::DateTim | |||
| 199 | 118 | }) | |
| 200 | 119 | } | |
| 201 | 120 | ||
| 202 | - | /// IP key extractor that prefers `CF-Connecting-IP` (set by Cloudflare, cannot | |
| 203 | - | /// be spoofed by clients) over `X-Forwarded-For` (which can be spoofed if the | |
| 204 | - | /// proxy chain doesn't strip it). Falls back to `SmartIpKeyExtractor` behavior | |
| 205 | - | /// when `CF-Connecting-IP` is absent (e.g., direct/dev access without Cloudflare). | |
| 206 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 207 | - | pub struct CloudflareIpKeyExtractor; | |
| 208 | - | ||
| 209 | - | impl tower_governor::key_extractor::KeyExtractor for CloudflareIpKeyExtractor { | |
| 210 | - | type Key = std::net::IpAddr; | |
| 211 | - | ||
| 212 | - | fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, tower_governor::errors::GovernorError> { | |
| 213 | - | // Prefer CF-Connecting-IP (trusted, set by Cloudflare edge) | |
| 214 | - | if let Some(ip) = req | |
| 215 | - | .headers() | |
| 216 | - | .get("cf-connecting-ip") | |
| 217 | - | .and_then(|v: &axum::http::HeaderValue| v.to_str().ok()) | |
| 218 | - | .and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok()) | |
| 219 | - | { | |
| 220 | - | return Ok(ip); | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | // Fall back to SmartIpKeyExtractor behavior for non-Cloudflare environments | |
| 224 | - | tower_governor::key_extractor::SmartIpKeyExtractor.extract(req) | |
| 121 | + | /// Estimate Stripe's processing fee and the net amount the creator receives. | |
| 122 | + | /// | |
| 123 | + | /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard | |
| 124 | + | /// Stripe rate from [`constants`](crate::constants). | |
| 125 | + | pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) { | |
| 126 | + | if price_cents <= 0 { | |
| 127 | + | return (0, 0); | |
| 225 | 128 | } | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | /// Build a rate limiter config from a per-millisecond interval and burst size. | |
| 229 | - | /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers. | |
| 230 | - | pub fn rate_limiter_ms( | |
| 231 | - | ms: u64, | |
| 232 | - | burst: u32, | |
| 233 | - | ) -> std::sync::Arc< | |
| 234 | - | tower_governor::governor::GovernorConfig< | |
| 235 | - | CloudflareIpKeyExtractor, | |
| 236 | - | ::governor::middleware::StateInformationMiddleware, | |
| 237 | - | >, | |
| 238 | - | > { | |
| 239 | - | std::sync::Arc::new( | |
| 240 | - | tower_governor::governor::GovernorConfigBuilder::default() | |
| 241 | - | .key_extractor(CloudflareIpKeyExtractor) | |
| 242 | - | .per_millisecond(ms) | |
| 243 | - | .burst_size(burst) | |
| 244 | - | .use_headers() | |
| 245 | - | .finish() | |
| 246 | - | .expect("rate limiter config"), | |
| 247 | - | ) | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | /// Build a rate limiter config from a per-second rate and burst size. | |
| 251 | - | /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers. | |
| 252 | - | pub fn rate_limiter_per_sec( | |
| 253 | - | per_sec: u64, | |
| 254 | - | burst: u32, | |
| 255 | - | ) -> std::sync::Arc< | |
| 256 | - | tower_governor::governor::GovernorConfig< | |
| 257 | - | CloudflareIpKeyExtractor, | |
| 258 | - | ::governor::middleware::StateInformationMiddleware, | |
| 259 | - | >, | |
| 260 | - | > { | |
| 261 | - | std::sync::Arc::new( | |
| 262 | - | tower_governor::governor::GovernorConfigBuilder::default() | |
| 263 | - | .key_extractor(CloudflareIpKeyExtractor) | |
| 264 | - | .per_second(per_sec) | |
| 265 | - | .burst_size(burst) | |
| 266 | - | .use_headers() | |
| 267 | - | .finish() | |
| 268 | - | .expect("rate limiter config"), | |
| 269 | - | ) | |
| 129 | + | let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE | |
| 130 | + | + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32; | |
| 131 | + | let creator_receives = (price_cents - fee).max(0); | |
| 132 | + | (fee.min(price_cents), creator_receives) | |
| 270 | 133 | } | |
| 271 | 134 | ||
| 272 | 135 | /// Build an HTMX response that shows a toast notification with an empty body. | |
| @@ -293,100 +156,6 @@ pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue { | |||
| 293 | 156 | }) | |
| 294 | 157 | } | |
| 295 | 158 | ||
| 296 | - | /// Extract up to two uppercase initials from a name for avatar display. | |
| 297 | - | pub fn get_initials(name: &str) -> String { | |
| 298 | - | name.split_whitespace() | |
| 299 | - | .filter_map(|word| word.chars().next()) | |
| 300 | - | .take(2) | |
| 301 | - | .collect::<String>() | |
| 302 | - | .to_uppercase() | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | /// Generate a license key code in word-word-word-word-word format. | |
| 306 | - | /// | |
| 307 | - | /// Uses 5 random words from the 2048-word list (~55 bits of entropy). | |
| 308 | - | /// Returns a `KeyCode` via `from_trusted` — the wordlist guarantees validity. | |
| 309 | - | pub fn generate_key_code() -> crate::db::KeyCode { | |
| 310 | - | use rand::Rng; | |
| 311 | - | let mut rng = rand::rng(); | |
| 312 | - | let words: Vec<&str> = (0..5) | |
| 313 | - | .map(|_| { | |
| 314 | - | let idx = rng.random_range(0..crate::wordlist::WORDLIST.len()); | |
| 315 | - | crate::wordlist::WORDLIST[idx] | |
| 316 | - | }) | |
| 317 | - | .collect(); | |
| 318 | - | crate::db::KeyCode::from_trusted(words.join("-")) | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | /// Estimate Stripe's processing fee and the net amount the creator receives. | |
| 322 | - | /// | |
| 323 | - | /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard | |
| 324 | - | /// Stripe rate from [`constants`](crate::constants). | |
| 325 | - | pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) { | |
| 326 | - | if price_cents <= 0 { | |
| 327 | - | return (0, 0); | |
| 328 | - | } | |
| 329 | - | let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE | |
| 330 | - | + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32; | |
| 331 | - | let creator_receives = (price_cents - fee).max(0); | |
| 332 | - | (fee.min(price_cents), creator_receives) | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | /// Sanitize a string for use as a CSV cell value. | |
| 336 | - | /// | |
| 337 | - | /// Prevents CSV injection by quoting cells and escaping values that start | |
| 338 | - | /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`). | |
| 339 | - | /// Also handles embedded commas, quotes, and newlines per RFC 4180. | |
| 340 | - | pub fn sanitize_csv_cell(value: &str) -> String { | |
| 341 | - | let needs_prefix = value | |
| 342 | - | .chars() | |
| 343 | - | .next() | |
| 344 | - | .map(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r')) | |
| 345 | - | .unwrap_or(false); | |
| 346 | - | ||
| 347 | - | let escaped = value.replace('"', "\"\""); | |
| 348 | - | ||
| 349 | - | if needs_prefix { | |
| 350 | - | // Prefix with a single quote inside the quoted field to neutralize formulas | |
| 351 | - | format!("\"'{}\"", escaped) | |
| 352 | - | } else if value.contains(',') || value.contains('"') || value.contains('\n') { | |
| 353 | - | format!("\"{}\"", escaped) | |
| 354 | - | } else { | |
| 355 | - | escaped | |
| 356 | - | } | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | /// Generate an HMAC-signed personal RSS feed URL for a user. | |
| 360 | - | /// | |
| 361 | - | /// The URL is permanent (no expiry) and tied to the signing secret. | |
| 362 | - | /// If the secret rotates, old URLs become invalid. | |
| 363 | - | pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String { | |
| 364 | - | use hmac::{Hmac, Mac}; | |
| 365 | - | use sha2::Sha256; | |
| 366 | - | ||
| 367 | - | let message = format!("feed:{}", user_id); | |
| 368 | - | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 369 | - | .expect("HMAC-SHA256 accepts any key length"); | |
| 370 | - | mac.update(message.as_bytes()); | |
| 371 | - | let sig = hex::encode(mac.finalize().into_bytes()); | |
| 372 | - | ||
| 373 | - | format!("{}/feed/{}?sig={}", host_url, user_id, sig) | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | /// Verify a personal feed URL signature. | |
| 377 | - | pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool { | |
| 378 | - | use hmac::{Hmac, Mac}; | |
| 379 | - | use sha2::Sha256; | |
| 380 | - | ||
| 381 | - | let message = format!("feed:{}", user_id); | |
| 382 | - | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 383 | - | .expect("HMAC-SHA256 accepts any key length"); | |
| 384 | - | mac.update(message.as_bytes()); | |
| 385 | - | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 386 | - | ||
| 387 | - | constant_time_compare(&expected, signature) | |
| 388 | - | } | |
| 389 | - | ||
| 390 | 159 | /// Fetch MT discussion thread stats (URL + post count) for a linked thread. | |
| 391 | 160 | /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread. | |
| 392 | 161 | pub async fn fetch_discussion_info( | |
| @@ -419,7 +188,6 @@ pub async fn fetch_discussion_info( | |||
| 419 | 188 | Ok(Ok(stats)) => (Some(url), Some(stats.post_count)), | |
| 420 | 189 | Ok(Err(e)) => { | |
| 421 | 190 | tracing::debug!(error = ?e, "failed to fetch MT thread stats"); | |
| 422 | - | // Still return the URL even if stats failed | |
| 423 | 191 | (Some(url), None) | |
| 424 | 192 | } | |
| 425 | 193 | Err(_) => { | |
| @@ -468,172 +236,6 @@ mod tests { | |||
| 468 | 236 | assert!(!is_htmx_request(&headers)); | |
| 469 | 237 | } | |
| 470 | 238 | ||
| 471 | - | // ── constant_time_compare ── | |
| 472 | - | ||
| 473 | - | #[test] | |
| 474 | - | fn compare_equal_strings() { | |
| 475 | - | assert!(constant_time_compare("abc123", "abc123")); | |
| 476 | - | } | |
| 477 | - | ||
| 478 | - | #[test] | |
| 479 | - | fn compare_different_strings() { | |
| 480 | - | assert!(!constant_time_compare("abc123", "abc124")); | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | #[test] | |
| 484 | - | fn compare_different_lengths() { | |
| 485 | - | assert!(!constant_time_compare("short", "longer")); | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | #[test] | |
| 489 | - | fn compare_empty_strings() { | |
| 490 | - | assert!(constant_time_compare("", "")); | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | // ── slugify ── | |
| 494 | - | ||
| 495 | - | #[test] | |
| 496 | - | fn slugify_basic() { | |
| 497 | - | assert_eq!(slugify("Hello World").as_str(), "hello-world"); | |
| 498 | - | } | |
| 499 | - | ||
| 500 | - | #[test] | |
| 501 | - | fn slugify_special_chars() { | |
| 502 | - | assert_eq!(slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist"); | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | #[test] | |
| 506 | - | fn slugify_multiple_spaces() { | |
| 507 | - | assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces"); | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | #[test] | |
| 511 | - | fn slugify_leading_trailing_special() { | |
| 512 | - | assert_eq!(slugify("---hello---").as_str(), "hello"); | |
| 513 | - | } | |
| 514 | - | ||
| 515 | - | #[test] | |
| 516 | - | fn slugify_unicode() { | |
| 517 | - | // Non-ASCII alphanumeric chars are kept by is_alphanumeric | |
| 518 | - | let slug = slugify("café résumé"); | |
| 519 | - | assert!(slug.contains("caf")); | |
| 520 | - | assert!(!slug.contains(' ')); | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | #[test] | |
| 524 | - | fn slugify_too_short_falls_back() { | |
| 525 | - | assert_eq!(slugify("a").as_str(), "post"); | |
| 526 | - | assert_eq!(slugify("").as_str(), "post"); | |
| 527 | - | assert_eq!(slugify("---").as_str(), "post"); | |
| 528 | - | } | |
| 529 | - | ||
| 530 | - | #[test] | |
| 531 | - | fn slugify_numbers() { | |
| 532 | - | assert_eq!(slugify("Version 2.0").as_str(), "version-2-0"); | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | // ── format_price ── | |
| 536 | - | ||
| 537 | - | #[test] | |
| 538 | - | fn format_price_free() { | |
| 539 | - | assert_eq!(format_price(0), "Free"); | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | #[test] | |
| 543 | - | fn format_price_whole_dollars() { | |
| 544 | - | assert_eq!(format_price(500), "$5"); | |
| 545 | - | assert_eq!(format_price(100), "$1"); | |
| 546 | - | assert_eq!(format_price(10000), "$100"); | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | #[test] | |
| 550 | - | fn format_price_with_cents() { | |
| 551 | - | assert_eq!(format_price(999), "$9.99"); | |
| 552 | - | assert_eq!(format_price(150), "$1.50"); | |
| 553 | - | assert_eq!(format_price(1), "$0.01"); | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | // ── format_revenue ── | |
| 557 | - | ||
| 558 | - | #[test] | |
| 559 | - | fn format_revenue_zero() { | |
| 560 | - | assert_eq!(format_revenue(0), "$0.00"); | |
| 561 | - | } | |
| 562 | - | ||
| 563 | - | #[test] | |
| 564 | - | fn format_revenue_whole_dollars() { | |
| 565 | - | assert_eq!(format_revenue(500), "$5.00"); | |
| 566 | - | assert_eq!(format_revenue(10000), "$100.00"); | |
| 567 | - | } | |
| 568 | - | ||
| 569 | - | #[test] | |
| 570 | - | fn format_revenue_with_cents() { | |
| 571 | - | assert_eq!(format_revenue(999), "$9.99"); | |
| 572 | - | assert_eq!(format_revenue(150), "$1.50"); | |
| 573 | - | assert_eq!(format_revenue(1), "$0.01"); | |
| 574 | - | } | |
| 575 | - | ||
| 576 | - | #[test] | |
| 577 | - | fn format_revenue_large_amount() { | |
| 578 | - | assert_eq!(format_revenue(1_000_000), "$10000.00"); | |
| 579 | - | } | |
| 580 | - | ||
| 581 | - | // ── format_file_size ── | |
| 582 | - | ||
| 583 | - | #[test] | |
| 584 | - | fn format_file_size_zero() { | |
| 585 | - | assert_eq!(format_file_size(0), "N/A"); | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | #[test] | |
| 589 | - | fn format_file_size_bytes() { | |
| 590 | - | assert_eq!(format_file_size(512), "512 B"); | |
| 591 | - | assert_eq!(format_file_size(1), "1 B"); | |
| 592 | - | } | |
| 593 | - | ||
| 594 | - | #[test] | |
| 595 | - | fn format_file_size_kilobytes() { | |
| 596 | - | assert_eq!(format_file_size(1024), "1.0 KB"); | |
| 597 | - | assert_eq!(format_file_size(1536), "1.5 KB"); | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | #[test] | |
| 601 | - | fn format_file_size_megabytes() { | |
| 602 | - | assert_eq!(format_file_size(1024 * 1024), "1.0 MB"); | |
| 603 | - | assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB"); | |
| 604 | - | } | |
| 605 | - | ||
| 606 | - | #[test] | |
| 607 | - | fn format_bytes_zero() { | |
| 608 | - | assert_eq!(format_bytes(0), "0 B"); | |
| 609 | - | } | |
| 610 | - | ||
| 611 | - | #[test] | |
| 612 | - | fn format_bytes_small() { | |
| 613 | - | assert_eq!(format_bytes(512), "512 B"); | |
| 614 | - | } | |
| 615 | - | ||
| 616 | - | #[test] | |
| 617 | - | fn format_bytes_megabytes() { | |
| 618 | - | assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB"); | |
| 619 | - | } | |
| 620 | - | ||
| 621 | - | #[test] | |
| 622 | - | fn format_bytes_gigabytes() { | |
| 623 | - | assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB"); | |
| 624 | - | } | |
| 625 | - | ||
| 626 | - | #[test] | |
| 627 | - | fn format_bytes_negative_clamped() { | |
| 628 | - | assert_eq!(format_bytes(-100), "0 B"); | |
| 629 | - | } | |
| 630 | - | ||
| 631 | - | #[test] | |
| 632 | - | fn format_file_size_gigabytes() { | |
| 633 | - | assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); | |
| 634 | - | assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB"); | |
| 635 | - | } | |
| 636 | - | ||
| 637 | 239 | // ── hx_toast ── | |
| 638 | 240 | ||
| 639 | 241 | #[test] | |
| @@ -642,8 +244,6 @@ mod tests { | |||
| 642 | 244 | let s = val.to_str().unwrap(); | |
| 643 | 245 | assert!(s.contains("showToast")); |
Lines truncated
| @@ -11,7 +11,10 @@ pub mod error; | |||
| 11 | 11 | pub mod git; | |
| 12 | 12 | pub mod git_ssh; | |
| 13 | 13 | pub mod license_templates; | |
| 14 | + | pub mod crypto; | |
| 15 | + | pub mod formatting; | |
| 14 | 16 | pub mod helpers; | |
| 17 | + | pub mod rate_limit; | |
| 15 | 18 | pub mod import; | |
| 16 | 19 | pub mod markdown; | |
| 17 | 20 | pub mod metrics; |
| @@ -0,0 +1,69 @@ | |||
| 1 | + | //! Rate limiting: Cloudflare-aware IP extraction and governor config builders. | |
| 2 | + | ||
| 3 | + | /// IP key extractor that prefers `CF-Connecting-IP` (set by Cloudflare, cannot | |
| 4 | + | /// be spoofed by clients) over `X-Forwarded-For` (which can be spoofed if the | |
| 5 | + | /// proxy chain doesn't strip it). Falls back to `SmartIpKeyExtractor` behavior | |
| 6 | + | /// when `CF-Connecting-IP` is absent (e.g., direct/dev access without Cloudflare). | |
| 7 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 8 | + | pub struct CloudflareIpKeyExtractor; | |
| 9 | + | ||
| 10 | + | impl tower_governor::key_extractor::KeyExtractor for CloudflareIpKeyExtractor { | |
| 11 | + | type Key = std::net::IpAddr; | |
| 12 | + | ||
| 13 | + | fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, tower_governor::errors::GovernorError> { | |
| 14 | + | if let Some(ip) = req | |
| 15 | + | .headers() | |
| 16 | + | .get("cf-connecting-ip") | |
| 17 | + | .and_then(|v: &axum::http::HeaderValue| v.to_str().ok()) | |
| 18 | + | .and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok()) | |
| 19 | + | { | |
| 20 | + | return Ok(ip); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | tower_governor::key_extractor::SmartIpKeyExtractor.extract(req) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Build a rate limiter config from a per-millisecond interval and burst size. | |
| 28 | + | /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers. | |
| 29 | + | pub fn rate_limiter_ms( | |
| 30 | + | ms: u64, | |
| 31 | + | burst: u32, | |
| 32 | + | ) -> std::sync::Arc< | |
| 33 | + | tower_governor::governor::GovernorConfig< | |
| 34 | + | CloudflareIpKeyExtractor, | |
| 35 | + | ::governor::middleware::StateInformationMiddleware, | |
| 36 | + | >, | |
| 37 | + | > { | |
| 38 | + | std::sync::Arc::new( | |
| 39 | + | tower_governor::governor::GovernorConfigBuilder::default() | |
| 40 | + | .key_extractor(CloudflareIpKeyExtractor) | |
| 41 | + | .per_millisecond(ms) | |
| 42 | + | .burst_size(burst) | |
| 43 | + | .use_headers() | |
| 44 | + | .finish() | |
| 45 | + | .expect("rate limiter config"), | |
| 46 | + | ) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Build a rate limiter config from a per-second rate and burst size. | |
| 50 | + | /// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers. | |
| 51 | + | pub fn rate_limiter_per_sec( | |
| 52 | + | per_sec: u64, | |
| 53 | + | burst: u32, | |
| 54 | + | ) -> std::sync::Arc< | |
| 55 | + | tower_governor::governor::GovernorConfig< | |
| 56 | + | CloudflareIpKeyExtractor, | |
| 57 | + | ::governor::middleware::StateInformationMiddleware, | |
| 58 | + | >, | |
| 59 | + | > { | |
| 60 | + | std::sync::Arc::new( | |
| 61 | + | tower_governor::governor::GovernorConfigBuilder::default() | |
| 62 | + | .key_extractor(CloudflareIpKeyExtractor) | |
| 63 | + | .per_second(per_sec) | |
| 64 | + | .burst_size(burst) | |
| 65 | + | .use_headers() | |
| 66 | + | .finish() | |
| 67 | + | .expect("rate limiter config"), | |
| 68 | + | ) | |
| 69 | + | } |