max / makenotwork
3 files changed,
+59 insertions,
-44 deletions
| @@ -68,6 +68,36 @@ struct DiscoverData { | |||
| 68 | 68 | showing_end: u32, | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | + | /// Clamp the discover price filters to a sane range. A negative bound is | |
| 72 | + | /// meaningless (prices are non-negative cents) and is dropped; an inverted range | |
| 73 | + | /// (min > max) can only match nothing, so both bounds are dropped rather than | |
| 74 | + | /// issuing an empty-by-construction query. | |
| 75 | + | fn sanitize_price_range(min: Option<i32>, max: Option<i32>) -> (Option<i32>, Option<i32>) { | |
| 76 | + | let min = min.filter(|&v| v >= 0); | |
| 77 | + | let max = max.filter(|&v| v >= 0); | |
| 78 | + | if let (Some(lo), Some(hi)) = (min, max) | |
| 79 | + | && lo > hi | |
| 80 | + | { | |
| 81 | + | return (None, None); | |
| 82 | + | } | |
| 83 | + | (min, max) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | #[cfg(test)] | |
| 87 | + | mod price_range_tests { | |
| 88 | + | use super::sanitize_price_range; | |
| 89 | + | ||
| 90 | + | #[test] | |
| 91 | + | fn drops_negatives_and_inverted_ranges() { | |
| 92 | + | assert_eq!(sanitize_price_range(Some(100), Some(500)), (Some(100), Some(500))); | |
| 93 | + | assert_eq!(sanitize_price_range(Some(-1), Some(500)), (None, Some(500))); | |
| 94 | + | assert_eq!(sanitize_price_range(Some(100), Some(-5)), (Some(100), None)); | |
| 95 | + | // inverted range can only match nothing -> drop both | |
| 96 | + | assert_eq!(sanitize_price_range(Some(500), Some(100)), (None, None)); | |
| 97 | + | assert_eq!(sanitize_price_range(None, None), (None, None)); | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 71 | 101 | /// Fetch items or projects with pagination; shared by both handlers. | |
| 72 | 102 | async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> { | |
| 73 | 103 | // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a | |
| @@ -123,12 +153,13 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis | |||
| 123 | 153 | .filter(|s| !s.is_empty()) | |
| 124 | 154 | .and_then(|s| s.parse().ok()); | |
| 125 | 155 | ||
| 156 | + | let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price); | |
| 126 | 157 | let filters = DiscoverFilters { | |
| 127 | 158 | search: search_filter, | |
| 128 | 159 | item_type: item_type_filter, | |
| 129 | 160 | tag: tag_filter, | |
| 130 | - | min_price: query.min_price, | |
| 131 | - | max_price: query.max_price, | |
| 161 | + | min_price, | |
| 162 | + | max_price, | |
| 132 | 163 | sort_by: sort_filter, | |
| 133 | 164 | ai_tier: ai_tier_filter, | |
| 134 | 165 | }; |
| @@ -1,6 +1,5 @@ | |||
| 1 | 1 | //! Validators for items, chapters, tags, blog posts, collections, and related content. | |
| 2 | 2 | ||
| 3 | - | use crate::constants; | |
| 4 | 3 | use crate::error::AppError; | |
| 5 | 4 | use super::limits; | |
| 6 | 5 | ||
| @@ -242,17 +241,9 @@ pub fn validate_section_body(body: &str) -> Result<(), AppError> { | |||
| 242 | 241 | Ok(()) | |
| 243 | 242 | } | |
| 244 | 243 | ||
| 245 | - | /// Validate price in cents (must be non-negative) | |
| 246 | - | pub fn validate_price_cents(price: i32) -> Result<(), AppError> { | |
| 247 | - | if price < 0 { | |
| 248 | - | return Err(AppError::validation("Price cannot be negative".to_string())); | |
| 249 | - | } | |
| 250 | - | // Cap at $10,000 | |
| 251 | - | if price > constants::MAX_PRICE_CENTS { | |
| 252 | - | return Err(AppError::validation("Price cannot exceed $10,000".to_string())); | |
| 253 | - | } | |
| 254 | - | Ok(()) | |
| 255 | - | } | |
| 244 | + | // Price validation lives on the `PriceCents` newtype (`db::validated_types`), | |
| 245 | + | // which every live write path constructs — the former free-standing | |
| 246 | + | // `validate_price_cents` here was a dead second source of truth and was removed. | |
| 256 | 247 | ||
| 257 | 248 | #[cfg(test)] | |
| 258 | 249 | mod tests { | |
| @@ -364,15 +355,6 @@ mod tests { | |||
| 364 | 355 | } | |
| 365 | 356 | ||
| 366 | 357 | #[test] | |
| 367 | - | fn test_validate_price_cents() { | |
| 368 | - | assert!(validate_price_cents(0).is_ok()); | |
| 369 | - | assert!(validate_price_cents(999).is_ok()); | |
| 370 | - | assert!(validate_price_cents(1_000_000).is_ok()); // $10,000 | |
| 371 | - | assert!(validate_price_cents(-1).is_err()); // negative | |
| 372 | - | assert!(validate_price_cents(1_000_001).is_err()); // over cap | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | #[test] | |
| 376 | 358 | fn test_validate_section_title() { | |
| 377 | 359 | assert!(validate_section_title("Features").is_ok()); | |
| 378 | 360 | assert!(validate_section_title(" Features ").is_ok()); // trimmed | |
| @@ -443,18 +425,6 @@ mod tests { | |||
| 443 | 425 | } | |
| 444 | 426 | ||
| 445 | 427 | #[test] | |
| 446 | - | fn test_validate_price_cents_exact_max() { | |
| 447 | - | assert!(validate_price_cents(constants::MAX_PRICE_CENTS).is_ok()); | |
| 448 | - | assert!(validate_price_cents(constants::MAX_PRICE_CENTS + 1).is_err()); | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | #[test] | |
| 452 | - | fn test_validate_price_cents_i32_extremes() { | |
| 453 | - | assert!(validate_price_cents(i32::MIN).is_err()); | |
| 454 | - | assert!(validate_price_cents(i32::MAX).is_err()); | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | #[test] | |
| 458 | 428 | fn test_validate_section_title_only_whitespace_padded() { | |
| 459 | 429 | // Leading/trailing whitespace with content in the middle should pass | |
| 460 | 430 | assert!(validate_section_title(" Features ").is_ok()); | |
| @@ -506,14 +476,5 @@ mod tests { | |||
| 506 | 476 | } | |
| 507 | 477 | } | |
| 508 | 478 | ||
| 509 | - | #[test] | |
| 510 | - | fn prop_price_cents_valid_range(price in 0..=1_000_000i32) { | |
| 511 | - | proptest::prop_assert!(validate_price_cents(price).is_ok(), "Valid price rejected: {}", price); | |
| 512 | - | } | |
| 513 | - | ||
| 514 | - | #[test] | |
| 515 | - | fn prop_price_cents_negative_always_rejected(price in i32::MIN..0i32) { | |
| 516 | - | proptest::prop_assert!(validate_price_cents(price).is_err(), "Negative price accepted: {}", price); | |
| 517 | - | } | |
| 518 | 479 | } | |
| 519 | 480 | } |
| @@ -130,6 +130,13 @@ pub fn validate_activation_label(label: &str) -> Result<(), AppError> { | |||
| 130 | 130 | limits::ACTIVATION_LABEL_MAX | |
| 131 | 131 | ))); | |
| 132 | 132 | } | |
| 133 | + | // Reject control characters (parity with display_name/bio) — a label with | |
| 134 | + | // embedded newlines/escapes would corrupt logs and admin views. | |
| 135 | + | if label.chars().any(|c| c.is_control()) { | |
| 136 | + | return Err(AppError::validation( | |
| 137 | + | "Label cannot contain control characters".to_string(), | |
| 138 | + | )); | |
| 139 | + | } | |
| 133 | 140 | Ok(()) | |
| 134 | 141 | } | |
| 135 | 142 | ||
| @@ -211,6 +218,12 @@ pub fn validate_ssh_key_label(label: &str) -> std::result::Result<(), AppError> | |||
| 211 | 218 | limits::SSH_KEY_LABEL_MAX | |
| 212 | 219 | ))); | |
| 213 | 220 | } | |
| 221 | + | // Reject control characters (parity with display_name/bio). | |
| 222 | + | if label.chars().any(|c| c.is_control()) { | |
| 223 | + | return Err(AppError::validation( | |
| 224 | + | "SSH key label cannot contain control characters".to_string(), | |
| 225 | + | )); | |
| 226 | + | } | |
| 214 | 227 | Ok(()) | |
| 215 | 228 | } | |
| 216 | 229 | ||
| @@ -237,6 +250,16 @@ mod tests { | |||
| 237 | 250 | } | |
| 238 | 251 | ||
| 239 | 252 | #[test] | |
| 253 | + | fn test_activation_and_ssh_labels_reject_control_chars() { | |
| 254 | + | assert!(validate_activation_label("Living Room").is_ok()); | |
| 255 | + | assert!(validate_activation_label("Living\nRoom").is_err()); | |
| 256 | + | assert!(validate_activation_label("tab\there").is_err()); | |
| 257 | + | assert!(validate_ssh_key_label("laptop").is_ok()); | |
| 258 | + | assert!(validate_ssh_key_label("laptop\0").is_err()); | |
| 259 | + | assert!(validate_ssh_key_label("esc\u{1b}ape").is_err()); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | #[test] | |
| 240 | 263 | fn test_validate_bio() { | |
| 241 | 264 | assert!(validate_bio("I make music").is_ok()); | |
| 242 | 265 | assert!(validate_bio("").is_ok()); |