1use serde::{Deserialize, Serialize};
7use std::fmt;
8use thiserror::Error;
9use urlencoding::encode;
10
11pub const MAX_APP_NAME_LEN: usize = 255;
13
14pub const MAX_DESCRIPTION_LEN: usize = 4096;
16
17pub const MAX_BUSINESS_UNIT_NAME_LEN: usize = 255;
19
20pub const MAX_TEAMS_COUNT: usize = 100;
22
23pub const MAX_CUSTOM_FIELDS_COUNT: usize = 50;
25
26pub const MAX_TAG_VALUE_LEN: usize = 128;
28
29pub const MAX_GUID_LEN: usize = 128;
31
32pub const DEFAULT_PAGE_SIZE: u32 = 50;
34
35pub const MAX_PAGE_SIZE: u32 = 500;
37
38pub const MAX_PAGE_NUMBER: u32 = 10000;
40
41#[derive(Debug, Error)]
43#[must_use = "Need to handle all error enum types."]
44pub enum ValidationError {
45 #[error("Application GUID cannot be empty")]
46 EmptyGuid,
47
48 #[error("Application GUID too long: {actual} chars (max: {max})")]
49 GuidTooLong { actual: usize, max: usize },
50
51 #[error("Invalid GUID format: {0}")]
52 InvalidGuidFormat(String),
53
54 #[error("Invalid characters in GUID (possible path traversal)")]
55 InvalidCharactersInGuid,
56
57 #[error("Application name cannot be empty")]
58 EmptyApplicationName,
59
60 #[error("Application name too long: {actual} chars (max: {max})")]
61 ApplicationNameTooLong { actual: usize, max: usize },
62
63 #[error("Invalid characters in application name")]
64 InvalidCharactersInName,
65
66 #[error("Suspicious pattern in application name (possible path traversal)")]
67 SuspiciousNamePattern,
68
69 #[error("Description too long: {actual} chars (max: {max})")]
70 DescriptionTooLong { actual: usize, max: usize },
71
72 #[error("Description contains null byte")]
73 NullByteInDescription,
74
75 #[error("Too many teams: {actual} (max: {max})")]
76 TooManyTeams { actual: usize, max: usize },
77
78 #[error("Too many custom fields: {actual} (max: {max})")]
79 TooManyCustomFields { actual: usize, max: usize },
80
81 #[error("Invalid page size: {0} (must be 1-{MAX_PAGE_SIZE})")]
82 InvalidPageSize(u32),
83
84 #[error("Page size too large: {0} (max: {MAX_PAGE_SIZE})")]
85 PageSizeTooLarge(u32),
86
87 #[error("Page number too large: {0} (max: {MAX_PAGE_NUMBER})")]
88 PageNumberTooLarge(u32),
89
90 #[error("Empty URL segment not allowed")]
91 EmptySegment,
92
93 #[error("URL segment too long: {actual} chars (max: {max})")]
94 SegmentTooLong { actual: usize, max: usize },
95
96 #[error("Invalid path characters (possible path traversal)")]
97 InvalidPathCharacters,
98
99 #[error("Control characters not allowed")]
100 ControlCharactersNotAllowed,
101
102 #[error("Query encoding failed: {0}")]
103 QueryEncodingFailed(String),
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[serde(transparent)]
109pub struct AppGuid(String);
110
111impl AppGuid {
112 const VALID_GUID_PATTERN: &'static str =
114 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
115
116 pub fn new(guid: impl Into<String>) -> Result<Self, ValidationError> {
128 let guid = guid.into();
129
130 if guid.is_empty() {
132 return Err(ValidationError::EmptyGuid);
133 }
134
135 if guid.len() > MAX_GUID_LEN {
137 return Err(ValidationError::GuidTooLong {
138 actual: guid.len(),
139 max: MAX_GUID_LEN,
140 });
141 }
142
143 #[allow(clippy::expect_used)] let uuid_regex =
146 regex::Regex::new(Self::VALID_GUID_PATTERN).expect("valid UUID regex pattern");
147
148 if !uuid_regex.is_match(&guid) {
149 return Err(ValidationError::InvalidGuidFormat(guid));
150 }
151
152 if guid.contains('/') || guid.contains('\\') || guid.contains("..") {
154 return Err(ValidationError::InvalidCharactersInGuid);
155 }
156
157 Ok(Self(guid))
158 }
159
160 #[must_use = "this method returns the inner value without modifying the type"]
162 pub fn as_str(&self) -> &str {
163 &self.0
164 }
165
166 #[must_use = "this method returns the inner value without modifying the type"]
168 pub fn as_url_safe(&self) -> &str {
169 &self.0
171 }
172}
173
174impl fmt::Display for AppGuid {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 write!(f, "{}", self.0)
177 }
178}
179
180impl AsRef<str> for AppGuid {
181 fn as_ref(&self) -> &str {
182 &self.0
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(transparent)]
189pub struct AppName(String);
190
191impl AppName {
192 pub fn new(name: impl Into<String>) -> Result<Self, ValidationError> {
202 let name = name.into();
203
204 let trimmed = name.trim();
206 if trimmed.is_empty() {
207 return Err(ValidationError::EmptyApplicationName);
208 }
209
210 if trimmed.len() > MAX_APP_NAME_LEN {
212 return Err(ValidationError::ApplicationNameTooLong {
213 actual: trimmed.len(),
214 max: MAX_APP_NAME_LEN,
215 });
216 }
217
218 if trimmed.chars().any(|c| c.is_control()) {
220 return Err(ValidationError::InvalidCharactersInName);
221 }
222
223 if trimmed.contains("..") || trimmed.contains('/') || trimmed.contains('\\') {
225 return Err(ValidationError::SuspiciousNamePattern);
226 }
227
228 Ok(Self(trimmed.to_string()))
229 }
230
231 #[must_use = "this method returns the inner value without modifying the type"]
233 pub fn as_str(&self) -> &str {
234 &self.0
235 }
236}
237
238impl fmt::Display for AppName {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 write!(f, "{}", self.0)
241 }
242}
243
244impl AsRef<str> for AppName {
245 fn as_ref(&self) -> &str {
246 &self.0
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(transparent)]
253pub struct Description(String);
254
255impl Description {
256 pub fn new(desc: impl Into<String>) -> Result<Self, ValidationError> {
262 let desc = desc.into();
263
264 if desc.len() > MAX_DESCRIPTION_LEN {
266 return Err(ValidationError::DescriptionTooLong {
267 actual: desc.len(),
268 max: MAX_DESCRIPTION_LEN,
269 });
270 }
271
272 if desc.contains('\0') {
274 return Err(ValidationError::NullByteInDescription);
275 }
276
277 Ok(Self(desc))
278 }
279
280 #[must_use = "this method returns the inner value without modifying the type"]
282 pub fn as_str(&self) -> &str {
283 &self.0
284 }
285}
286
287impl fmt::Display for Description {
288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 write!(f, "{}", self.0)
290 }
291}
292
293impl AsRef<str> for Description {
294 fn as_ref(&self) -> &str {
295 &self.0
296 }
297}
298
299pub fn validate_url_segment(segment: &str, max_len: usize) -> Result<&str, ValidationError> {
305 if segment.is_empty() {
306 return Err(ValidationError::EmptySegment);
307 }
308
309 if segment.len() > max_len {
310 return Err(ValidationError::SegmentTooLong {
311 actual: segment.len(),
312 max: max_len,
313 });
314 }
315
316 if segment.contains("..") || segment.contains('/') || segment.contains('\\') {
318 return Err(ValidationError::InvalidPathCharacters);
319 }
320
321 if segment.chars().any(|c| c.is_control()) {
323 return Err(ValidationError::ControlCharactersNotAllowed);
324 }
325
326 Ok(segment)
327}
328
329pub fn validate_page_size(size: Option<u32>) -> Result<u32, ValidationError> {
376 match size {
377 None => Ok(DEFAULT_PAGE_SIZE),
378 Some(0) => Err(ValidationError::InvalidPageSize(0)),
379 Some(s) if s > MAX_PAGE_SIZE => {
380 log::warn!(
381 "Page size {} exceeds maximum {}, capping to maximum",
382 s,
383 MAX_PAGE_SIZE
384 );
385 Ok(MAX_PAGE_SIZE)
386 }
387 Some(s) => Ok(s),
388 }
389}
390
391pub fn validate_page_number(page: Option<u32>) -> Result<Option<u32>, ValidationError> {
434 match page {
435 None => Ok(None),
436 Some(p) if p > MAX_PAGE_NUMBER => {
437 log::warn!(
438 "Page number {} exceeds maximum {}, capping to maximum",
439 p,
440 MAX_PAGE_NUMBER
441 );
442 Ok(Some(MAX_PAGE_NUMBER))
443 }
444 Some(p) => Ok(Some(p)),
445 }
446}
447
448#[must_use = "this function performs URL encoding and returns the encoded value"]
474pub fn encode_query_param(value: &str) -> String {
475 encode(value).into_owned()
476}
477
478#[must_use = "this function builds and returns a query parameter tuple"]
497pub fn build_query_param(key: &str, value: &str) -> (String, String) {
498 (key.to_string(), encode_query_param(value))
499}
500
501#[cfg(test)]
502#[allow(clippy::expect_used)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn test_app_guid_valid() {
508 let guid =
509 AppGuid::new("550e8400-e29b-41d4-a716-446655440000").expect("should create valid guid");
510 assert_eq!(guid.as_str(), "550e8400-e29b-41d4-a716-446655440000");
511 }
512
513 #[test]
514 fn test_app_guid_invalid_format() {
515 assert!(AppGuid::new("not-a-guid").is_err());
516 assert!(AppGuid::new("12345").is_err());
517 assert!(AppGuid::new("").is_err());
518 }
519
520 #[test]
521 fn test_app_guid_path_traversal() {
522 assert!(AppGuid::new("../etc/passwd").is_err());
523 assert!(AppGuid::new("550e8400/../test").is_err());
524 }
525
526 #[test]
527 fn test_app_name_valid() {
528 let name = AppName::new("My Application").expect("should create valid app name");
529 assert_eq!(name.as_str(), "My Application");
530 }
531
532 #[test]
533 fn test_app_name_trims_whitespace() {
534 let name = AppName::new(" Trimmed ").expect("should create valid app name");
535 assert_eq!(name.as_str(), "Trimmed");
536 }
537
538 #[test]
539 fn test_app_name_empty() {
540 assert!(AppName::new("").is_err());
541 assert!(AppName::new(" ").is_err());
542 }
543
544 #[test]
545 fn test_app_name_too_long() {
546 let long_name = "a".repeat(MAX_APP_NAME_LEN + 1);
547 assert!(AppName::new(long_name).is_err());
548 }
549
550 #[test]
551 fn test_app_name_path_traversal() {
552 assert!(AppName::new("../etc/passwd").is_err());
553 assert!(AppName::new("test/../admin").is_err());
554 assert!(AppName::new("test/admin").is_err());
555 }
556
557 #[test]
558 fn test_description_valid() {
559 let desc = Description::new("This is a valid description")
560 .expect("should create valid description");
561 assert_eq!(desc.as_str(), "This is a valid description");
562 }
563
564 #[test]
565 fn test_description_too_long() {
566 let long_desc = "a".repeat(MAX_DESCRIPTION_LEN + 1);
567 assert!(Description::new(long_desc).is_err());
568 }
569
570 #[test]
571 fn test_description_null_byte() {
572 assert!(Description::new("test\0null").is_err());
573 }
574
575 #[test]
576 fn test_validate_url_segment() {
577 assert!(validate_url_segment("valid-segment", 100).is_ok());
578 assert!(validate_url_segment("", 100).is_err());
579 assert!(validate_url_segment("../traversal", 100).is_err());
580 assert!(validate_url_segment("test/path", 100).is_err());
581 }
582
583 #[test]
584 fn test_validate_page_size_default() {
585 let result = validate_page_size(None).expect("should return default");
586 assert_eq!(result, DEFAULT_PAGE_SIZE);
587 }
588
589 #[test]
590 fn test_validate_page_size_valid() {
591 let result = validate_page_size(Some(100)).expect("should accept valid size");
592 assert_eq!(result, 100);
593
594 let result = validate_page_size(Some(MAX_PAGE_SIZE)).expect("should accept max size");
595 assert_eq!(result, MAX_PAGE_SIZE);
596 }
597
598 #[test]
599 fn test_validate_page_size_zero() {
600 let result = validate_page_size(Some(0));
601 assert!(result.is_err());
602 assert!(matches!(result, Err(ValidationError::InvalidPageSize(0))));
603 }
604
605 #[test]
606 fn test_validate_page_size_too_large() {
607 let result = validate_page_size(Some(1000)).expect("should cap to max");
608 assert_eq!(result, MAX_PAGE_SIZE);
609
610 let result = validate_page_size(Some(u32::MAX)).expect("should cap to max");
611 assert_eq!(result, MAX_PAGE_SIZE);
612 }
613
614 #[test]
615 fn test_validate_page_number_none() {
616 let result = validate_page_number(None).expect("should accept None");
617 assert_eq!(result, None);
618 }
619
620 #[test]
621 fn test_validate_page_number_valid() {
622 let result = validate_page_number(Some(10)).expect("should accept valid page");
623 assert_eq!(result, Some(10));
624
625 let result = validate_page_number(Some(MAX_PAGE_NUMBER)).expect("should accept max page");
626 assert_eq!(result, Some(MAX_PAGE_NUMBER));
627 }
628
629 #[test]
630 fn test_validate_page_number_too_large() {
631 let result = validate_page_number(Some(50000)).expect("should cap to max");
632 assert_eq!(result, Some(MAX_PAGE_NUMBER));
633
634 let result = validate_page_number(Some(u32::MAX)).expect("should cap to max");
635 assert_eq!(result, Some(MAX_PAGE_NUMBER));
636 }
637
638 #[test]
639 fn test_encode_query_param_normal() {
640 assert_eq!(encode_query_param("MyApp"), "MyApp");
641 assert_eq!(encode_query_param("test-app"), "test-app");
642 assert_eq!(encode_query_param("app_123"), "app_123");
643 }
644
645 #[test]
646 fn test_encode_query_param_injection_attempts() {
647 assert_eq!(encode_query_param("foo&admin=true"), "foo%26admin%3Dtrue");
649
650 assert_eq!(encode_query_param("key=value"), "key%3Dvalue");
652
653 assert_eq!(encode_query_param("test;command"), "test%3Bcommand");
655
656 assert_eq!(encode_query_param("50%off"), "50%25off");
658
659 assert_eq!(encode_query_param("My App"), "My%20App");
661
662 assert_eq!(
664 encode_query_param("foo&bar=baz;test%data"),
665 "foo%26bar%3Dbaz%3Btest%25data"
666 );
667 }
668
669 #[test]
670 fn test_encode_query_param_path_traversal() {
671 assert_eq!(encode_query_param("../etc/passwd"), "..%2Fetc%2Fpasswd");
672 assert_eq!(encode_query_param("..\\windows"), "..%5Cwindows");
673 }
674
675 #[test]
676 fn test_build_query_param() {
677 let param = build_query_param("name", "MyApp");
678 assert_eq!(param.0, "name");
679 assert_eq!(param.1, "MyApp");
680
681 let param = build_query_param("name", "My App & Co");
682 assert_eq!(param.0, "name");
683 assert_eq!(param.1, "My%20App%20%26%20Co");
684
685 let param = build_query_param("filter", "status=active");
686 assert_eq!(param.0, "filter");
687 assert_eq!(param.1, "status%3Dactive");
688 }
689}