veracode_platform/
validation.rs

1//! Input validation types and utilities for defensive programming.
2//!
3//! This module provides validated wrapper types that ensure data meets
4//! security and business requirements before being used in API operations.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use thiserror::Error;
9use urlencoding::encode;
10
11/// Maximum length for application names
12pub const MAX_APP_NAME_LEN: usize = 255;
13
14/// Maximum length for application descriptions
15pub const MAX_DESCRIPTION_LEN: usize = 4096;
16
17/// Maximum length for business unit names
18pub const MAX_BUSINESS_UNIT_NAME_LEN: usize = 255;
19
20/// Maximum number of teams per application
21pub const MAX_TEAMS_COUNT: usize = 100;
22
23/// Maximum number of custom fields
24pub const MAX_CUSTOM_FIELDS_COUNT: usize = 50;
25
26/// Maximum length for tag values
27pub const MAX_TAG_VALUE_LEN: usize = 128;
28
29/// Maximum GUID length
30pub const MAX_GUID_LEN: usize = 128;
31
32/// Default page size for pagination
33pub const DEFAULT_PAGE_SIZE: u32 = 50;
34
35/// Maximum page size for pagination
36pub const MAX_PAGE_SIZE: u32 = 500;
37
38/// Maximum page number for pagination
39pub const MAX_PAGE_NUMBER: u32 = 10000;
40
41/// Validation errors for input data
42#[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/// Validated application GUID - ensures format compliance and prevents injection
107#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[serde(transparent)]
109pub struct AppGuid(String);
110
111impl AppGuid {
112    /// UUID v4 format pattern
113    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    /// Validates and constructs a new `AppGuid`
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the GUID is empty, exceeds maximum length, contains invalid
121    /// characters, or doesn't match the expected UUID format.
122    ///
123    /// # Panics
124    ///
125    /// This function contains an `expect()` call on a compile-time constant regex pattern
126    /// which should never panic in practice.
127    pub fn new(guid: impl Into<String>) -> Result<Self, ValidationError> {
128        let guid = guid.into();
129
130        // Check not empty
131        if guid.is_empty() {
132            return Err(ValidationError::EmptyGuid);
133        }
134
135        // Check length bounds
136        if guid.len() > MAX_GUID_LEN {
137            return Err(ValidationError::GuidTooLong {
138                actual: guid.len(),
139                max: MAX_GUID_LEN,
140            });
141        }
142
143        // Validate UUID format using regex
144        #[allow(clippy::expect_used)] // Compile-time constant regex pattern, safe to expect
145        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        // Additional security: reject path separators and traversal sequences
153        if guid.contains('/') || guid.contains('\\') || guid.contains("..") {
154            return Err(ValidationError::InvalidCharactersInGuid);
155        }
156
157        Ok(Self(guid))
158    }
159
160    /// Get the GUID as a string slice
161    #[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    /// Get URL-safe representation (for path segments)
167    #[must_use = "this method returns the inner value without modifying the type"]
168    pub fn as_url_safe(&self) -> &str {
169        // UUIDs are already URL-safe (only contain [0-9a-fA-F-])
170        &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/// Validated application name
187#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(transparent)]
189pub struct AppName(String);
190
191impl AppName {
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if validation fails due to invalid input parameters.
196    /// Validates and constructs a new `AppName`
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if validation fails due to invalid input parameters.
201    pub fn new(name: impl Into<String>) -> Result<Self, ValidationError> {
202        let name = name.into();
203
204        // Trim and check not empty
205        let trimmed = name.trim();
206        if trimmed.is_empty() {
207            return Err(ValidationError::EmptyApplicationName);
208        }
209
210        // Check length
211        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        // Check for control characters
219        if trimmed.chars().any(|c| c.is_control()) {
220            return Err(ValidationError::InvalidCharactersInName);
221        }
222
223        // Reject names that look like path traversal attempts
224        if trimmed.contains("..") || trimmed.contains('/') || trimmed.contains('\\') {
225            return Err(ValidationError::SuspiciousNamePattern);
226        }
227
228        Ok(Self(trimmed.to_string()))
229    }
230
231    /// Get the name as a string slice
232    #[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/// Validated description with length bounds
251#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(transparent)]
253pub struct Description(String);
254
255impl Description {
256    /// Validates and constructs a new Description
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if validation fails due to invalid input parameters.
261    pub fn new(desc: impl Into<String>) -> Result<Self, ValidationError> {
262        let desc = desc.into();
263
264        // Check length
265        if desc.len() > MAX_DESCRIPTION_LEN {
266            return Err(ValidationError::DescriptionTooLong {
267                actual: desc.len(),
268                max: MAX_DESCRIPTION_LEN,
269            });
270        }
271
272        // Reject descriptions with null bytes
273        if desc.contains('\0') {
274            return Err(ValidationError::NullByteInDescription);
275        }
276
277        Ok(Self(desc))
278    }
279
280    /// Get the description as a string slice
281    #[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
299/// Validates a URL path segment to prevent injection
300///
301/// # Errors
302///
303/// Returns an error if validation fails due to invalid input parameters.
304pub 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    // Reject path traversal sequences
317    if segment.contains("..") || segment.contains('/') || segment.contains('\\') {
318        return Err(ValidationError::InvalidPathCharacters);
319    }
320
321    // Reject control characters
322    if segment.chars().any(|c| c.is_control()) {
323        return Err(ValidationError::ControlCharactersNotAllowed);
324    }
325
326    Ok(segment)
327}
328
329/// Validates and normalizes a page size parameter.
330///
331/// This function ensures that page sizes are within safe bounds to prevent
332/// resource exhaustion attacks.
333///
334/// # Behavior
335///
336/// - Returns `DEFAULT_PAGE_SIZE` if `None` is provided
337/// - Rejects page size of 0
338/// - Caps page size at `MAX_PAGE_SIZE` with warning log
339/// - Returns the validated page size
340///
341/// # Returns
342///
343/// A `Result` containing the validated page size or a `ValidationError`.
344///
345/// # Security
346///
347///
348/// # Errors
349///
350/// Returns an error if the API request fails, the resource is not found,
351/// or authentication/authorization fails.
352/// This function prevents `DoS` attacks from unbounded pagination requests.
353///
354/// # Examples
355///
356/// ```
357/// use veracode_platform::validation::{validate_page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE};
358///
359/// // Default when None
360/// assert_eq!(validate_page_size(None).unwrap(), DEFAULT_PAGE_SIZE);
361///
362/// // Normal value passes through
363/// assert_eq!(validate_page_size(Some(100)).unwrap(), 100);
364///
365/// // Zero is rejected
366/// assert!(validate_page_size(Some(0)).is_err());
367///
368/// // Too large is capped (with warning log)
369/// assert_eq!(validate_page_size(Some(10000)).unwrap(), MAX_PAGE_SIZE);
370/// ```
371///
372/// # Errors
373///
374/// Returns an error if validation fails due to invalid input parameters.
375pub 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
391/// Validates and normalizes a page number parameter.
392///
393/// This function ensures that page numbers are within safe bounds to prevent
394/// resource exhaustion attacks.
395///
396/// # Behavior
397///
398/// - Returns `None` if `None` is provided (use API default, typically 0)
399/// - Caps page number at `MAX_PAGE_NUMBER` with warning log
400/// - Returns the validated page number
401///
402/// # Returns
403///
404/// A `Result` containing the validated page number or a `ValidationError`.
405///
406/// # Security
407///
408///
409/// # Errors
410///
411/// Returns an error if the API request fails, the resource is not found,
412/// or authentication/authorization fails.
413/// This function prevents `DoS` attacks from unbounded pagination requests.
414///
415/// # Examples
416///
417/// ```
418/// use veracode_platform::validation::{validate_page_number, MAX_PAGE_NUMBER};
419///
420/// // None passes through
421/// assert_eq!(validate_page_number(None).unwrap(), None);
422///
423/// // Normal value passes through
424/// assert_eq!(validate_page_number(Some(10)).unwrap(), Some(10));
425///
426/// // Too large is capped (with warning log)
427/// assert_eq!(validate_page_number(Some(99999)).unwrap(), Some(MAX_PAGE_NUMBER));
428/// ```
429///
430/// # Errors
431///
432/// Returns an error if validation fails due to invalid input parameters.
433pub 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/// Encodes a query parameter value for safe use in URLs.
449///
450/// This function prevents query parameter injection attacks by properly
451/// URL-encoding special characters that could be used to inject additional
452/// parameters or manipulate the query string.
453///
454/// # Security
455///
456/// This function prevents injection attacks like:
457/// - `"foo&admin=true"` → `"foo%26admin%3Dtrue"`
458/// - `"test;rm -rf /"` → `"test%3Brm%20-rf%20%2F"`
459///
460/// # Examples
461///
462/// ```
463/// use veracode_platform::validation::encode_query_param;
464///
465/// // Normal values pass through unchanged
466/// assert_eq!(encode_query_param("MyApp"), "MyApp");
467///
468/// // Special characters are encoded
469/// assert_eq!(encode_query_param("foo&bar"), "foo%26bar");
470/// assert_eq!(encode_query_param("key=value"), "key%3Dvalue");
471/// assert_eq!(encode_query_param("test;command"), "test%3Bcommand");
472/// ```
473#[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/// Safely builds a query parameter tuple with URL encoding.
479///
480/// This is a convenience function for building query parameter tuples
481/// with proper URL encoding applied to the value.
482///
483/// # Security
484///
485/// Prevents query parameter injection by encoding special characters.
486///
487/// # Examples
488///
489/// ```
490/// use veracode_platform::validation::build_query_param;
491///
492/// let param = build_query_param("name", "My App & Co");
493/// assert_eq!(param.0, "name");
494/// assert_eq!(param.1, "My%20App%20%26%20Co");
495/// ```
496#[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        // Test ampersand injection
648        assert_eq!(encode_query_param("foo&admin=true"), "foo%26admin%3Dtrue");
649
650        // Test equals sign injection
651        assert_eq!(encode_query_param("key=value"), "key%3Dvalue");
652
653        // Test semicolon injection
654        assert_eq!(encode_query_param("test;command"), "test%3Bcommand");
655
656        // Test percent sign (double encoding protection)
657        assert_eq!(encode_query_param("50%off"), "50%25off");
658
659        // Test space
660        assert_eq!(encode_query_param("My App"), "My%20App");
661
662        // Test multiple special characters
663        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}