max / makenotwork
35 files changed,
+387 insertions,
-200 deletions
| @@ -12,6 +12,61 @@ | |||
| 12 | 12 | #[derive(Clone)] | |
| 13 | 13 | pub struct ApiErrorMessage(pub String); | |
| 14 | 14 | ||
| 15 | + | /// Payload carried by `AppError::Validation`. | |
| 16 | + | /// | |
| 17 | + | /// Always has a `summary` (the user-visible banner above the form). Optionally | |
| 18 | + | /// carries one or more `(field, message)` pairs so form templates can highlight | |
| 19 | + | /// specific inputs instead of rendering only a global banner. | |
| 20 | + | /// | |
| 21 | + | /// Construct via `From<String>` / `From<&str>` when you only have a message, | |
| 22 | + | /// or use `ValidationError::new(summary).with_field(...)` for field-scoped | |
| 23 | + | /// errors. The matching `AppError::validation(msg)` and | |
| 24 | + | /// `AppError::validation_fields(summary, fields)` constructors are the usual | |
| 25 | + | /// entry points from handlers. | |
| 26 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 27 | + | pub struct ValidationError { | |
| 28 | + | pub summary: String, | |
| 29 | + | pub fields: Vec<(String, String)>, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl ValidationError { | |
| 33 | + | pub fn new(summary: impl Into<String>) -> Self { | |
| 34 | + | Self { | |
| 35 | + | summary: summary.into(), | |
| 36 | + | fields: Vec::new(), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | pub fn with_field(mut self, field: impl Into<String>, message: impl Into<String>) -> Self { | |
| 41 | + | self.fields.push((field.into(), message.into())); | |
| 42 | + | self | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl std::fmt::Display for ValidationError { | |
| 47 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 48 | + | f.write_str(&self.summary) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | impl From<String> for ValidationError { | |
| 53 | + | fn from(summary: String) -> Self { | |
| 54 | + | ValidationError::new(summary) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl From<&str> for ValidationError { | |
| 59 | + | fn from(summary: &str) -> Self { | |
| 60 | + | ValidationError::new(summary.to_string()) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | impl From<&String> for ValidationError { | |
| 65 | + | fn from(summary: &String) -> Self { | |
| 66 | + | ValidationError::new(summary.clone()) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 15 | 70 | /// Application error type that can be converted into an HTTP response | |
| 16 | 71 | #[derive(Debug, thiserror::Error)] | |
| 17 | 72 | pub enum AppError { | |
| @@ -28,7 +83,7 @@ | |||
| 28 | 83 | BadRequest(String), | |
| 29 | 84 | ||
| 30 | 85 | #[error("Validation error: {0}")] | |
| 31 | - | Validation(String), | |
| 86 | + | Validation(ValidationError), | |
| 32 | 87 | ||
| 33 | 88 | #[error("Database error: {0}")] | |
| 34 | 89 | Database(#[from] sqlx::Error), | |
| @@ -59,6 +114,42 @@ | |||
| 59 | 114 | } | |
| 60 | 115 | ||
| 61 | 116 | impl AppError { | |
| 117 | + | /// Construct a plain validation error with just a summary message. | |
| 118 | + | /// | |
| 119 | + | /// Equivalent to the old `AppError::validation("msg".to_string())` shape — | |
| 120 | + | /// all 187 mechanical migration sites use this. Reach for | |
| 121 | + | /// [`AppError::validation_fields`] instead when you know which input was | |
| 122 | + | /// at fault and want the form template to highlight it. | |
| 123 | + | pub fn validation(summary: impl Into<ValidationError>) -> Self { | |
| 124 | + | AppError::Validation(summary.into()) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Construct a field-scoped validation error. | |
| 128 | + | /// | |
| 129 | + | /// `summary` is the banner shown above the form; `fields` is a list of | |
| 130 | + | /// `(field_name, message)` pairs the template uses to attach errors to | |
| 131 | + | /// specific inputs. Field names must match the corresponding `<input | |
| 132 | + | /// name=...>` so the template can pair them up. | |
| 133 | + | pub fn validation_fields( | |
| 134 | + | summary: impl Into<String>, | |
| 135 | + | fields: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>, | |
| 136 | + | ) -> Self { | |
| 137 | + | let mut err = ValidationError::new(summary); | |
| 138 | + | for (k, v) in fields { | |
| 139 | + | err = err.with_field(k, v); | |
| 140 | + | } | |
| 141 | + | AppError::Validation(err) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// If this error is a validation error with per-field detail, return the | |
| 145 | + | /// field list. Templates use this to render per-input error messages. | |
| 146 | + | pub fn validation_fields_ref(&self) -> Option<&[(String, String)]> { | |
| 147 | + | match self { | |
| 148 | + | AppError::Validation(v) if !v.fields.is_empty() => Some(&v.fields), | |
| 149 | + | _ => None, | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 62 | 153 | /// Static tag for Prometheus metric labels (e.g. `kind="database"`) | |
| 63 | 154 | pub fn tag(&self) -> &'static str { | |
| 64 | 155 | match self { | |
| @@ -106,7 +197,7 @@ | |||
| 106 | 197 | AppError::Unauthorized => "You need to log in to access this page.".to_string(), | |
| 107 | 198 | AppError::Forbidden => "You don't have permission to access this page.".to_string(), | |
| 108 | 199 | AppError::BadRequest(msg) => msg.clone(), | |
| 109 | - | AppError::Validation(msg) => msg.clone(), | |
| 200 | + | AppError::Validation(v) => v.summary.clone(), | |
| 110 | 201 | AppError::InvalidFileType(msg) => msg.clone(), | |
| 111 | 202 | AppError::FileTooLarge(msg) => msg.clone(), | |
| 112 | 203 | AppError::MalwareDetected(_) => { | |
| @@ -238,7 +329,7 @@ | |||
| 238 | 329 | #[test] | |
| 239 | 330 | fn status_code_validation() { | |
| 240 | 331 | assert_eq!( | |
| 241 | - | AppError::Validation("test".into()).status_code(), | |
| 332 | + | AppError::validation("test").status_code(), | |
| 242 | 333 | StatusCode::UNPROCESSABLE_ENTITY | |
| 243 | 334 | ); | |
| 244 | 335 | } | |
| @@ -258,7 +349,7 @@ | |||
| 258 | 349 | ||
| 259 | 350 | #[test] | |
| 260 | 351 | fn user_message_validation_passes_through() { | |
| 261 | - | let msg = AppError::Validation("Name too long".into()).user_message(); | |
| 352 | + | let msg = AppError::validation("Name too long").user_message(); | |
| 262 | 353 | assert_eq!(msg, "Name too long"); | |
| 263 | 354 | } | |
| 264 | 355 | ||
| @@ -319,7 +410,7 @@ | |||
| 319 | 410 | ||
| 320 | 411 | #[test] | |
| 321 | 412 | fn into_response_validation() { | |
| 322 | - | let (status, body, _) = response_status_and_body(AppError::Validation("too long".into())); | |
| 413 | + | let (status, body, _) = response_status_and_body(AppError::validation("too long")); | |
| 323 | 414 | assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); | |
| 324 | 415 | assert_eq!(body, "too long"); | |
| 325 | 416 | } | |
| @@ -433,7 +524,7 @@ | |||
| 433 | 524 | assert_eq!(AppError::Unauthorized.tag(), "unauthorized"); | |
| 434 | 525 | assert_eq!(AppError::Forbidden.tag(), "forbidden"); | |
| 435 | 526 | assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request"); | |
| 436 | - | assert_eq!(AppError::Validation("x".into()).tag(), "validation"); | |
| 527 | + | assert_eq!(AppError::validation("x").tag(), "validation"); | |
| 437 | 528 | assert_eq!(AppError::Storage("x".into()).tag(), "storage"); | |
| 438 | 529 | assert_eq!(AppError::InvalidFileType("x".into()).tag(), "invalid_file_type"); | |
| 439 | 530 | assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large"); | |
| @@ -456,4 +547,70 @@ | |||
| 456 | 547 | assert!(!msg.contains("Win.Trojan")); | |
| 457 | 548 | assert!(msg.contains("security scanner")); | |
| 458 | 549 | } | |
| 550 | + | ||
| 551 | + | // ── ValidationError + field-scoped Validation ─────────────────────── | |
| 552 | + | ||
| 553 | + | #[test] | |
| 554 | + | fn validation_error_from_string_has_no_fields() { | |
| 555 | + | let v: ValidationError = "boom".to_string().into(); | |
| 556 | + | assert_eq!(v.summary, "boom"); | |
| 557 | + | assert!(v.fields.is_empty()); | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | #[test] | |
| 561 | + | fn validation_error_from_str_has_no_fields() { | |
| 562 | + | let v: ValidationError = "boom".into(); | |
| 563 | + | assert_eq!(v.summary, "boom"); | |
| 564 | + | assert!(v.fields.is_empty()); | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | #[test] | |
| 568 | + | fn validation_error_with_field_chains() { | |
| 569 | + | let v = ValidationError::new("Please fix the fields") | |
| 570 | + | .with_field("username", "Already taken") | |
| 571 | + | .with_field("email", "Invalid"); | |
| 572 | + | assert_eq!(v.summary, "Please fix the fields"); | |
| 573 | + | assert_eq!(v.fields.len(), 2); | |
| 574 | + | assert_eq!(v.fields[0], ("username".to_string(), "Already taken".to_string())); | |
| 575 | + | assert_eq!(v.fields[1], ("email".to_string(), "Invalid".to_string())); | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | #[test] | |
| 579 | + | fn validation_constructor_accepts_plain_string() { | |
| 580 | + | // The most common call shape across the 187 migrated sites. | |
| 581 | + | let e = AppError::validation("nope"); | |
| 582 | + | assert_eq!(e.tag(), "validation"); | |
| 583 | + | assert_eq!(e.user_message(), "nope"); | |
| 584 | + | assert!(e.validation_fields_ref().is_none()); | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | #[test] | |
| 588 | + | fn validation_constructor_accepts_owned_string() { | |
| 589 | + | let e = AppError::validation("nope".to_string()); | |
| 590 | + | assert_eq!(e.user_message(), "nope"); | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | #[test] | |
| 594 | + | fn validation_fields_constructor_exposes_field_list() { | |
| 595 | + | let e = AppError::validation_fields( | |
| 596 | + | "Please fix the highlighted fields", | |
| 597 | + | [("username", "Already taken"), ("email", "Invalid")], | |
| 598 | + | ); | |
| 599 | + | assert_eq!(e.tag(), "validation"); | |
| 600 | + | assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY); | |
| 601 | + | assert_eq!(e.user_message(), "Please fix the highlighted fields"); | |
| 602 | + | let fields = e.validation_fields_ref().expect("fields present"); | |
| 603 | + | assert_eq!(fields.len(), 2); | |
| 604 | + | assert_eq!(fields[0].0, "username"); | |
| 605 | + | assert_eq!(fields[1].0, "email"); | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | #[test] | |
| 609 | + | fn validation_without_fields_returns_none_from_ref() { | |
| 610 | + | // The accessor is "fields-only" — a plain validation error returns | |
| 611 | + | // None so templates can branch on field-awareness without filtering | |
| 612 | + | // empty vecs. | |
| 613 | + | let e = AppError::validation("just a summary"); | |
| 614 | + | assert!(e.validation_fields_ref().is_none()); | |
| 615 | + | } | |
| 459 | 616 | } |
| @@ -146,8 +146,8 @@ | |||
| 146 | 146 | if normalized.len() > EMAIL_MAX_LEN | |
| 147 | 147 | || !email_address::EmailAddress::is_valid(&normalized) | |
| 148 | 148 | { | |
| 149 | - | return Err(AppError::Validation( | |
| 150 | - | "Please enter a valid email address".into(), | |
| 149 | + | return Err(AppError::validation( | |
| 150 | + | "Please enter a valid email address", | |
| 151 | 151 | )); | |
| 152 | 152 | } | |
| 153 | 153 | Ok(Self(normalized)) | |
| @@ -388,12 +388,12 @@ | |||
| 388 | 388 | /// Validate and construct. Rejects negative values and values exceeding the cap. | |
| 389 | 389 | pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> { | |
| 390 | 390 | if cents < 0 { | |
| 391 | - | return Err(crate::error::AppError::Validation( | |
| 391 | + | return Err(crate::error::AppError::validation( | |
| 392 | 392 | "Price cannot be negative".to_string(), | |
| 393 | 393 | )); | |
| 394 | 394 | } | |
| 395 | 395 | if cents > crate::constants::MAX_PRICE_CENTS { | |
| 396 | - | return Err(crate::error::AppError::Validation( | |
| 396 | + | return Err(crate::error::AppError::validation( | |
| 397 | 397 | "Price cannot exceed $10,000".to_string(), | |
| 398 | 398 | )); | |
| 399 | 399 | } |
| @@ -128,8 +128,8 @@ | |||
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | 130 | if payload.subscribers.is_empty() && payload.transactions.is_empty() { | |
| 131 | - | return Err(AppError::Validation( | |
| 132 | - | "No valid rows found. Check your column mapping and CSV format.".into(), | |
| 131 | + | return Err(AppError::validation( | |
| 132 | + | "No valid rows found. Check your column mapping and CSV format.", | |
| 133 | 133 | )); | |
| 134 | 134 | } | |
| 135 | 135 |
| @@ -44,9 +44,9 @@ | |||
| 44 | 44 | /// Validate build_command and artifact_path for shell safety. | |
| 45 | 45 | fn validate_build_config_fields(build_command: &str, artifact_path: &str) -> Result<()> { | |
| 46 | 46 | crate::build_runner::validate_build_command(build_command) | |
| 47 | - | .map_err(|e| AppError::Validation(format!("build_command: {e}")))?; | |
| 47 | + | .map_err(|e| AppError::validation(format!("build_command: {e}")))?; | |
| 48 | 48 | crate::build_runner::validate_artifact_path(artifact_path) | |
| 49 | - | .map_err(|e| AppError::Validation(format!("artifact_path: {e}")))?; | |
| 49 | + | .map_err(|e| AppError::validation(format!("artifact_path: {e}")))?; | |
| 50 | 50 | Ok(()) | |
| 51 | 51 | } | |
| 52 | 52 |
| @@ -7,10 +7,10 @@ | |||
| 7 | 7 | /// Validate an item title | |
| 8 | 8 | pub fn validate_item_title(title: &str) -> Result<(), AppError> { | |
| 9 | 9 | if title.is_empty() { | |
| 10 | - | return Err(AppError::Validation("Title is required".to_string())); | |
| 10 | + | return Err(AppError::validation("Title is required".to_string())); | |
| 11 | 11 | } | |
| 12 | 12 | if title.chars().count() > limits::ITEM_TITLE_MAX { | |
| 13 | - | return Err(AppError::Validation(format!( | |
| 13 | + | return Err(AppError::validation(format!( | |
| 14 | 14 | "Title must be {} characters or less", | |
| 15 | 15 | limits::ITEM_TITLE_MAX | |
| 16 | 16 | ))); | |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | /// Validate an item description | |
| 22 | 22 | pub fn validate_item_description(description: &str) -> Result<(), AppError> { | |
| 23 | 23 | if description.chars().count() > limits::ITEM_DESCRIPTION_MAX { | |
| 24 | - | return Err(AppError::Validation(format!( | |
| 24 | + | return Err(AppError::validation(format!( | |
| 25 | 25 | "Description must be {} characters or less", | |
| 26 | 26 | limits::ITEM_DESCRIPTION_MAX | |
| 27 | 27 | ))); | |
| @@ -32,10 +32,10 @@ | |||
| 32 | 32 | /// Validate a chapter title | |
| 33 | 33 | pub fn validate_chapter_title(title: &str) -> Result<(), AppError> { | |
| 34 | 34 | if title.is_empty() { | |
| 35 | - | return Err(AppError::Validation("Chapter title is required".to_string())); | |
| 35 | + | return Err(AppError::validation("Chapter title is required".to_string())); | |
| 36 | 36 | } | |
| 37 | 37 | if title.chars().count() > limits::CHAPTER_TITLE_MAX { | |
| 38 | - | return Err(AppError::Validation(format!( | |
| 38 | + | return Err(AppError::validation(format!( | |
| 39 | 39 | "Chapter title must be {} characters or less", | |
| 40 | 40 | limits::CHAPTER_TITLE_MAX | |
| 41 | 41 | ))); | |
| @@ -46,7 +46,7 @@ | |||
| 46 | 46 | /// Validate an item text body | |
| 47 | 47 | pub fn validate_item_text_body(body: &str) -> Result<(), AppError> { | |
| 48 | 48 | if body.chars().count() > limits::ITEM_TEXT_BODY_MAX { | |
| 49 | - | return Err(AppError::Validation(format!( | |
| 49 | + | return Err(AppError::validation(format!( | |
| 50 | 50 | "Text body must be {} characters or less", | |
| 51 | 51 | limits::ITEM_TEXT_BODY_MAX | |
| 52 | 52 | ))); | |
| @@ -60,17 +60,17 @@ | |||
| 60 | 60 | /// so this is only needed when creating new tags. | |
| 61 | 61 | pub fn validate_tag_name(name: &str) -> Result<(), AppError> { | |
| 62 | 62 | if name.is_empty() { | |
| 63 | - | return Err(AppError::Validation("Tag name cannot be empty".to_string())); | |
| 63 | + | return Err(AppError::validation("Tag name cannot be empty".to_string())); | |
| 64 | 64 | } | |
| 65 | 65 | if name.chars().count() > limits::TAG_MAX { | |
| 66 | - | return Err(AppError::Validation(format!( | |
| 66 | + | return Err(AppError::validation(format!( | |
| 67 | 67 | "Tag name must be {} characters or less", | |
| 68 | 68 | limits::TAG_MAX | |
| 69 | 69 | ))); | |
| 70 | 70 | } | |
| 71 | 71 | // Tags can only contain alphanumeric characters, spaces, and hyphens | |
| 72 | 72 | if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-') { | |
| 73 | - | return Err(AppError::Validation( | |
| 73 | + | return Err(AppError::validation( | |
| 74 | 74 | "Tag names can only contain letters, numbers, spaces, and hyphens".to_string(), | |
| 75 | 75 | )); | |
| 76 | 76 | } | |
| @@ -90,16 +90,16 @@ | |||
| 90 | 90 | ||
| 91 | 91 | pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> { | |
| 92 | 92 | tagtree::validate_with(slug, &MNW_TAG_CONFIG) | |
| 93 | - | .map_err(|e| AppError::Validation(format!("Invalid tag: {}", e.0))) | |
| 93 | + | .map_err(|e| AppError::validation(format!("Invalid tag: {}", e.0))) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | /// Validate a version number | |
| 97 | 97 | pub fn validate_version_number(version: &str) -> Result<(), AppError> { | |
| 98 | 98 | if version.is_empty() { | |
| 99 | - | return Err(AppError::Validation("Version number is required".to_string())); | |
| 99 | + | return Err(AppError::validation("Version number is required".to_string())); | |
| 100 | 100 | } | |
| 101 | 101 | if version.chars().count() > limits::VERSION_NUMBER_MAX { | |
| 102 | - | return Err(AppError::Validation(format!( | |
| 102 | + | return Err(AppError::validation(format!( | |
| 103 | 103 | "Version number must be {} characters or less", | |
| 104 | 104 | limits::VERSION_NUMBER_MAX | |
| 105 | 105 | ))); | |
| @@ -110,7 +110,7 @@ | |||
| 110 | 110 | /// Validate changelog text | |
| 111 | 111 | pub fn validate_changelog(changelog: &str) -> Result<(), AppError> { | |
| 112 | 112 | if changelog.chars().count() > limits::CHANGELOG_MAX { | |
| 113 | - | return Err(AppError::Validation(format!( | |
| 113 | + | return Err(AppError::validation(format!( | |
| 114 | 114 | "Changelog must be {} characters or less", | |
| 115 | 115 | limits::CHANGELOG_MAX | |
| 116 | 116 | ))); | |
| @@ -121,13 +121,13 @@ | |||
| 121 | 121 | /// Validate a waitlist pitch | |
| 122 | 122 | pub fn validate_waitlist_pitch(pitch: &str) -> Result<(), AppError> { | |
| 123 | 123 | if pitch.chars().count() < limits::WAITLIST_PITCH_MIN { | |
| 124 | - | return Err(AppError::Validation(format!( | |
| 124 | + | return Err(AppError::validation(format!( | |
| 125 | 125 | "Pitch must be at least {} characters", | |
| 126 | 126 | limits::WAITLIST_PITCH_MIN | |
| 127 | 127 | ))); | |
| 128 | 128 | } | |
| 129 | 129 | if pitch.chars().count() > limits::WAITLIST_PITCH_MAX { | |
| 130 | - | return Err(AppError::Validation(format!( | |
| 130 | + | return Err(AppError::validation(format!( | |
| 131 | 131 | "Pitch must be {} characters or less", | |
| 132 | 132 | limits::WAITLIST_PITCH_MAX | |
| 133 | 133 | ))); | |
| @@ -138,10 +138,10 @@ | |||
| 138 | 138 | /// Validate a blog post title | |
| 139 | 139 | pub fn validate_blog_post_title(title: &str) -> Result<(), AppError> { | |
| 140 | 140 | if title.is_empty() { | |
| 141 | - | return Err(AppError::Validation("Blog post title is required".to_string())); | |
| 141 | + | return Err(AppError::validation("Blog post title is required".to_string())); | |
| 142 | 142 | } | |
| 143 | 143 | if title.chars().count() > limits::BLOG_POST_TITLE_MAX { | |
| 144 | - | return Err(AppError::Validation(format!( | |
| 144 | + | return Err(AppError::validation(format!( | |
| 145 | 145 | "Blog post title must be {} characters or less", | |
| 146 | 146 | limits::BLOG_POST_TITLE_MAX | |
| 147 | 147 | ))); | |
| @@ -157,7 +157,7 @@ | |||
| 157 | 157 | /// Validate a blog post body | |
| 158 | 158 | pub fn validate_blog_post_body(body: &str) -> Result<(), AppError> { | |
| 159 | 159 | if body.chars().count() > limits::BLOG_POST_BODY_MAX { | |
| 160 | - | return Err(AppError::Validation(format!( | |
| 160 | + | return Err(AppError::validation(format!( | |
| 161 | 161 | "Blog post body must be {} characters or less", | |
| 162 | 162 | limits::BLOG_POST_BODY_MAX | |
| 163 | 163 | ))); | |
| @@ -168,21 +168,21 @@ | |||
| 168 | 168 | /// Validate a license key code format (word-word-word-word-word) | |
| 169 | 169 | pub fn validate_key_code(code: &str) -> Result<(), AppError> { | |
| 170 | 170 | if code.is_empty() { | |
| 171 | - | return Err(AppError::Validation("Key code is required".to_string())); | |
| 171 | + | return Err(AppError::validation("Key code is required".to_string())); | |
| 172 | 172 | } | |
| 173 | 173 | if code.chars().count() > limits::KEY_CODE_MAX { | |
| 174 | - | return Err(AppError::Validation(format!( | |
| 174 | + | return Err(AppError::validation(format!( | |
| 175 | 175 | "Key code must be {} characters or less", | |
| 176 | 176 | limits::KEY_CODE_MAX | |
| 177 | 177 | ))); | |
| 178 | 178 | } | |
| 179 | 179 | let parts: Vec<&str> = code.split('-').collect(); | |
| 180 | 180 | if parts.len() != 5 { | |
| 181 | - | return Err(AppError::Validation("Invalid key code format".to_string())); | |
| 181 | + | return Err(AppError::validation("Invalid key code format".to_string())); | |
| 182 | 182 | } | |
| 183 | 183 | for part in &parts { | |
| 184 | 184 | if part.is_empty() || !part.chars().all(|c| c.is_ascii_lowercase()) { | |
| 185 | - | return Err(AppError::Validation("Invalid key code format".to_string())); | |
| 185 | + | return Err(AppError::validation("Invalid key code format".to_string())); | |
| 186 | 186 | } | |
| 187 | 187 | } | |
| 188 | 188 | Ok(()) | |
| @@ -191,10 +191,10 @@ | |||
| 191 | 191 | /// Validate a collection title | |
| 192 | 192 | pub fn validate_collection_title(title: &str) -> Result<(), AppError> { | |
| 193 | 193 | if title.is_empty() { | |
| 194 | - | return Err(AppError::Validation("Collection title is required".to_string())); | |
| 194 | + | return Err(AppError::validation("Collection title is required".to_string())); | |
| 195 | 195 | } | |
| 196 | 196 | if title.chars().count() > limits::COLLECTION_TITLE_MAX { | |
| 197 | - | return Err(AppError::Validation(format!( | |
| 197 | + | return Err(AppError::validation(format!( | |
| 198 | 198 | "Collection title must be {} characters or less", | |
| 199 | 199 | limits::COLLECTION_TITLE_MAX | |
| 200 | 200 | ))); | |
| @@ -205,7 +205,7 @@ | |||
| 205 | 205 | /// Validate a collection description | |
| 206 | 206 | pub fn validate_collection_description(description: &str) -> Result<(), AppError> { | |
| 207 | 207 | if description.chars().count() > limits::COLLECTION_DESCRIPTION_MAX { | |
| 208 | - | return Err(AppError::Validation(format!( | |
| 208 | + | return Err(AppError::validation(format!( | |
| 209 | 209 | "Collection description must be {} characters or less", | |
| 210 | 210 | limits::COLLECTION_DESCRIPTION_MAX | |
| 211 | 211 | ))); | |
| @@ -217,10 +217,10 @@ | |||
| 217 | 217 | pub fn validate_section_title(title: &str) -> Result<(), AppError> { | |
| 218 | 218 | let trimmed = title.trim(); | |
| 219 | 219 | if trimmed.is_empty() { | |
| 220 | - | return Err(AppError::Validation("Section title is required".to_string())); | |
| 220 | + | return Err(AppError::validation("Section title is required".to_string())); | |
| 221 | 221 | } | |
| 222 | 222 | if trimmed.chars().count() > limits::SECTION_TITLE_MAX { | |
| 223 | - | return Err(AppError::Validation(format!( | |
| 223 | + | return Err(AppError::validation(format!( | |
| 224 | 224 | "Section title must be {} characters or less", | |
| 225 | 225 | limits::SECTION_TITLE_MAX | |
| 226 | 226 | ))); | |
| @@ -231,7 +231,7 @@ | |||
| 231 | 231 | /// Validate an item section body | |
| 232 | 232 | pub fn validate_section_body(body: &str) -> Result<(), AppError> { | |
| 233 | 233 | if body.chars().count() > limits::SECTION_BODY_MAX { | |
| 234 | - | return Err(AppError::Validation(format!( | |
| 234 | + | return Err(AppError::validation(format!( | |
| 235 | 235 | "Section body must be {} characters or less", | |
| 236 | 236 | limits::SECTION_BODY_MAX | |
| 237 | 237 | ))); | |
| @@ -242,11 +242,11 @@ | |||
| 242 | 242 | /// Validate price in cents (must be non-negative) | |
| 243 | 243 | pub fn validate_price_cents(price: i32) -> Result<(), AppError> { | |
| 244 | 244 | if price < 0 { | |
| 245 | - | return Err(AppError::Validation("Price cannot be negative".to_string())); | |
| 245 | + | return Err(AppError::validation("Price cannot be negative".to_string())); | |
| 246 | 246 | } | |
| 247 | 247 | // Cap at $10,000 | |
| 248 | 248 | if price > constants::MAX_PRICE_CENTS { | |
| 249 | - | return Err(AppError::Validation("Price cannot exceed $10,000".to_string())); | |
| 249 | + | return Err(AppError::validation("Price cannot exceed $10,000".to_string())); | |
| 250 | 250 | } | |
| 251 | 251 | Ok(()) | |
| 252 | 252 | } |
| @@ -72,12 +72,12 @@ | |||
| 72 | 72 | pub fn validate_slug(slug: &str) -> Result<(), AppError> { | |
| 73 | 73 | let len = slug.chars().count(); | |
| 74 | 74 | if len < 2 { | |
| 75 | - | return Err(AppError::Validation( | |
| 75 | + | return Err(AppError::validation( | |
| 76 | 76 | "URL name must be at least 2 characters".to_string(), | |
| 77 | 77 | )); | |
| 78 | 78 | } | |
| 79 | 79 | if len > limits::PROJECT_SLUG_MAX { | |
| 80 | - | return Err(AppError::Validation(format!( | |
| 80 | + | return Err(AppError::validation(format!( | |
| 81 | 81 | "URL name must be {} characters or less", | |
| 82 | 82 | limits::PROJECT_SLUG_MAX | |
| 83 | 83 | ))); | |
| @@ -86,12 +86,12 @@ | |||
| 86 | 86 | .chars() | |
| 87 | 87 | .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 88 | 88 | { | |
| 89 | - | return Err(AppError::Validation( | |
| 89 | + | return Err(AppError::validation( | |
| 90 | 90 | "URL name can only contain lowercase letters, numbers, and hyphens".to_string(), | |
| 91 | 91 | )); | |
| 92 | 92 | } | |
| 93 | 93 | if !slug.chars().any(|c| c.is_ascii_alphanumeric()) { | |
| 94 | - | return Err(AppError::Validation( | |
| 94 | + | return Err(AppError::validation( | |
| 95 | 95 | "URL name must contain at least one letter or number".to_string(), | |
| 96 | 96 | )); | |
| 97 | 97 | } | |
| @@ -103,10 +103,10 @@ | |||
| 103 | 103 | /// Validate a sync app name | |
| 104 | 104 | pub fn validate_sync_app_name(name: &str) -> Result<(), AppError> { | |
| 105 | 105 | if name.is_empty() { | |
| 106 | - | return Err(AppError::Validation("App name is required".to_string())); | |
| 106 | + | return Err(AppError::validation("App name is required".to_string())); | |
| 107 | 107 | } | |
| 108 | 108 | if name.chars().count() > limits::SYNC_APP_NAME_MAX { | |
| 109 | - | return Err(AppError::Validation(format!( | |
| 109 | + | return Err(AppError::validation(format!( | |
| 110 | 110 | "App name must be {} characters or less", | |
| 111 | 111 | limits::SYNC_APP_NAME_MAX | |
| 112 | 112 | ))); | |
| @@ -117,10 +117,10 @@ | |||
| 117 | 117 | /// Validate a sync device name | |
| 118 | 118 | pub fn validate_sync_device_name(name: &str) -> Result<(), AppError> { | |
| 119 | 119 | if name.is_empty() { | |
| 120 | - | return Err(AppError::Validation("Device name is required".to_string())); | |
| 120 | + | return Err(AppError::validation("Device name is required".to_string())); | |
| 121 | 121 | } | |
| 122 | 122 | if name.chars().count() > limits::SYNC_DEVICE_NAME_MAX { | |
| 123 | - | return Err(AppError::Validation(format!( | |
| 123 | + | return Err(AppError::validation(format!( | |
| 124 | 124 | "Device name must be {} characters or less", | |
| 125 | 125 | limits::SYNC_DEVICE_NAME_MAX | |
| 126 | 126 | ))); | |
| @@ -131,16 +131,16 @@ | |||
| 131 | 131 | /// Validate a sync table name | |
| 132 | 132 | pub fn validate_sync_table_name(name: &str) -> Result<(), AppError> { | |
| 133 | 133 | if name.is_empty() { | |
| 134 | - | return Err(AppError::Validation("Table name is required".to_string())); | |
| 134 | + | return Err(AppError::validation("Table name is required".to_string())); | |
| 135 | 135 | } | |
| 136 | 136 | if name.chars().count() > limits::SYNC_TABLE_NAME_MAX { | |
| 137 | - | return Err(AppError::Validation(format!( | |
| 137 | + | return Err(AppError::validation(format!( | |
| 138 | 138 | "Table name must be {} characters or less", | |
| 139 | 139 | limits::SYNC_TABLE_NAME_MAX | |
| 140 | 140 | ))); | |
| 141 | 141 | } | |
| 142 | 142 | if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { | |
| 143 | - | return Err(AppError::Validation( | |
| 143 | + | return Err(AppError::validation( | |
| 144 | 144 | "Table name can only contain letters, numbers, and underscores".to_string(), | |
| 145 | 145 | )); | |
| 146 | 146 | } | |
| @@ -150,10 +150,10 @@ | |||
| 150 | 150 | /// Validate a sync row ID | |
| 151 | 151 | pub fn validate_sync_row_id(row_id: &str) -> Result<(), AppError> { | |
| 152 | 152 | if row_id.is_empty() { | |
| 153 | - | return Err(AppError::Validation("Row ID is required".to_string())); | |
| 153 | + | return Err(AppError::validation("Row ID is required".to_string())); | |
| 154 | 154 | } | |
| 155 | 155 | if row_id.chars().count() > limits::SYNC_ROW_ID_MAX { | |
| 156 | - | return Err(AppError::Validation(format!( | |
| 156 | + | return Err(AppError::validation(format!( | |
| 157 | 157 | "Row ID must be {} characters or less", | |
| 158 | 158 | limits::SYNC_ROW_ID_MAX | |
| 159 | 159 | ))); | |
| @@ -161,7 +161,7 @@ | |||
| 161 | 161 | // Reject null bytes and control characters — these can cause issues in | |
| 162 | 162 | // DB queries, file paths, and log output downstream. | |
| 163 | 163 | if row_id.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) { | |
| 164 | - | return Err(AppError::Validation( | |
| 164 | + | return Err(AppError::validation( | |
| 165 | 165 | "Row ID contains invalid characters".to_string(), | |
| 166 | 166 | )); | |
| 167 | 167 | } | |
| @@ -171,13 +171,13 @@ | |||
| 171 | 171 | /// Validate a sync blob hash (must be exactly 64 lowercase hex characters) | |
| 172 | 172 | pub fn validate_sync_blob_hash(hash: &str) -> Result<(), AppError> { | |
| 173 | 173 | if hash.len() != limits::SYNC_BLOB_HASH_LEN { | |
| 174 | - | return Err(AppError::Validation(format!( | |
| 174 | + | return Err(AppError::validation(format!( | |
| 175 | 175 | "Blob hash must be exactly {} hex characters", | |
| 176 | 176 | limits::SYNC_BLOB_HASH_LEN | |
| 177 | 177 | ))); | |
| 178 | 178 | } | |
| 179 | 179 | if !hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) { | |
| 180 | - | return Err(AppError::Validation( | |
| 180 | + | return Err(AppError::validation( | |
| 181 | 181 | "Blob hash must be lowercase hexadecimal".to_string(), | |
| 182 | 182 | )); | |
| 183 | 183 | } | |
| @@ -190,16 +190,16 @@ | |||
| 190 | 190 | /// since downstream goes through SQL and log lines. | |
| 191 | 191 | pub fn validate_synckit_key(key: &str) -> Result<(), AppError> { | |
| 192 | 192 | if key.is_empty() { | |
| 193 | - | return Err(AppError::Validation("SDK key is required".to_string())); | |
| 193 | + | return Err(AppError::validation("SDK key is required".to_string())); | |
| 194 | 194 | } | |
| 195 | 195 | if key.len() > limits::SYNC_KEY_MAX { | |
| 196 | - | return Err(AppError::Validation(format!( | |
| 196 | + | return Err(AppError::validation(format!( | |
| 197 | 197 | "SDK key must be {} bytes or less", | |
| 198 | 198 | limits::SYNC_KEY_MAX | |
| 199 | 199 | ))); | |
| 200 | 200 | } | |
| 201 | 201 | if key.bytes().any(|b| b == 0 || (b < 0x20 && b != b'\t')) { | |
| 202 | - | return Err(AppError::Validation( | |
| 202 | + | return Err(AppError::validation( | |
| 203 | 203 | "SDK key contains invalid characters".to_string(), | |
| 204 | 204 | )); | |
| 205 | 205 | } |
| @@ -7,10 +7,10 @@ | |||
| 7 | 7 | /// Validate a subscription tier name | |
| 8 | 8 | pub fn validate_tier_name(name: &str) -> Result<(), AppError> { | |
| 9 | 9 | if name.is_empty() { | |
| 10 | - | return Err(AppError::Validation("Tier name is required".to_string())); | |
| 10 | + | return Err(AppError::validation("Tier name is required".to_string())); | |
| 11 | 11 | } | |
| 12 | 12 | if name.chars().count() > limits::TIER_NAME_MAX { | |
| 13 | - | return Err(AppError::Validation(format!( | |
| 13 | + | return Err(AppError::validation(format!( | |
| 14 | 14 | "Tier name must be {} characters or less", | |
| 15 | 15 | limits::TIER_NAME_MAX | |
| 16 | 16 | ))); | |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | /// Validate a subscription tier description | |
| 22 | 22 | pub fn validate_tier_description(description: &str) -> Result<(), AppError> { | |
| 23 | 23 | if description.chars().count() > limits::TIER_DESCRIPTION_MAX { | |
| 24 | - | return Err(AppError::Validation(format!( | |
| 24 | + | return Err(AppError::validation(format!( | |
| 25 | 25 | "Tier description must be {} characters or less", | |
| 26 | 26 | limits::TIER_DESCRIPTION_MAX | |
| 27 | 27 | ))); | |
| @@ -32,13 +32,13 @@ | |||
| 32 | 32 | /// Validate a subscription tier price in cents (must be at least $1.00) | |
| 33 | 33 | pub fn validate_tier_price(price_cents: i32) -> Result<(), AppError> { | |
| 34 | 34 | if price_cents < crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS { | |
| 35 | - | return Err(AppError::Validation(format!( | |
| 35 | + | return Err(AppError::validation(format!( | |
| 36 | 36 | "Subscription price must be at least ${:.2}", | |
| 37 | 37 | crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS as f64 / 100.0 | |
| 38 | 38 | ))); | |
| 39 | 39 | } | |
| 40 | 40 | if price_cents > constants::MAX_PRICE_CENTS { | |
| 41 | - | return Err(AppError::Validation("Subscription price cannot exceed $10,000".to_string())); | |
| 41 | + | return Err(AppError::validation("Subscription price cannot exceed $10,000".to_string())); | |
| 42 | 42 | } | |
| 43 | 43 | Ok(()) | |
| 44 | 44 | } |
| @@ -6,10 +6,10 @@ | |||
| 6 | 6 | /// Validate a project title | |
| 7 | 7 | pub fn validate_project_title(title: &str) -> Result<(), AppError> { | |
| 8 | 8 | if title.is_empty() { | |
| 9 | - | return Err(AppError::Validation("Project title is required".to_string())); | |
| 9 | + | return Err(AppError::validation("Project title is required".to_string())); | |
| 10 | 10 | } | |
| 11 | 11 | if title.chars().count() > limits::PROJECT_TITLE_MAX { | |
| 12 | - | return Err(AppError::Validation(format!( | |
| 12 | + | return Err(AppError::validation(format!( | |
| 13 | 13 | "Project title must be {} characters or less", | |
| 14 | 14 | limits::PROJECT_TITLE_MAX | |
| 15 | 15 | ))); | |
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | /// Validate a project description | |
| 21 | 21 | pub fn validate_project_description(description: &str) -> Result<(), AppError> { | |
| 22 | 22 | if description.chars().count() > limits::PROJECT_DESCRIPTION_MAX { | |
| 23 | - | return Err(AppError::Validation(format!( | |
| 23 | + | return Err(AppError::validation(format!( | |
| 24 | 24 | "Project description must be {} characters or less", | |
| 25 | 25 | limits::PROJECT_DESCRIPTION_MAX | |
| 26 | 26 | ))); | |
| @@ -36,10 +36,10 @@ | |||
| 36 | 36 | /// Validate an issue title | |
| 37 | 37 | pub fn validate_issue_title(title: &str) -> Result<(), AppError> { | |
| 38 | 38 | if title.is_empty() { | |
| 39 | - | return Err(AppError::Validation("Issue title is required".to_string())); | |
| 39 | + | return Err(AppError::validation("Issue title is required".to_string())); | |
| 40 | 40 | } | |
| 41 | 41 | if title.chars().count() > limits::ISSUE_TITLE_MAX { | |
| 42 | - | return Err(AppError::Validation(format!( | |
| 42 | + | return Err(AppError::validation(format!( | |
| 43 | 43 | "Issue title must be {} characters or less", | |
| 44 | 44 | limits::ISSUE_TITLE_MAX | |
| 45 | 45 | ))); | |
| @@ -50,7 +50,7 @@ | |||
| 50 | 50 | /// Validate an issue body (markdown) | |
| 51 | 51 | pub fn validate_issue_body(body: &str) -> Result<(), AppError> { | |
| 52 | 52 | if body.chars().count() > limits::ISSUE_BODY_MAX { | |
| 53 | - | return Err(AppError::Validation(format!( | |
| 53 | + | return Err(AppError::validation(format!( | |
| 54 | 54 | "Issue body must be {} characters or less", | |
| 55 | 55 | limits::ISSUE_BODY_MAX | |
| 56 | 56 | ))); | |
| @@ -61,10 +61,10 @@ | |||
| 61 | 61 | /// Validate an issue comment body (markdown) | |
| 62 | 62 | pub fn validate_issue_comment_body(body: &str) -> Result<(), AppError> { | |
| 63 | 63 | if body.trim().is_empty() { | |
| 64 | - | return Err(AppError::Validation("Comment body is required".to_string())); | |
| 64 | + | return Err(AppError::validation("Comment body is required".to_string())); | |
| 65 | 65 | } | |
| 66 | 66 | if body.chars().count() > limits::ISSUE_COMMENT_BODY_MAX { | |
| 67 | - | return Err(AppError::Validation(format!( | |
| 67 | + | return Err(AppError::validation(format!( | |
| 68 | 68 | "Comment must be {} characters or less", | |
| 69 | 69 | limits::ISSUE_COMMENT_BODY_MAX | |
| 70 | 70 | ))); | |
| @@ -75,10 +75,10 @@ | |||
| 75 | 75 | /// Validate an issue label name | |
| 76 | 76 | pub fn validate_label_name(name: &str) -> Result<(), AppError> { | |
| 77 | 77 | if name.trim().is_empty() { | |
| 78 | - | return Err(AppError::Validation("Label name is required".to_string())); | |
| 78 | + | return Err(AppError::validation("Label name is required".to_string())); | |
| 79 | 79 | } | |
| 80 | 80 | if name.chars().count() > limits::ISSUE_LABEL_NAME_MAX { | |
| 81 | - | return Err(AppError::Validation(format!( | |
| 81 | + | return Err(AppError::validation(format!( | |
| 82 | 82 | "Label name must be {} characters or less", | |
| 83 | 83 | limits::ISSUE_LABEL_NAME_MAX | |
| 84 | 84 | ))); | |
| @@ -86,7 +86,7 @@ | |||
| 86 | 86 | // Only allow printable characters (letters, numbers, punctuation, spaces). | |
| 87 | 87 | // Rejects control characters, null bytes, and non-printable unicode. | |
| 88 | 88 | if name.chars().any(|c| c.is_control()) { | |
| 89 | - | return Err(AppError::Validation( | |
| 89 | + | return Err(AppError::validation( | |
| 90 | 90 | "Label name cannot contain control characters".to_string(), | |
| 91 | 91 | )); | |
| 92 | 92 | } | |
| @@ -96,12 +96,12 @@ | |||
| 96 | 96 | /// Validate a label color (hex format: #RRGGBB) | |
| 97 | 97 | pub fn validate_label_color(color: &str) -> Result<(), AppError> { | |
| 98 | 98 | if color.len() != 7 || !color.starts_with('#') { | |
| 99 | - | return Err(AppError::Validation( | |
| 99 | + | return Err(AppError::validation( | |
| 100 | 100 | "Label color must be in #RRGGBB format".to_string(), | |
| 101 | 101 | )); | |
| 102 | 102 | } | |
| 103 | 103 | if !color[1..].chars().all(|c| c.is_ascii_hexdigit()) { | |
| 104 | - | return Err(AppError::Validation( | |
| 104 | + | return Err(AppError::validation( | |
| 105 | 105 | "Label color must be a valid hex color".to_string(), | |
| 106 | 106 | )); | |
| 107 | 107 | } | |
| @@ -111,7 +111,7 @@ | |||
| 111 | 111 | /// Validate a git repo description (length check, trimmed input expected). | |
| 112 | 112 | pub fn validate_repo_description(desc: &str) -> Result<(), AppError> { | |
| 113 | 113 | if desc.chars().count() > limits::REPO_DESCRIPTION_MAX { | |
| 114 | - | return Err(AppError::Validation(format!( | |
| 114 | + | return Err(AppError::validation(format!( | |
| 115 | 115 | "Repository description must be {} characters or less", | |
| 116 | 116 | limits::REPO_DESCRIPTION_MAX | |
| 117 | 117 | ))); | |
| @@ -122,12 +122,12 @@ | |||
| 122 | 122 | /// Validate a git repository name: 1-64 chars, ASCII alphanumeric + hyphens/underscores/dots, no leading dot. | |
| 123 | 123 | pub fn validate_git_repo_name(name: &str) -> Result<(), AppError> { | |
| 124 | 124 | if name.is_empty() || name.len() > 64 { | |
| 125 | - | return Err(AppError::Validation( | |
| 125 | + | return Err(AppError::validation( | |
| 126 | 126 | "Git repo name must be 1-64 characters".to_string(), | |
| 127 | 127 | )); | |
| 128 | 128 | } | |
| 129 | 129 | if name.starts_with('.') { | |
| 130 | - | return Err(AppError::Validation( | |
| 130 | + | return Err(AppError::validation( | |
| 131 | 131 | "Git repo name cannot start with a dot".to_string(), | |
| 132 | 132 | )); | |
| 133 | 133 | } | |
| @@ -135,7 +135,7 @@ | |||
| 135 | 135 | .chars() | |
| 136 | 136 | .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') | |
| 137 | 137 | { | |
| 138 | - | return Err(AppError::Validation( | |
| 138 | + | return Err(AppError::validation( | |
| 139 | 139 | "Git repo name can only contain letters, numbers, hyphens, underscores, and dots" | |
| 140 | 140 | .to_string(), | |
| 141 | 141 | )); |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | /// Validate a display name | |
| 7 | 7 | pub fn validate_display_name(name: &str) -> Result<(), AppError> { | |
| 8 | 8 | if name.chars().count() > limits::DISPLAY_NAME_MAX { | |
| 9 | - | return Err(AppError::Validation(format!( | |
| 9 | + | return Err(AppError::validation(format!( | |
| 10 | 10 | "Display name must be {} characters or less", | |
| 11 | 11 | limits::DISPLAY_NAME_MAX | |
| 12 | 12 | ))); | |
| @@ -14,7 +14,7 @@ | |||
| 14 | 14 | // Reject control characters (ASCII 0-31 except space, plus DEL 0x7F) | |
| 15 | 15 | // to prevent social engineering in plain-text emails. | |
| 16 | 16 | if name.chars().any(|c| c.is_control()) { | |
| 17 | - | return Err(AppError::Validation( | |
| 17 | + | return Err(AppError::validation( | |
| 18 | 18 | "Display name cannot contain control characters".to_string(), | |
| 19 | 19 | )); | |
| 20 | 20 | } | |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | /// Validate a bio | |
| 25 | 25 | pub fn validate_bio(bio: &str) -> Result<(), AppError> { | |
| 26 | 26 | if bio.chars().count() > limits::BIO_MAX { | |
| 27 | - | return Err(AppError::Validation(format!( | |
| 27 | + | return Err(AppError::validation(format!( | |
| 28 | 28 | "Bio must be {} characters or less", | |
| 29 | 29 | limits::BIO_MAX | |
| 30 | 30 | ))); | |
| @@ -35,7 +35,7 @@ | |||
| 35 | 35 | /// Validate a link URL | |
| 36 | 36 | pub fn validate_link_url(url_str: &str) -> Result<(), AppError> { | |
| 37 | 37 | if url_str.chars().count() > limits::LINK_URL_MAX { | |
| 38 | - | return Err(AppError::Validation(format!( | |
| 38 | + | return Err(AppError::validation(format!( | |
| 39 | 39 | "URL must be {} characters or less", | |
| 40 | 40 | limits::LINK_URL_MAX | |
| 41 | 41 | ))); | |
| @@ -43,13 +43,13 @@ | |||
| 43 | 43 | ||
| 44 | 44 | // Parse URL properly to prevent malformed/malicious URLs | |
| 45 | 45 | let parsed = url::Url::parse(url_str) | |
| 46 | - | .map_err(|_| AppError::Validation("Invalid URL format".to_string()))?; | |
| 46 | + | .map_err(|_| AppError::validation("Invalid URL format".to_string()))?; | |
| 47 | 47 | ||
| 48 | 48 | // Only allow http and https schemes | |
| 49 | 49 | match parsed.scheme() { | |
| 50 | 50 | "http" | "https" => {} | |
| 51 | 51 | _ => { | |
| 52 | - | return Err(AppError::Validation( | |
| 52 | + | return Err(AppError::validation( | |
| 53 | 53 | "URL must use http:// or https://".to_string(), | |
| 54 | 54 | )); | |
| 55 | 55 | } | |
| @@ -57,7 +57,7 @@ | |||
| 57 | 57 | ||
| 58 | 58 | // Must have a host | |
| 59 | 59 | if parsed.host_str().is_none() { | |
| 60 | - | return Err(AppError::Validation("URL must have a host".to_string())); | |
| 60 | + | return Err(AppError::validation("URL must have a host".to_string())); | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 63 | Ok(()) | |
| @@ -66,10 +66,10 @@ | |||
| 66 | 66 | /// Validate a link title | |
| 67 | 67 | pub fn validate_link_title(title: &str) -> Result<(), AppError> { | |
| 68 | 68 | if title.is_empty() { | |
| 69 | - | return Err(AppError::Validation("Link title is required".to_string())); | |
| 69 | + | return Err(AppError::validation("Link title is required".to_string())); | |
| 70 | 70 | } | |
| 71 | 71 | if title.chars().count() > limits::LINK_TITLE_MAX { | |
| 72 | - | return Err(AppError::Validation(format!( | |
| 72 | + | return Err(AppError::validation(format!( | |
| 73 | 73 | "Link title must be {} characters or less", | |
| 74 | 74 | limits::LINK_TITLE_MAX | |
| 75 | 75 | ))); | |
| @@ -83,17 +83,17 @@ | |||
| 83 | 83 | pub fn validate_username(username: &str) -> Result<(), AppError> { | |
| 84 | 84 | let len = username.chars().count(); | |
| 85 | 85 | if len < 3 { | |
| 86 | - | return Err(AppError::Validation( | |
| 86 | + | return Err(AppError::validation( | |
| 87 | 87 | "Username must be at least 3 characters".to_string(), | |
| 88 | 88 | )); | |
| 89 | 89 | } | |
| 90 | 90 | if len > 50 { | |
| 91 | - | return Err(AppError::Validation( | |
| 91 | + | return Err(AppError::validation( | |
| 92 | 92 | "Username must be 50 characters or less".to_string(), | |
| 93 | 93 | )); | |
| 94 | 94 | } | |
| 95 | 95 | if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { | |
| 96 | - | return Err(AppError::Validation( | |
| 96 | + | return Err(AppError::validation( | |
| 97 | 97 | "Username can only contain letters, numbers, and underscores".to_string(), | |
| 98 | 98 | )); | |
| 99 | 99 | } | |
| @@ -103,10 +103,10 @@ | |||
| 103 | 103 | /// Validate a machine ID | |
| 104 | 104 | pub fn validate_machine_id(machine_id: &str) -> Result<(), AppError> { | |
| 105 | 105 | if machine_id.is_empty() { | |
| 106 | - | return Err(AppError::Validation("Machine ID is required".to_string())); | |
| 106 | + | return Err(AppError::validation("Machine ID is required".to_string())); | |
| 107 | 107 | } | |
| 108 | 108 | if machine_id.chars().count() > limits::MACHINE_ID_MAX { | |
| 109 | - | return Err(AppError::Validation(format!( | |
| 109 | + | return Err(AppError::validation(format!( | |
| 110 | 110 | "Machine ID must be {} characters or less", | |
| 111 | 111 | limits::MACHINE_ID_MAX | |
| 112 | 112 | ))); | |
| @@ -117,7 +117,7 @@ | |||
| 117 | 117 | /// Validate an activation label | |
| 118 | 118 | pub fn validate_activation_label(label: &str) -> Result<(), AppError> { | |
| 119 | 119 | if label.chars().count() > limits::ACTIVATION_LABEL_MAX { | |
| 120 | - | return Err(AppError::Validation(format!( | |
| 120 | + | return Err(AppError::validation(format!( | |
| 121 | 121 | "Label must be {} characters or less", | |
| 122 | 122 | limits::ACTIVATION_LABEL_MAX | |
| 123 | 123 | ))); | |
| @@ -147,16 +147,16 @@ | |||
| 147 | 147 | let input = input.trim(); | |
| 148 | 148 | ||
| 149 | 149 | if input.is_empty() { | |
| 150 | - | return Err(AppError::Validation("SSH public key is required".to_string())); | |
| 150 | + | return Err(AppError::validation("SSH public key is required".to_string())); | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | 153 | if input.len() > 8192 { | |
| 154 | - | return Err(AppError::Validation("SSH public key is too large".to_string())); | |
| 154 | + | return Err(AppError::validation("SSH public key is too large".to_string())); | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | let parts: Vec<&str> = input.split_whitespace().collect(); | |
| 158 | 158 | if parts.len() < 2 { | |
| 159 | - | return Err(AppError::Validation( | |
| 159 | + | return Err(AppError::validation( | |
| 160 | 160 | "Invalid SSH key format: expected '{type} {base64} [comment]'".to_string(), | |
| 161 | 161 | )); | |
| 162 | 162 | } | |
| @@ -165,7 +165,7 @@ | |||
| 165 | 165 | let key_data = parts[1]; | |
| 166 | 166 | ||
| 167 | 167 | if !SSH_KEY_TYPES.contains(&key_type) { | |
| 168 | - | return Err(AppError::Validation(format!( | |
| 168 | + | return Err(AppError::validation(format!( | |
| 169 | 169 | "Unsupported SSH key type '{}'. Accepted: ssh-rsa, ssh-ed25519, ecdsa-sha2-*", | |
| 170 | 170 | key_type | |
| 171 | 171 | ))); | |
| @@ -175,10 +175,10 @@ | |||
| 175 | 175 | use base64::Engine; | |
| 176 | 176 | let decoded = base64::engine::general_purpose::STANDARD | |
| 177 | 177 | .decode(key_data) | |
| 178 | - | .map_err(|_| AppError::Validation("Invalid SSH key: bad base64 encoding".to_string()))?; | |
| 178 | + | .map_err(|_| AppError::validation("Invalid SSH key: bad base64 encoding".to_string()))?; | |
| 179 | 179 | ||
| 180 | 180 | if decoded.len() < 16 { | |
| 181 | - | return Err(AppError::Validation("Invalid SSH key: data too short".to_string())); | |
| 181 | + | return Err(AppError::validation("Invalid SSH key: data too short".to_string())); | |
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | 184 | // Compute fingerprint: SHA256:{base64(sha256(decoded))} (same as ssh-keygen -lf) | |
| @@ -198,7 +198,7 @@ | |||
| 198 | 198 | /// Validate an SSH key label | |
| 199 | 199 | pub fn validate_ssh_key_label(label: &str) -> std::result::Result<(), AppError> { | |
| 200 | 200 | if label.chars().count() > limits::SSH_KEY_LABEL_MAX { | |
| 201 | - | return Err(AppError::Validation(format!( | |
| 201 | + | return Err(AppError::validation(format!( | |
| 202 | 202 | "SSH key label must be {} characters or less", | |
| 203 | 203 | limits::SSH_KEY_LABEL_MAX | |
| 204 | 204 | ))); |
| @@ -78,7 +78,7 @@ | |||
| 78 | 78 | AdminUser(_admin): AdminUser, | |
| 79 | 79 | ) -> Result<Response> { | |
| 80 | 80 | let Some(ref mt) = state.mt_client else { | |
| 81 | - | return Err(AppError::Validation("MT integration not configured".to_string())); | |
| 81 | + | return Err(AppError::validation("MT integration not configured".to_string())); | |
| 82 | 82 | }; | |
| 83 | 83 | ||
| 84 | 84 | let projects = db::projects::get_projects_without_mt_community(&state.db).await?; | |
| @@ -155,7 +155,7 @@ | |||
| 155 | 155 | ) -> Result<Response> { | |
| 156 | 156 | let shutdown_date = form.shutdown_date.trim(); | |
| 157 | 157 | if shutdown_date.is_empty() { | |
| 158 | - | return Err(AppError::Validation("Shutdown date is required".to_string())); | |
| 158 | + | return Err(AppError::validation("Shutdown date is required".to_string())); | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | 161 | let all_users = db::users::get_all_user_emails(&state.db).await?; |