Skip to main content

veracode_platform/
client.rs

1//! Core Veracode API client implementation.
2//!
3//! This module contains the foundational client for making authenticated requests
4//! to the Veracode API, including HMAC authentication and HTTP request handling.
5
6use bytes::Bytes;
7use hex;
8use hmac::{Hmac, Mac};
9use log::{info, warn};
10use reqwest::{Body, Client, multipart};
11use secrecy::ExposeSecret;
12use serde::Serialize;
13use sha2::Sha256;
14use std::borrow::Cow;
15use std::collections::HashMap;
16use std::fs::File;
17use std::io::{Read, Seek, SeekFrom};
18use std::sync::Arc;
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20use tokio::fs::File as TokioFile;
21use url::Url;
22
23use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
24use crate::{VeracodeConfig, VeracodeError};
25
26// Type aliases for HMAC
27type HmacSha256 = Hmac<Sha256>;
28
29// Constants for authentication error messages to avoid repeated allocations
30const INVALID_URL_MSG: &str = "Invalid URL";
31const INVALID_API_KEY_MSG: &str = "Invalid API key format - must be hex string";
32const INVALID_NONCE_MSG: &str = "Invalid nonce format";
33const HMAC_CREATION_FAILED_MSG: &str = "Failed to create HMAC";
34
35/// Core Veracode API client.
36///
37/// This struct provides the foundational HTTP client with HMAC authentication
38/// for making requests to any Veracode API endpoint.
39#[derive(Clone)]
40pub struct VeracodeClient {
41    config: VeracodeConfig,
42    client: Client,
43}
44
45impl VeracodeClient {
46    /// Build URL with query parameters - centralized helper
47    fn build_url_with_params(&self, endpoint: &str, query_params: &[(&str, &str)]) -> String {
48        // Pre-allocate string capacity for better performance
49        let estimated_capacity = self
50            .config
51            .base_url
52            .len()
53            .saturating_add(endpoint.len())
54            .saturating_add(query_params.len().saturating_mul(32)); // Rough estimate for query params
55
56        let mut url = String::with_capacity(estimated_capacity);
57        url.push_str(&self.config.base_url);
58        url.push_str(endpoint);
59
60        if !query_params.is_empty() {
61            url.push('?');
62            for (i, (key, value)) in query_params.iter().enumerate() {
63                if i > 0 {
64                    url.push('&');
65                }
66                url.push_str(&urlencoding::encode(key));
67                url.push('=');
68                url.push_str(&urlencoding::encode(value));
69            }
70        }
71
72        url
73    }
74
75    /// Create a new Veracode API client.
76    ///
77    /// # Arguments
78    ///
79    /// * `config` - Configuration containing API credentials and settings
80    ///
81    /// # Returns
82    ///
83    /// A new `VeracodeClient` instance ready to make API calls.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the API request fails, the resource is not found,
88    /// or authentication/authorization fails.
89    pub fn new(config: VeracodeConfig) -> Result<Self, VeracodeError> {
90        let mut client_builder = Client::builder();
91
92        // Use the certificate validation setting from config
93        if !config.validate_certificates {
94            client_builder = client_builder
95                .danger_accept_invalid_certs(true)
96                .danger_accept_invalid_hostnames(true);
97        }
98
99        // Configure HTTP timeouts from config
100        client_builder = client_builder
101            .connect_timeout(Duration::from_secs(config.connect_timeout))
102            .timeout(Duration::from_secs(config.request_timeout));
103
104        // Configure proxy if specified
105        if let Some(proxy_url) = &config.proxy_url {
106            let mut proxy = reqwest::Proxy::all(proxy_url)
107                .map_err(|e| VeracodeError::InvalidConfig(format!("Invalid proxy URL: {e}")))?;
108
109            // Add basic authentication if credentials are provided
110            if let (Some(username), Some(password)) =
111                (&config.proxy_username, &config.proxy_password)
112            {
113                proxy = proxy.basic_auth(username.expose_secret(), password.expose_secret());
114            }
115
116            client_builder = client_builder.proxy(proxy);
117        }
118
119        let client = client_builder.build().map_err(VeracodeError::Http)?;
120        Ok(Self { config, client })
121    }
122
123    /// Create an XML API variant of this client, reusing the same underlying HTTP connection pool.
124    ///
125    /// Rather than building a new `reqwest::Client` (which reloads system CA certificates and
126    /// discards pooled connections), this clones the existing client β€” `reqwest::Client` is
127    /// internally `Arc`-backed, so the clone is cheap and shares the connection pool.
128    #[must_use]
129    pub fn new_xml_variant(&self) -> Self {
130        let mut xml_config = self.config.clone();
131        xml_config.base_url = xml_config.xml_base_url.clone();
132        Self {
133            config: xml_config,
134            client: self.client.clone(),
135        }
136    }
137
138    /// Get the base URL for API requests.
139    #[must_use]
140    pub fn base_url(&self) -> &str {
141        &self.config.base_url
142    }
143
144    /// Get access to the configuration
145    #[must_use]
146    pub fn config(&self) -> &VeracodeConfig {
147        &self.config
148    }
149
150    /// Get access to the underlying reqwest client
151    #[must_use]
152    pub fn client(&self) -> &Client {
153        &self.client
154    }
155
156    /// Execute an HTTP request with retry logic and exponential backoff.
157    ///
158    /// This method implements the retry strategy defined in the client's configuration.
159    /// It will retry requests that fail due to transient errors (network issues,
160    /// server errors, rate limiting) using exponential backoff. For rate limiting (429),
161    /// it uses intelligent delays based on Veracode's minute-window rate limits.
162    ///
163    /// # Arguments
164    ///
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the API request fails, the resource is not found,
169    /// or authentication/authorization fails.
170    /// * `request_builder` - A closure that creates the `reqwest::RequestBuilder`
171    /// * `operation_name` - A human-readable name for logging/error messages
172    ///
173    /// # Returns
174    ///
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the API request fails, the resource is not found,
179    /// or authentication/authorization fails.
180    /// A `Result` containing the HTTP response or a `VeracodeError`.
181    async fn execute_with_retry<F>(
182        &self,
183        request_builder: F,
184        operation_name: Cow<'_, str>,
185    ) -> Result<reqwest::Response, VeracodeError>
186    where
187        F: Fn() -> reqwest::RequestBuilder,
188    {
189        let retry_config = &self.config.retry_config;
190        let start_time = Instant::now();
191        let mut total_delay = std::time::Duration::from_millis(0);
192
193        // If retries are disabled, make a single attempt
194        if retry_config.max_attempts == 0 {
195            return match request_builder().send().await {
196                Ok(response) => Ok(response),
197                Err(e) => Err(VeracodeError::Http(e)),
198            };
199        }
200
201        let mut last_error = None;
202        let mut rate_limit_attempts: u32 = 0;
203
204        for attempt in 1..=retry_config.max_attempts.saturating_add(1) {
205            // Build and send the request
206            match request_builder().send().await {
207                Ok(response) => {
208                    // Check for rate limiting before treating as success
209                    if response.status().as_u16() == 429 {
210                        // Extract Retry-After header if present
211                        let retry_after_seconds = response
212                            .headers()
213                            .get("retry-after")
214                            .and_then(|h| h.to_str().ok())
215                            .and_then(|s| s.parse::<u64>().ok());
216
217                        let message = "HTTP 429: Rate limit exceeded".to_string();
218                        let veracode_error = VeracodeError::RateLimited {
219                            retry_after_seconds,
220                            message,
221                        };
222
223                        // Increment rate limit attempt counter
224                        rate_limit_attempts = rate_limit_attempts.saturating_add(1);
225
226                        // Check if we should retry based on rate limit specific limits
227                        if attempt > retry_config.max_attempts
228                            || rate_limit_attempts > retry_config.rate_limit_max_attempts
229                        {
230                            last_error = Some(veracode_error);
231                            break;
232                        }
233
234                        // Calculate rate limit specific delay
235                        let delay = retry_config.calculate_rate_limit_delay(retry_after_seconds);
236                        total_delay = total_delay.saturating_add(delay);
237
238                        // Check total delay limit
239                        if total_delay.as_millis() > retry_config.max_total_delay_ms as u128 {
240                            let msg = format!(
241                                "{} exceeded maximum total retry time of {}ms after {} attempts",
242                                operation_name, retry_config.max_total_delay_ms, attempt
243                            );
244                            last_error = Some(VeracodeError::RetryExhausted(msg));
245                            break;
246                        }
247
248                        // Log rate limit with specific formatting
249                        let wait_time = match retry_after_seconds {
250                            Some(seconds) => format!("{seconds}s (from Retry-After header)"),
251                            None => format!("{}s (until next minute window)", delay.as_secs()),
252                        };
253                        warn!(
254                            "🚦 {operation_name} rate limited on attempt {attempt}, waiting {wait_time}"
255                        );
256
257                        // Wait and continue to next attempt
258                        tokio::time::sleep(delay).await;
259                        last_error = Some(veracode_error);
260                        continue;
261                    }
262
263                    if attempt > 1 {
264                        // Log successful retry for debugging
265                        info!("βœ… {operation_name} succeeded on attempt {attempt}");
266                    }
267                    return Ok(response);
268                }
269                Err(e) => {
270                    // For connection errors, network issues, etc., use normal retry logic
271                    let veracode_error = VeracodeError::Http(e);
272
273                    // Check if this is the last attempt or if the error is not retryable
274                    if attempt > retry_config.max_attempts
275                        || !retry_config.is_retryable_error(&veracode_error)
276                    {
277                        last_error = Some(veracode_error);
278                        break;
279                    }
280
281                    // Use normal exponential backoff for non-429 errors
282                    let delay = retry_config.calculate_delay(attempt);
283                    total_delay = total_delay.saturating_add(delay);
284
285                    // Check if we've exceeded the maximum total delay
286                    if total_delay.as_millis() > retry_config.max_total_delay_ms as u128 {
287                        // Format error message once
288                        let msg = format!(
289                            "{} exceeded maximum total retry time of {}ms after {} attempts",
290                            operation_name, retry_config.max_total_delay_ms, attempt
291                        );
292                        last_error = Some(VeracodeError::RetryExhausted(msg));
293                        break;
294                    }
295
296                    // Log retry attempt for debugging
297                    warn!(
298                        "⚠️  {operation_name} failed on attempt {attempt}, retrying in {}ms: {veracode_error}",
299                        delay.as_millis()
300                    );
301
302                    // Wait before next attempt
303                    tokio::time::sleep(delay).await;
304                    last_error = Some(veracode_error);
305                }
306            }
307        }
308
309        // All attempts failed - create error message efficiently
310        match last_error {
311            Some(error) => {
312                let elapsed = start_time.elapsed();
313                match error {
314                    VeracodeError::RetryExhausted(_) => Err(error),
315                    VeracodeError::Http(_)
316                    | VeracodeError::Serialization(_)
317                    | VeracodeError::Authentication(_)
318                    | VeracodeError::InvalidResponse(_)
319                    | VeracodeError::HttpStatus { .. }
320                    | VeracodeError::InvalidConfig(_)
321                    | VeracodeError::NotFound(_)
322                    | VeracodeError::RateLimited { .. }
323                    | VeracodeError::Validation(_) => {
324                        let msg = format!(
325                            "{} failed after {} attempts over {}ms: {}",
326                            operation_name,
327                            retry_config.max_attempts.saturating_add(1),
328                            elapsed.as_millis(),
329                            error
330                        );
331                        Err(VeracodeError::RetryExhausted(msg))
332                    }
333                }
334            }
335            None => {
336                let msg = format!(
337                    "{} failed after {} attempts with unknown error",
338                    operation_name,
339                    retry_config.max_attempts.saturating_add(1)
340                );
341                Err(VeracodeError::RetryExhausted(msg))
342            }
343        }
344    }
345
346    /// Generate HMAC signature for authentication based on official Veracode JavaScript implementation
347    fn generate_hmac_signature(
348        &self,
349        method: &str,
350        url: &str,
351        timestamp: u64,
352        nonce: &str,
353    ) -> Result<String, VeracodeError> {
354        let url_parsed = Url::parse(url)
355            .map_err(|_| VeracodeError::Authentication(INVALID_URL_MSG.to_string()))?;
356
357        let path_and_query = match url_parsed.query() {
358            Some(query) => format!("{}?{}", url_parsed.path(), query),
359            None => url_parsed.path().to_string(),
360        };
361
362        let host = url_parsed.host_str().unwrap_or("");
363
364        // Based on the official Veracode JavaScript implementation:
365        // var data = `id=${id}&host=${host}&url=${url}&method=${method}`;
366        let data = format!(
367            "id={}&host={}&url={}&method={}",
368            self.config.credentials.expose_api_id(),
369            host,
370            path_and_query,
371            method
372        );
373
374        let timestamp_str = timestamp.to_string();
375        let ver_str = "vcode_request_version_1";
376
377        // Convert hex strings to bytes
378        let key_bytes = hex::decode(self.config.credentials.expose_api_key())
379            .map_err(|_| VeracodeError::Authentication(INVALID_API_KEY_MSG.to_string()))?;
380
381        let nonce_bytes = hex::decode(nonce)
382            .map_err(|_| VeracodeError::Authentication(INVALID_NONCE_MSG.to_string()))?;
383
384        // Step 1: HMAC(nonce, key)
385        let mut mac1 = HmacSha256::new_from_slice(&key_bytes)
386            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
387        mac1.update(&nonce_bytes);
388        let hashed_nonce = mac1.finalize().into_bytes();
389
390        // Step 2: HMAC(timestamp, hashed_nonce)
391        let mut mac2 = HmacSha256::new_from_slice(&hashed_nonce)
392            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
393        mac2.update(timestamp_str.as_bytes());
394        let hashed_timestamp = mac2.finalize().into_bytes();
395
396        // Step 3: HMAC(ver_str, hashed_timestamp)
397        let mut mac3 = HmacSha256::new_from_slice(&hashed_timestamp)
398            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
399        mac3.update(ver_str.as_bytes());
400        let hashed_ver_str = mac3.finalize().into_bytes();
401
402        // Step 4: HMAC(data, hashed_ver_str)
403        let mut mac4 = HmacSha256::new_from_slice(&hashed_ver_str)
404            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
405        mac4.update(data.as_bytes());
406        let signature = mac4.finalize().into_bytes();
407
408        // Return the hex-encoded signature (lowercase)
409        Ok(hex::encode(signature).to_lowercase())
410    }
411
412    /// Generate authorization header for HMAC authentication
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the API request fails, the resource is not found,
417    /// or authentication/authorization fails.
418    pub fn generate_auth_header(&self, method: &str, url: &str) -> Result<String, VeracodeError> {
419        #[allow(clippy::cast_possible_truncation)]
420        let timestamp = SystemTime::now()
421            .duration_since(UNIX_EPOCH)
422            .map_err(|e| VeracodeError::Authentication(format!("System time error: {e}")))?
423            .as_millis() as u64; // Use milliseconds like JavaScript
424
425        // Generate a 16-byte random nonce and convert to hex string
426        let nonce_bytes: [u8; 16] = rand::random();
427        let nonce = hex::encode(nonce_bytes);
428
429        let signature = self.generate_hmac_signature(method, url, timestamp, &nonce)?;
430
431        Ok(format!(
432            "VERACODE-HMAC-SHA-256 id={},ts={},nonce={},sig={}",
433            self.config.credentials.expose_api_id(),
434            timestamp,
435            nonce,
436            signature
437        ))
438    }
439
440    /// Make a GET request to the specified endpoint.
441    ///
442    /// # Arguments
443    ///
444    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications")
445    /// * `query_params` - Optional query parameters as key-value pairs
446    ///
447    /// # Returns
448    ///
449    /// A `Result` containing the HTTP response.
450    ///
451    /// # Errors
452    ///
453    /// Returns an error if the API request fails, the resource is not found,
454    /// or authentication/authorization fails.
455    pub async fn get(
456        &self,
457        endpoint: &str,
458        query_params: Option<&[(String, String)]>,
459    ) -> Result<reqwest::Response, VeracodeError> {
460        // Pre-allocate URL capacity
461        let param_count = query_params.map_or(0, |p| p.len());
462        let estimated_capacity = self
463            .config
464            .base_url
465            .len()
466            .saturating_add(endpoint.len())
467            .saturating_add(param_count.saturating_mul(32));
468        let mut url = String::with_capacity(estimated_capacity);
469        url.push_str(&self.config.base_url);
470        url.push_str(endpoint);
471
472        if let Some(params) = query_params
473            && !params.is_empty()
474        {
475            url.push('?');
476            for (i, (key, value)) in params.iter().enumerate() {
477                if i > 0 {
478                    url.push('&');
479                }
480                url.push_str(key);
481                url.push('=');
482                url.push_str(value);
483            }
484        }
485
486        // Create request builder closure for retry logic
487        let request_builder = || {
488            // Re-generate auth header for each attempt to avoid signature expiry
489            let Ok(auth_header) = self.generate_auth_header("GET", &url) else {
490                return self.client.get("invalid://url");
491            };
492
493            self.client
494                .get(&url)
495                .header("Authorization", auth_header)
496                .header("Content-Type", "application/json")
497        };
498
499        // Use Cow::Borrowed for simple operations when possible
500        let operation_name = if endpoint.len() < 50 {
501            Cow::Owned(format!("GET {endpoint}"))
502        } else {
503            Cow::Borrowed("GET [long endpoint]")
504        };
505        self.execute_with_retry(request_builder, operation_name)
506            .await
507    }
508
509    /// Make a POST request to the specified endpoint.
510    ///
511    /// # Arguments
512    ///
513    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications")
514    /// * `body` - Optional request body that implements Serialize
515    ///
516    /// # Returns
517    ///
518    /// A `Result` containing the HTTP response.
519    ///
520    /// # Errors
521    ///
522    /// Returns an error if the API request fails, the resource is not found,
523    /// or authentication/authorization fails.
524    pub async fn post<T: Serialize>(
525        &self,
526        endpoint: &str,
527        body: Option<&T>,
528    ) -> Result<reqwest::Response, VeracodeError> {
529        let mut url =
530            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
531        url.push_str(&self.config.base_url);
532        url.push_str(endpoint);
533
534        // Serialize body once outside the retry loop for efficiency
535        let serialized_body = if let Some(body) = body {
536            Some(serde_json::to_string(body)?)
537        } else {
538            None
539        };
540
541        // Create request builder closure for retry logic
542        let request_builder = || {
543            // Re-generate auth header for each attempt to avoid signature expiry
544            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
545                return self.client.post("invalid://url");
546            };
547
548            let mut request = self
549                .client
550                .post(&url)
551                .header("Authorization", auth_header)
552                .header("Content-Type", "application/json");
553
554            if let Some(ref body_str) = serialized_body {
555                request = request.body(body_str.clone());
556            }
557
558            request
559        };
560
561        let operation_name = if endpoint.len() < 50 {
562            Cow::Owned(format!("POST {endpoint}"))
563        } else {
564            Cow::Borrowed("POST [long endpoint]")
565        };
566        self.execute_with_retry(request_builder, operation_name)
567            .await
568    }
569
570    /// Make a PUT request to the specified endpoint.
571    ///
572    /// # Arguments
573    ///
574    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications/guid")
575    /// * `body` - Optional request body that implements Serialize
576    ///
577    /// # Returns
578    ///
579    /// A `Result` containing the HTTP response.
580    ///
581    /// # Errors
582    ///
583    /// Returns an error if the API request fails, the resource is not found,
584    /// or authentication/authorization fails.
585    pub async fn put<T: Serialize>(
586        &self,
587        endpoint: &str,
588        body: Option<&T>,
589    ) -> Result<reqwest::Response, VeracodeError> {
590        let mut url =
591            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
592        url.push_str(&self.config.base_url);
593        url.push_str(endpoint);
594
595        // Serialize body once outside the retry loop for efficiency
596        let serialized_body = if let Some(body) = body {
597            Some(serde_json::to_string(body)?)
598        } else {
599            None
600        };
601
602        // Create request builder closure for retry logic
603        let request_builder = || {
604            // Re-generate auth header for each attempt to avoid signature expiry
605            let Ok(auth_header) = self.generate_auth_header("PUT", &url) else {
606                return self.client.put("invalid://url");
607            };
608
609            let mut request = self
610                .client
611                .put(&url)
612                .header("Authorization", auth_header)
613                .header("Content-Type", "application/json");
614
615            if let Some(ref body_str) = serialized_body {
616                request = request.body(body_str.clone());
617            }
618
619            request
620        };
621
622        let operation_name = if endpoint.len() < 50 {
623            Cow::Owned(format!("PUT {endpoint}"))
624        } else {
625            Cow::Borrowed("PUT [long endpoint]")
626        };
627        self.execute_with_retry(request_builder, operation_name)
628            .await
629    }
630
631    /// Make a DELETE request to the specified endpoint.
632    ///
633    /// # Arguments
634    ///
635    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications/guid")
636    ///
637    /// # Returns
638    ///
639    /// A `Result` containing the HTTP response.
640    ///
641    /// # Errors
642    ///
643    /// Returns an error if the API request fails, the resource is not found,
644    /// or authentication/authorization fails.
645    pub async fn delete(&self, endpoint: &str) -> Result<reqwest::Response, VeracodeError> {
646        let mut url =
647            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
648        url.push_str(&self.config.base_url);
649        url.push_str(endpoint);
650
651        // Create request builder closure for retry logic
652        let request_builder = || {
653            // Re-generate auth header for each attempt to avoid signature expiry
654            let Ok(auth_header) = self.generate_auth_header("DELETE", &url) else {
655                return self.client.delete("invalid://url");
656            };
657
658            self.client
659                .delete(&url)
660                .header("Authorization", auth_header)
661                .header("Content-Type", "application/json")
662        };
663
664        let operation_name = if endpoint.len() < 50 {
665            Cow::Owned(format!("DELETE {endpoint}"))
666        } else {
667            Cow::Borrowed("DELETE [long endpoint]")
668        };
669        self.execute_with_retry(request_builder, operation_name)
670            .await
671    }
672
673    /// Helper method to handle common response processing.
674    ///
675    /// Checks if the response is successful and returns an error if not.
676    ///
677    /// # Arguments
678    ///
679    /// * `response` - The HTTP response to check
680    /// * `context` - A description of the operation being performed (e.g., "get application")
681    ///
682    /// # Returns
683    ///
684    /// A `Result` containing the response if successful, or an error if not.
685    ///
686    /// # Error Context
687    ///
688    /// This method enhances error messages with context about the failed operation
689    /// to improve debugging and user experience.
690    ///
691    /// # Errors
692    ///
693    /// Returns an error if the API request fails, the resource is not found,
694    /// or authentication/authorization fails.
695    pub async fn handle_response(
696        response: reqwest::Response,
697        context: &str,
698    ) -> Result<reqwest::Response, VeracodeError> {
699        if !response.status().is_success() {
700            let status = response.status();
701            let status_code = status.as_u16();
702            let url = response.url().to_string();
703            let error_text = response.text().await?;
704
705            // Use structured HttpStatus error for better error handling
706            return Err(VeracodeError::HttpStatus {
707                status_code,
708                url,
709                message: format!("Failed to {context}: {error_text}"),
710            });
711        }
712        Ok(response)
713    }
714
715    /// Make a GET request with full URL construction and query parameter handling.
716    ///
717    /// This is a higher-level method that builds the full URL and handles query parameters.
718    ///
719    /// # Arguments
720    ///
721    /// * `endpoint` - The API endpoint path
722    /// * `query_params` - Optional query parameters
723    ///
724    /// # Returns
725    ///
726    /// A `Result` containing the HTTP response, pre-processed for success/failure.
727    ///
728    /// # Errors
729    ///
730    /// Returns an error if the API request fails, the resource is not found,
731    /// or authentication/authorization fails.
732    pub async fn get_with_query(
733        &self,
734        endpoint: &str,
735        query_params: Option<Vec<(String, String)>>,
736    ) -> Result<reqwest::Response, VeracodeError> {
737        let query_slice = query_params.as_deref();
738        let response = self.get(endpoint, query_slice).await?;
739        Self::handle_response(response, &format!("GET {endpoint}")).await
740    }
741
742    /// Make a POST request with automatic response handling.
743    ///
744    /// # Arguments
745    ///
746    /// * `endpoint` - The API endpoint path
747    /// * `body` - Optional request body
748    ///
749    /// # Returns
750    ///
751    /// A `Result` containing the HTTP response, pre-processed for success/failure.
752    ///
753    /// # Errors
754    ///
755    /// Returns an error if the API request fails, the resource is not found,
756    /// or authentication/authorization fails.
757    pub async fn post_with_response<T: Serialize>(
758        &self,
759        endpoint: &str,
760        body: Option<&T>,
761    ) -> Result<reqwest::Response, VeracodeError> {
762        let response = self.post(endpoint, body).await?;
763        Self::handle_response(response, &format!("POST {endpoint}")).await
764    }
765
766    /// Make a PUT request with automatic response handling.
767    ///
768    /// # Arguments
769    ///
770    /// * `endpoint` - The API endpoint path
771    /// * `body` - Optional request body
772    ///
773    /// # Returns
774    ///
775    /// A `Result` containing the HTTP response, pre-processed for success/failure.
776    ///
777    /// # Errors
778    ///
779    /// Returns an error if the API request fails, the resource is not found,
780    /// or authentication/authorization fails.
781    pub async fn put_with_response<T: Serialize>(
782        &self,
783        endpoint: &str,
784        body: Option<&T>,
785    ) -> Result<reqwest::Response, VeracodeError> {
786        let response = self.put(endpoint, body).await?;
787        Self::handle_response(response, &format!("PUT {endpoint}")).await
788    }
789
790    /// Make a DELETE request with automatic response handling.
791    ///
792    /// # Arguments
793    ///
794    /// * `endpoint` - The API endpoint path
795    ///
796    /// # Returns
797    ///
798    /// A `Result` containing the HTTP response, pre-processed for success/failure.
799    ///
800    /// # Errors
801    ///
802    /// Returns an error if the API request fails, the resource is not found,
803    /// or authentication/authorization fails.
804    pub async fn delete_with_response(
805        &self,
806        endpoint: &str,
807    ) -> Result<reqwest::Response, VeracodeError> {
808        let response = self.delete(endpoint).await?;
809        Self::handle_response(response, &format!("DELETE {endpoint}")).await
810    }
811
812    /// Make paginated GET requests to collect all results.
813    ///
814    /// This method automatically handles pagination by making multiple requests
815    /// and combining all results into a single response.
816    ///
817    /// # Arguments
818    ///
819    /// * `endpoint` - The API endpoint path
820    /// * `base_query_params` - Base query parameters (non-pagination)
821    /// * `page_size` - Number of items per page (default: 500)
822    ///
823    /// # Returns
824    ///
825    /// A `Result` containing all paginated results as a single response body string.
826    ///
827    /// # Errors
828    ///
829    /// Returns an error if the API request fails, the resource is not found,
830    /// or authentication/authorization fails.
831    pub async fn get_paginated(
832        &self,
833        endpoint: &str,
834        base_query_params: Option<Vec<(String, String)>>,
835        page_size: Option<u32>,
836    ) -> Result<String, VeracodeError> {
837        let size = page_size.unwrap_or(500);
838        let mut page: u32 = 0;
839        let mut all_items = Vec::new();
840        let mut page_info = None;
841
842        loop {
843            let mut query_params = base_query_params.clone().unwrap_or_default();
844            query_params.push(("page".to_string(), page.to_string()));
845            query_params.push(("size".to_string(), size.to_string()));
846
847            let response = self.get_with_query(endpoint, Some(query_params)).await?;
848            let response_text = response.text().await?;
849
850            // Validate JSON depth before parsing to prevent DoS attacks
851            validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
852                VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e))
853            })?;
854
855            // Try to parse as JSON to extract items and pagination info
856            if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
857                // Handle embedded response format
858                if let Some(embedded) = json_value.get("_embedded") {
859                    if let Some(items_array) =
860                        embedded.as_object().and_then(|obj| obj.values().next())
861                        && let Some(items) = items_array.as_array()
862                    {
863                        if items.is_empty() {
864                            break; // No more items
865                        }
866                        all_items.extend(items.clone());
867                    }
868                } else if let Some(items) = json_value.as_array() {
869                    // Handle direct array response
870                    if items.is_empty() {
871                        break;
872                    }
873                    all_items.extend(items.clone());
874                } else {
875                    // Single page response, return as-is
876                    return Ok(response_text);
877                }
878
879                // Check pagination info
880                if let Some(page_obj) = json_value.get("page") {
881                    page_info = Some(page_obj.clone());
882                    if let (Some(current), Some(total)) = (
883                        page_obj.get("number").and_then(|n| n.as_u64()),
884                        page_obj.get("totalPages").and_then(|n| n.as_u64()),
885                    ) && current.saturating_add(1) >= total
886                    {
887                        break; // Last page reached
888                    }
889                }
890            } else {
891                // Not JSON or parsing failed, return single response
892                return Ok(response_text);
893            }
894
895            page = page.saturating_add(1);
896
897            // Safety check to prevent infinite loops
898            if page > 100 {
899                break;
900            }
901        }
902
903        // Reconstruct response with all items
904        let combined_response = if let Some(page_info) = page_info {
905            // Use embedded format
906            serde_json::json!({
907                "_embedded": {
908                    "roles": all_items // This key might need to be dynamic
909                },
910                "page": page_info
911            })
912        } else {
913            // Use direct array format
914            serde_json::Value::Array(all_items)
915        };
916
917        Ok(combined_response.to_string())
918    }
919
920    /// Make a GET request with query parameters
921    ///
922    /// # Arguments
923    ///
924    /// * `endpoint` - The API endpoint to call
925    /// * `params` - Query parameters as a slice of tuples
926    ///
927    /// # Returns
928    ///
929    /// A `Result` containing the response or an error.
930    ///
931    /// # Errors
932    ///
933    /// Returns an error if the API request fails, the resource is not found,
934    /// or authentication/authorization fails.
935    pub async fn get_with_params(
936        &self,
937        endpoint: &str,
938        params: &[(&str, &str)],
939    ) -> Result<reqwest::Response, VeracodeError> {
940        let mut url =
941            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
942        url.push_str(&self.config.base_url);
943        url.push_str(endpoint);
944        let mut request_url =
945            Url::parse(&url).map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
946
947        // Add query parameters
948        if !params.is_empty() {
949            let mut query_pairs = request_url.query_pairs_mut();
950            for (key, value) in params {
951                query_pairs.append_pair(key, value);
952            }
953        }
954
955        let auth_header = self.generate_auth_header("GET", request_url.as_str())?;
956
957        let response = self
958            .client
959            .get(request_url)
960            .header("Authorization", auth_header)
961            .header("User-Agent", "Veracode Rust Client")
962            .send()
963            .await?;
964
965        Ok(response)
966    }
967
968    /// Make a POST request with form data
969    ///
970    /// # Arguments
971    ///
972    /// * `endpoint` - The API endpoint to call
973    /// * `params` - Form parameters as a slice of tuples
974    ///
975    /// # Returns
976    ///
977    /// A `Result` containing the response or an error.
978    ///
979    /// # Errors
980    ///
981    /// Returns an error if the API request fails, the resource is not found,
982    /// or authentication/authorization fails.
983    pub async fn post_form(
984        &self,
985        endpoint: &str,
986        params: &[(&str, &str)],
987    ) -> Result<reqwest::Response, VeracodeError> {
988        let mut url =
989            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
990        url.push_str(&self.config.base_url);
991        url.push_str(endpoint);
992
993        // Build form data - avoid unnecessary allocations
994        let form_data: Vec<(&str, &str)> = params.to_vec();
995
996        let auth_header = self.generate_auth_header("POST", &url)?;
997
998        let response = self
999            .client
1000            .post(&url)
1001            .header("Authorization", auth_header)
1002            .header("User-Agent", "Veracode Rust Client")
1003            .form(&form_data)
1004            .send()
1005            .await?;
1006
1007        Ok(response)
1008    }
1009
1010    /// Upload a file using multipart form data
1011    ///
1012    /// # Arguments
1013    ///
1014    /// * `endpoint` - The API endpoint to call
1015    /// * `params` - Additional form parameters
1016    /// * `file_field_name` - Name of the file field
1017    /// * `filename` - Name of the file
1018    /// * `file_data` - File data as bytes
1019    ///
1020    /// # Returns
1021    ///
1022    /// A `Result` containing the response or an error.
1023    ///
1024    /// # Errors
1025    ///
1026    /// Returns an error if the API request fails, the resource is not found,
1027    /// or authentication/authorization fails.
1028    pub async fn upload_file_multipart(
1029        &self,
1030        endpoint: &str,
1031        params: HashMap<&str, &str>,
1032        file_field_name: &str,
1033        filename: &str,
1034        file_data: Vec<u8>,
1035    ) -> Result<reqwest::Response, VeracodeError> {
1036        let mut url =
1037            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
1038        url.push_str(&self.config.base_url);
1039        url.push_str(endpoint);
1040
1041        // Build multipart form
1042        let mut form = multipart::Form::new();
1043
1044        // Add regular form fields
1045        for (key, value) in params {
1046            form = form.text(key.to_string(), value.to_string());
1047        }
1048
1049        // Add file
1050        let part = multipart::Part::bytes(file_data)
1051            .file_name(filename.to_string())
1052            .mime_str("application/octet-stream")
1053            .map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
1054
1055        form = form.part(file_field_name.to_string(), part);
1056
1057        let auth_header = self.generate_auth_header("POST", &url)?;
1058
1059        let response = self
1060            .client
1061            .post(&url)
1062            .header("Authorization", auth_header)
1063            .header("User-Agent", "Veracode Rust Client")
1064            .multipart(form)
1065            .send()
1066            .await?;
1067
1068        Ok(response)
1069    }
1070
1071    /// Upload a file using multipart form data with PUT method (for pipeline scans)
1072    ///
1073    /// # Arguments
1074    ///
1075    /// * `url` - The full URL to upload to
1076    /// * `file_field_name` - Name of the file field
1077    /// * `filename` - Name of the file
1078    /// * `file_data` - File data as bytes
1079    /// * `additional_headers` - Additional headers to include
1080    ///
1081    /// # Returns
1082    ///
1083    /// A `Result` containing the response or an error.
1084    ///
1085    /// # Errors
1086    ///
1087    /// Returns an error if the API request fails, the resource is not found,
1088    /// or authentication/authorization fails.
1089    pub async fn upload_file_multipart_put(
1090        &self,
1091        url: &str,
1092        file_field_name: &str,
1093        filename: &str,
1094        file_data: Vec<u8>,
1095        additional_headers: Option<HashMap<&str, &str>>,
1096    ) -> Result<reqwest::Response, VeracodeError> {
1097        // Build multipart form
1098        let part = multipart::Part::bytes(file_data)
1099            .file_name(filename.to_string())
1100            .mime_str("application/octet-stream")
1101            .map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
1102
1103        let form = multipart::Form::new().part(file_field_name.to_string(), part);
1104
1105        let auth_header = self.generate_auth_header("PUT", url)?;
1106
1107        let mut request = self
1108            .client
1109            .put(url)
1110            .header("Authorization", auth_header)
1111            .header("User-Agent", "Veracode Rust Client")
1112            .multipart(form);
1113
1114        // Add any additional headers
1115        if let Some(headers) = additional_headers {
1116            for (key, value) in headers {
1117                request = request.header(key, value);
1118            }
1119        }
1120
1121        let response = request.send().await?;
1122        Ok(response)
1123    }
1124
1125    /// Upload a file with query parameters (like Java implementation)
1126    ///
1127    /// This method mimics the Java API wrapper's approach where parameters
1128    /// are added to the query string and the file is uploaded separately.
1129    ///
1130    /// Memory optimization: Uses Cow for strings and Arc for file data to minimize cloning
1131    /// during retry attempts. Automatically retries on transient failures.
1132    ///
1133    /// # Arguments
1134    ///
1135    /// * `endpoint` - The API endpoint to call
1136    /// * `query_params` - Query parameters as key-value pairs
1137    /// * `file_field_name` - Name of the file field
1138    /// * `filename` - Name of the file
1139    /// * `file_data` - File data as bytes
1140    ///
1141    /// # Returns
1142    ///
1143    /// A `Result` containing the response or an error.
1144    ///
1145    /// # Errors
1146    ///
1147    /// Returns an error if the API request fails, the resource is not found,
1148    /// or authentication/authorization fails.
1149    pub async fn upload_file_with_query_params(
1150        &self,
1151        endpoint: &str,
1152        query_params: &[(&str, &str)],
1153        file_field_name: &str,
1154        filename: &str,
1155        file_data: Vec<u8>,
1156    ) -> Result<reqwest::Response, VeracodeError> {
1157        // Build URL with query parameters using centralized helper for consistency
1158        let url = self.build_url_with_params(endpoint, query_params);
1159
1160        // Wrap file data in Arc to avoid cloning during retries
1161        let file_data_arc = Arc::new(file_data);
1162
1163        // Use Cow for strings to minimize allocations - borrow for short strings, own for long ones
1164        let filename_cow: Cow<str> = if filename.len() < 128 {
1165            Cow::Borrowed(filename)
1166        } else {
1167            Cow::Owned(filename.to_string())
1168        };
1169
1170        let field_name_cow: Cow<str> = if file_field_name.len() < 32 {
1171            Cow::Borrowed(file_field_name)
1172        } else {
1173            Cow::Owned(file_field_name.to_string())
1174        };
1175
1176        // Create request builder closure for retry logic
1177        let request_builder = || {
1178            // Clone Arc (cheap - just increments reference count)
1179            let file_data_clone = Arc::clone(&file_data_arc);
1180
1181            // Re-create multipart form for each attempt
1182            let Ok(part) = multipart::Part::bytes((*file_data_clone).clone())
1183                .file_name(filename_cow.to_string())
1184                .mime_str("application/octet-stream")
1185            else {
1186                return self.client.post("invalid://url");
1187            };
1188
1189            let form = multipart::Form::new().part(field_name_cow.to_string(), part);
1190
1191            // Re-generate auth header for each attempt to avoid signature expiry
1192            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1193                return self.client.post("invalid://url");
1194            };
1195
1196            self.client
1197                .post(&url)
1198                .header("Authorization", auth_header)
1199                .header("User-Agent", "Veracode Rust Client")
1200                .multipart(form)
1201        };
1202
1203        // Use Cow for operation name based on endpoint length to minimize allocations
1204        let operation_name: Cow<str> = if endpoint.len() < 50 {
1205            Cow::Owned(format!("File Upload POST {endpoint}"))
1206        } else {
1207            Cow::Borrowed("File Upload POST [long endpoint]")
1208        };
1209
1210        self.execute_with_retry(request_builder, operation_name)
1211            .await
1212    }
1213
1214    /// Make a POST request with query parameters (like Java implementation for XML API)
1215    ///
1216    /// This method mimics the Java API wrapper's approach for POST operations
1217    /// where parameters are added to the query string rather than form data.
1218    ///
1219    /// # Arguments
1220    ///
1221    /// * `endpoint` - The API endpoint to call
1222    /// * `query_params` - Query parameters as key-value pairs
1223    ///
1224    /// # Returns
1225    ///
1226    /// A `Result` containing the response or an error.
1227    ///
1228    /// # Errors
1229    ///
1230    /// Returns an error if the API request fails, the resource is not found,
1231    /// or authentication/authorization fails.
1232    pub async fn post_with_query_params(
1233        &self,
1234        endpoint: &str,
1235        query_params: &[(&str, &str)],
1236    ) -> Result<reqwest::Response, VeracodeError> {
1237        // Build URL with query parameters using centralized helper
1238        let url = self.build_url_with_params(endpoint, query_params);
1239
1240        let auth_header = self.generate_auth_header("POST", &url)?;
1241
1242        let response = self
1243            .client
1244            .post(&url)
1245            .header("Authorization", auth_header)
1246            .header("User-Agent", "Veracode Rust Client")
1247            .send()
1248            .await?;
1249
1250        Ok(response)
1251    }
1252
1253    /// Make a GET request with query parameters (like Java implementation for XML API)
1254    ///
1255    /// This method mimics the Java API wrapper's approach for GET operations
1256    /// where parameters are added to the query string.
1257    ///
1258    /// # Arguments
1259    ///
1260    /// * `endpoint` - The API endpoint to call
1261    /// * `query_params` - Query parameters as key-value pairs
1262    ///
1263    /// # Returns
1264    ///
1265    /// A `Result` containing the response or an error.
1266    ///
1267    /// # Errors
1268    ///
1269    /// Returns an error if the API request fails, the resource is not found,
1270    /// or authentication/authorization fails.
1271    pub async fn get_with_query_params(
1272        &self,
1273        endpoint: &str,
1274        query_params: &[(&str, &str)],
1275    ) -> Result<reqwest::Response, VeracodeError> {
1276        // Build URL with query parameters using centralized helper
1277        let url = self.build_url_with_params(endpoint, query_params);
1278
1279        let auth_header = self.generate_auth_header("GET", &url)?;
1280
1281        let response = self
1282            .client
1283            .get(&url)
1284            .header("Authorization", auth_header)
1285            .header("User-Agent", "Veracode Rust Client")
1286            .send()
1287            .await?;
1288
1289        Ok(response)
1290    }
1291
1292    /// Upload a large file using chunked streaming (for uploadlargefile.do)
1293    ///
1294    /// This method implements chunked upload functionality similar to the Java API wrapper.
1295    /// It uploads files in chunks and provides progress tracking capabilities.
1296    ///
1297    /// # Arguments
1298    ///
1299    /// * `endpoint` - The API endpoint to call  
1300    /// * `query_params` - Query parameters as key-value pairs
1301    /// * `file_path` - Path to the file to upload
1302    /// * `content_type` - Content type for the file (default: binary/octet-stream)
1303    /// * `progress_callback` - Optional callback for progress tracking
1304    ///
1305    /// # Returns
1306    ///
1307    /// A `Result` containing the response or an error.
1308    ///
1309    /// # Errors
1310    ///
1311    /// Returns an error if the API request fails, the resource is not found,
1312    /// or authentication/authorization fails.
1313    pub async fn upload_large_file_chunked<F>(
1314        &self,
1315        endpoint: &str,
1316        query_params: &[(&str, &str)],
1317        file_path: &str,
1318        content_type: Option<&str>,
1319        progress_callback: Option<F>,
1320    ) -> Result<reqwest::Response, VeracodeError>
1321    where
1322        F: Fn(u64, u64, f64) + Send + Sync,
1323    {
1324        // Build URL with query parameters using centralized helper
1325        let url = self.build_url_with_params(endpoint, query_params);
1326
1327        // Open file and get size
1328        let mut file = File::open(file_path)
1329            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to open file: {e}")))?;
1330
1331        let file_size = file
1332            .metadata()
1333            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to get file size: {e}")))?
1334            .len();
1335
1336        // Check file size limit (2GB for uploadlargefile.do)
1337        #[allow(clippy::arithmetic_side_effects)]
1338        const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // 2GB
1339        if file_size > MAX_FILE_SIZE {
1340            return Err(VeracodeError::InvalidConfig(format!(
1341                "File size ({file_size} bytes) exceeds maximum limit of {MAX_FILE_SIZE} bytes"
1342            )));
1343        }
1344
1345        // Read entire file into memory for progress tracking and retry support
1346        file.seek(SeekFrom::Start(0))
1347            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to seek file: {e}")))?;
1348
1349        #[allow(clippy::cast_possible_truncation)]
1350        let mut file_data_vec = Vec::with_capacity(file_size as usize);
1351        file.read_to_end(&mut file_data_vec)
1352            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to read file: {e}")))?;
1353
1354        // Convert to Bytes for cheap cloning (Arc-backed internally)
1355        // This prevents expensive memory copies on each retry attempt
1356        let file_data = Bytes::from(file_data_vec);
1357        let content_type_cow: Cow<str> =
1358            content_type.map_or(Cow::Borrowed("binary/octet-stream"), |ct| {
1359                if ct.len() < 64 {
1360                    Cow::Borrowed(ct)
1361                } else {
1362                    Cow::Owned(ct.to_string())
1363                }
1364            });
1365
1366        // Create request builder closure for retry logic
1367        let request_builder = || {
1368            // Clone Bytes (cheap - Arc-backed internally, just increments reference count)
1369            let body_data = file_data.clone();
1370
1371            // Re-generate auth header for each attempt to avoid signature expiry
1372            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1373                return self.client.post("invalid://url");
1374            };
1375
1376            self.client
1377                .post(&url)
1378                .header("Authorization", auth_header)
1379                .header("User-Agent", "Veracode Rust Client")
1380                .header("Content-Type", content_type_cow.as_ref())
1381                .header("Content-Length", file_size.to_string())
1382                .body(body_data)
1383        };
1384
1385        // Track progress if callback provided (before upload starts)
1386        if let Some(ref callback) = progress_callback {
1387            callback(0, file_size, 0.0);
1388        }
1389
1390        // Use optimized operation name
1391        let operation_name: Cow<str> = if endpoint.len() < 50 {
1392            Cow::Owned(format!("Large File Upload POST {endpoint}"))
1393        } else {
1394            Cow::Borrowed("Large File Upload POST [long endpoint]")
1395        };
1396
1397        let response = self
1398            .execute_with_retry(request_builder, operation_name)
1399            .await?;
1400
1401        // Track progress if callback provided (after upload completes)
1402        if let Some(callback) = progress_callback {
1403            callback(file_size, file_size, 100.0);
1404        }
1405
1406        Ok(response)
1407    }
1408
1409    /// Upload a file with binary data (optimized for uploadlargefile.do)
1410    ///
1411    /// This method uploads a file as raw binary data without multipart encoding,
1412    /// which is the expected format for the uploadlargefile.do endpoint.
1413    ///
1414    /// Memory optimization: Uses Arc for file data and Cow for strings to minimize
1415    /// allocations during retry attempts. Automatically retries on transient failures.
1416    ///
1417    /// # Arguments
1418    ///
1419    /// * `endpoint` - The API endpoint to call
1420    /// * `query_params` - Query parameters as key-value pairs  
1421    /// * `file_data` - File data as bytes
1422    /// * `content_type` - Content type for the file
1423    ///
1424    /// # Returns
1425    ///
1426    /// A `Result` containing the response or an error.
1427    ///
1428    /// # Errors
1429    ///
1430    /// Returns an error if the API request fails, the resource is not found,
1431    /// or authentication/authorization fails.
1432    pub async fn upload_file_binary(
1433        &self,
1434        endpoint: &str,
1435        query_params: &[(&str, &str)],
1436        file_data: Vec<u8>,
1437        content_type: &str,
1438    ) -> Result<reqwest::Response, VeracodeError> {
1439        // Build URL with query parameters using centralized helper
1440        let url = self.build_url_with_params(endpoint, query_params);
1441
1442        // Convert to Bytes for cheap cloning (Arc-backed internally)
1443        // This prevents expensive memory copies on each retry attempt
1444        let file_data = Bytes::from(file_data);
1445        let file_size = file_data.len();
1446
1447        // Use Cow for content type to minimize allocations
1448        let content_type_cow: Cow<str> = if content_type.len() < 64 {
1449            Cow::Borrowed(content_type)
1450        } else {
1451            Cow::Owned(content_type.to_string())
1452        };
1453
1454        // Create request builder closure for retry logic
1455        let request_builder = || {
1456            // Clone Bytes (cheap - Arc-backed internally, just increments reference count)
1457            let body_data = file_data.clone();
1458
1459            // Re-generate auth header for each attempt to avoid signature expiry
1460            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1461                return self.client.post("invalid://url");
1462            };
1463
1464            self.client
1465                .post(&url)
1466                .header("Authorization", auth_header)
1467                .header("User-Agent", "Veracode Rust Client")
1468                .header("Content-Type", content_type_cow.as_ref())
1469                .header("Content-Length", file_size.to_string())
1470                .body(body_data)
1471        };
1472
1473        // Use optimized operation name based on endpoint length
1474        let operation_name: Cow<str> = if endpoint.len() < 50 {
1475            Cow::Owned(format!("Binary File Upload POST {endpoint}"))
1476        } else {
1477            Cow::Borrowed("Binary File Upload POST [long endpoint]")
1478        };
1479
1480        self.execute_with_retry(request_builder, operation_name)
1481            .await
1482    }
1483
1484    /// Upload a file with streaming (memory-efficient for large files)
1485    ///
1486    /// This method streams the file directly from disk without loading it entirely into memory.
1487    /// This is the recommended approach for large files (>100MB) to avoid high memory usage.
1488    ///
1489    /// # Arguments
1490    ///
1491    /// * `endpoint` - The API endpoint to call
1492    /// * `query_params` - Query parameters as key-value pairs
1493    /// * `file_path` - Path to the file to upload
1494    /// * `file_size` - Size of the file in bytes
1495    /// * `content_type` - Content type for the file
1496    ///
1497    /// # Returns
1498    ///
1499    /// A `Result` containing the response or an error.
1500    ///
1501    /// # Errors
1502    ///
1503    /// Returns an error if the API request fails, the resource is not found,
1504    /// or authentication/authorization fails.
1505    pub async fn upload_file_streaming(
1506        &self,
1507        endpoint: &str,
1508        query_params: &[(&str, &str)],
1509        file_path: &str,
1510        file_size: u64,
1511        content_type: &str,
1512    ) -> Result<reqwest::Response, VeracodeError> {
1513        // Build URL with query parameters using centralized helper
1514        let url = self.build_url_with_params(endpoint, query_params);
1515
1516        // Open file using tokio for async streaming
1517        let file = TokioFile::open(file_path)
1518            .await
1519            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to open file: {e}")))?;
1520
1521        // Create a stream from the file using tokio_util
1522        let stream = tokio_util::io::ReaderStream::new(file);
1523        let body = Body::wrap_stream(stream);
1524
1525        // Generate auth header
1526        let auth_header = self.generate_auth_header("POST", &url)?;
1527
1528        // Build and send the request (streaming upload - no retry support)
1529        // Note: Streaming bodies cannot be retried because the stream is consumed
1530        let response = self
1531            .client
1532            .post(&url)
1533            .header("Authorization", auth_header)
1534            .header("User-Agent", "Veracode Rust Client")
1535            .header("Content-Type", content_type)
1536            .header("Content-Length", file_size.to_string())
1537            .body(body)
1538            .send()
1539            .await
1540            .map_err(VeracodeError::Http)?;
1541
1542        Ok(response)
1543    }
1544}
1545
1546#[cfg(test)]
1547#[allow(clippy::expect_used)] // Test code: expect is acceptable for test setup
1548mod tests {
1549    use super::*;
1550    use proptest::prelude::*;
1551
1552    // ============================================================================
1553    // TIER 1: PROPERTY-BASED SECURITY TESTS (Fast, High ROI)
1554    // ============================================================================
1555
1556    /// Helper to create a test config with dummy credentials
1557    fn create_test_config() -> VeracodeConfig {
1558        use crate::{VeracodeCredentials, VeracodeRegion};
1559
1560        VeracodeConfig {
1561            credentials: VeracodeCredentials::new(
1562                "test_api_id".to_string(),
1563                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
1564            ),
1565            base_url: "https://api.veracode.com".to_string(),
1566            rest_base_url: "https://api.veracode.com".to_string(),
1567            xml_base_url: "https://analysiscenter.veracode.com".to_string(),
1568            region: VeracodeRegion::Commercial,
1569            validate_certificates: true,
1570            connect_timeout: 30,
1571            request_timeout: 300,
1572            proxy_url: None,
1573            proxy_username: None,
1574            proxy_password: None,
1575            retry_config: Default::default(),
1576        }
1577    }
1578
1579    // ============================================================================
1580    // SECURITY TEST: URL Construction & Parameter Encoding
1581    // ============================================================================
1582
1583    proptest! {
1584        #![proptest_config(ProptestConfig {
1585            cases: if cfg!(miri) { 5 } else { 1000 },
1586            failure_persistence: None,
1587            .. ProptestConfig::default()
1588        })]
1589
1590        /// Property: URL parameter encoding must prevent injection attacks
1591        /// Tests that special characters are properly encoded and cannot break URL structure
1592        #[test]
1593        fn proptest_url_params_prevent_injection(
1594            key in "[a-zA-Z0-9_]{1,50}",
1595            value in ".*{0,100}",
1596        ) {
1597            let config = create_test_config();
1598            let client = VeracodeClient::new(config)
1599                .expect("valid test client configuration");
1600
1601            let params = vec![(key.as_str(), value.as_str())];
1602            let url = client.build_url_with_params("/api/test", &params);
1603
1604            // Property 1: URL must not contain unencoded dangerous characters
1605            prop_assert!(!url.contains("<script>"));
1606            prop_assert!(!url.contains("javascript:"));
1607
1608            // Property 2: URL must contain properly encoded parameters
1609            prop_assert!(url.starts_with("https://api.veracode.com/api/test"));
1610
1611            // Property 3: If params are present, URL must contain '?'
1612            if !params.is_empty() && !key.is_empty() {
1613                prop_assert!(url.contains('?'));
1614            }
1615        }
1616
1617        /// Property: URL construction must handle capacity overflow safely
1618        /// Tests that large numbers of parameters don't cause panics or overflows
1619        #[test]
1620        fn proptest_url_params_capacity_safe(
1621            param_count in 0usize..=100,
1622        ) {
1623            let config = create_test_config();
1624            let client = VeracodeClient::new(config)
1625                .expect("valid test client configuration");
1626
1627            // Create param_count parameters
1628            let params: Vec<(&str, &str)> = (0..param_count)
1629                .map(|_| ("key", "value"))
1630                .collect();
1631
1632            // Must not panic on capacity calculations
1633            let url = client.build_url_with_params("/api/test", &params);
1634
1635            // Property: URL should be valid and not panic
1636            prop_assert!(url.starts_with("https://"));
1637            prop_assert!(url.len() < 100000); // Reasonable upper bound
1638        }
1639
1640        /// Property: Empty and whitespace-only parameters are handled safely
1641        #[test]
1642        fn proptest_url_params_empty_safe(
1643            key in "\\s*",
1644            value in "\\s*",
1645        ) {
1646            let config = create_test_config();
1647            let client = VeracodeClient::new(config)
1648                .expect("valid test client configuration");
1649
1650            let params = vec![(key.as_str(), value.as_str())];
1651            let url = client.build_url_with_params("/api/test", &params);
1652
1653            // Must not panic and produce valid URL
1654            prop_assert!(url.starts_with("https://"));
1655        }
1656    }
1657
1658    // ============================================================================
1659    // SECURITY TEST: HMAC Signature Generation
1660    // ============================================================================
1661
1662    proptest! {
1663        #![proptest_config(ProptestConfig {
1664            cases: if cfg!(miri) { 5 } else { 1000 },
1665            failure_persistence: None,
1666            .. ProptestConfig::default()
1667        })]
1668
1669        /// Property: HMAC signature generation must handle invalid URLs gracefully
1670        /// Tests that malformed URLs return errors instead of panicking
1671        #[test]
1672        fn proptest_hmac_invalid_urls_return_error(
1673            invalid_url in ".*{0,100}",
1674        ) {
1675            let config = create_test_config();
1676            let client = VeracodeClient::new(config)
1677                .expect("valid test client configuration");
1678
1679            // Property: Invalid URLs must return Err, never panic
1680            let result = client.generate_hmac_signature(
1681                "GET",
1682                &invalid_url,
1683                1234567890000,
1684                "0123456789abcdef0123456789abcdef",
1685            );
1686
1687            // Either succeeds (if URL happens to be valid) or returns error
1688            match result {
1689                Ok(_) => {
1690                    // If it succeeded, the URL must have been parseable
1691                    prop_assert!(Url::parse(&invalid_url).is_ok());
1692                },
1693                Err(e) => {
1694                    // Error must be Authentication error
1695                    prop_assert!(matches!(e, VeracodeError::Authentication(_)));
1696                }
1697            }
1698        }
1699
1700        /// Property: HMAC signature must be deterministic
1701        /// Same inputs must always produce the same signature
1702        #[test]
1703        fn proptest_hmac_deterministic(
1704            method in "[A-Z]{3,7}",
1705            timestamp in 1000000000000u64..2000000000000u64,
1706        ) {
1707            let config = create_test_config();
1708            let client = VeracodeClient::new(config)
1709                .expect("valid test client configuration");
1710
1711            let url = "https://api.veracode.com/api/test";
1712            let nonce = "0123456789abcdef0123456789abcdef";
1713
1714            let sig1 = client.generate_hmac_signature(&method, url, timestamp, nonce);
1715            let sig2 = client.generate_hmac_signature(&method, url, timestamp, nonce);
1716
1717            // Property: Deterministic - same inputs produce same output
1718            match (sig1, sig2) {
1719                (Ok(s1), Ok(s2)) => prop_assert_eq!(s1, s2),
1720                (Err(_), Err(_)) => {}, // Both failed - also deterministic
1721                _ => prop_assert!(false, "Non-deterministic result"),
1722            }
1723        }
1724
1725        /// Property: Invalid hex nonce must return error
1726        /// Tests that non-hex nonces are rejected safely
1727        #[test]
1728        fn proptest_hmac_invalid_nonce_returns_error(
1729            invalid_nonce in "[^0-9a-fA-F]{1,32}",
1730        ) {
1731            let config = create_test_config();
1732            let client = VeracodeClient::new(config)
1733                .expect("valid test client configuration");
1734
1735            let result = client.generate_hmac_signature(
1736                "GET",
1737                "https://api.veracode.com/api/test",
1738                1234567890000,
1739                &invalid_nonce,
1740            );
1741
1742            // Property: Non-hex nonce must return Authentication error
1743            prop_assert!(matches!(result, Err(VeracodeError::Authentication(_))));
1744        }
1745
1746        /// Property: Timestamp overflow must be handled safely
1747        /// Tests edge cases in timestamp handling
1748        #[test]
1749        fn proptest_hmac_timestamp_safe(
1750            timestamp in any::<u64>(),
1751        ) {
1752            let config = create_test_config();
1753            let client = VeracodeClient::new(config)
1754                .expect("valid test client configuration");
1755
1756            let url = "https://api.veracode.com/api/test";
1757            let nonce = "0123456789abcdef0123456789abcdef";
1758
1759            // Must not panic on any timestamp value
1760            let result = client.generate_hmac_signature("GET", url, timestamp, nonce);
1761
1762            // Property: Either succeeds or returns error, never panics
1763            prop_assert!(result.is_ok() || result.is_err());
1764        }
1765    }
1766
1767    // ============================================================================
1768    // SECURITY TEST: Authentication Header Generation
1769    // ============================================================================
1770
1771    proptest! {
1772        #![proptest_config(ProptestConfig {
1773            cases: if cfg!(miri) { 5 } else { 1000 },
1774            failure_persistence: None,
1775            .. ProptestConfig::default()
1776        })]
1777
1778        /// Property: Auth header must contain all required components
1779        /// Tests that generated headers have proper VERACODE-HMAC-SHA-256 format
1780        #[test]
1781        fn proptest_auth_header_format(
1782            method in "[A-Z]{3,7}",
1783        ) {
1784            let config = create_test_config();
1785            let client = VeracodeClient::new(config)
1786                .expect("valid test client configuration");
1787
1788            let url = "https://api.veracode.com/api/test";
1789            let result = client.generate_auth_header(&method, url);
1790
1791            if let Ok(header) = result {
1792                // Property 1: Must start with correct prefix
1793                prop_assert!(header.starts_with("VERACODE-HMAC-SHA-256"));
1794
1795                // Property 2: Must contain all required fields
1796                prop_assert!(header.contains("id="));
1797                prop_assert!(header.contains("ts="));
1798                prop_assert!(header.contains("nonce="));
1799                prop_assert!(header.contains("sig="));
1800
1801                // Property 3: Fields must be comma-separated
1802                let parts: Vec<&str> = header.split(',').collect();
1803                prop_assert_eq!(parts.len(), 4);
1804            }
1805        }
1806
1807        /// Property: Auth header nonce must be unique and valid hex
1808        /// Tests that nonces are properly generated as 32-character hex strings
1809        #[test]
1810        fn proptest_auth_header_nonce_unique(
1811            _seed in any::<u8>(),
1812        ) {
1813            let config = create_test_config();
1814            let client = VeracodeClient::new(config)
1815                .expect("valid test client configuration");
1816
1817            let url = "https://api.veracode.com/api/test";
1818
1819            // Generate two headers
1820            let header1 = client.generate_auth_header("GET", url)
1821                .expect("valid auth header generation");
1822            let header2 = client.generate_auth_header("GET", url)
1823                .expect("valid auth header generation");
1824
1825            // Extract nonces using a helper function (avoids lifetime issues)
1826            fn extract_nonce(h: &str) -> Option<String> {
1827                Some(h.split("nonce=")
1828                    .nth(1)?
1829                    .split(',')
1830                    .next()?
1831                    .to_string())
1832            }
1833
1834            if let (Some(nonce1), Some(nonce2)) = (extract_nonce(&header1), extract_nonce(&header2)) {
1835                // Property 1: Nonces should be different (probabilistically)
1836                // With 128-bit random, collision is extremely unlikely
1837                prop_assert_ne!(&nonce1, &nonce2);
1838
1839                // Property 2: Nonces must be valid hex (32 chars for 16 bytes)
1840                prop_assert_eq!(nonce1.len(), 32);
1841                prop_assert_eq!(nonce2.len(), 32);
1842                prop_assert!(nonce1.chars().all(|c| c.is_ascii_hexdigit()));
1843                prop_assert!(nonce2.chars().all(|c| c.is_ascii_hexdigit()));
1844            }
1845        }
1846    }
1847
1848    // ============================================================================
1849    // SECURITY TEST: Configuration & Client Creation
1850    // ============================================================================
1851
1852    proptest! {
1853        #![proptest_config(ProptestConfig {
1854            cases: if cfg!(miri) { 5 } else { 100 },
1855            failure_persistence: None,
1856            .. ProptestConfig::default()
1857        })]
1858
1859        /// Property: Client creation with invalid config must fail gracefully
1860        /// Tests that invalid proxy URLs are caught during client creation
1861        #[test]
1862        fn proptest_client_creation_invalid_proxy(
1863            invalid_proxy in ".*{0,100}",
1864        ) {
1865            use crate::{VeracodeCredentials, VeracodeRegion};
1866
1867            let config = VeracodeConfig {
1868                credentials: VeracodeCredentials::new(
1869                    "test_api_id".to_string(),
1870                    "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
1871                ),
1872                base_url: "https://api.veracode.com".to_string(),
1873                rest_base_url: "https://api.veracode.com".to_string(),
1874                xml_base_url: "https://analysiscenter.veracode.com".to_string(),
1875                region: VeracodeRegion::Commercial,
1876                validate_certificates: true,
1877                connect_timeout: 30,
1878                request_timeout: 300,
1879                proxy_url: Some(invalid_proxy.clone()),
1880                proxy_username: None,
1881                proxy_password: None,
1882                retry_config: Default::default(),
1883            };
1884
1885            let result = VeracodeClient::new(config);
1886
1887            // Property: Either succeeds (if proxy URL is valid) or returns InvalidConfig error
1888            match result {
1889                Ok(_) => {
1890                    // If successful, proxy URL must be valid
1891                    prop_assert!(reqwest::Proxy::all(&invalid_proxy).is_ok());
1892                },
1893                Err(e) => {
1894                    // Must be InvalidConfig error
1895                    prop_assert!(matches!(e, VeracodeError::InvalidConfig(_)));
1896                }
1897            }
1898        }
1899
1900        /// Property: Timeout values must be handled safely
1901        /// Tests that extreme timeout values don't cause panics
1902        #[test]
1903        fn proptest_client_timeouts_safe(
1904            connect_timeout in 1u64..=3600,
1905            request_timeout in 1u64..=7200,
1906        ) {
1907            use crate::{VeracodeCredentials, VeracodeRegion};
1908
1909            let config = VeracodeConfig {
1910                credentials: VeracodeCredentials::new(
1911                    "test_api_id".to_string(),
1912                    "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
1913                ),
1914                base_url: "https://api.veracode.com".to_string(),
1915                rest_base_url: "https://api.veracode.com".to_string(),
1916                xml_base_url: "https://analysiscenter.veracode.com".to_string(),
1917                region: VeracodeRegion::Commercial,
1918                validate_certificates: true,
1919                connect_timeout,
1920                request_timeout,
1921                proxy_url: None,
1922                proxy_username: None,
1923                proxy_password: None,
1924                retry_config: Default::default(),
1925            };
1926
1927            // Must not panic on any valid timeout values
1928            let result = VeracodeClient::new(config);
1929            prop_assert!(result.is_ok());
1930        }
1931    }
1932
1933    // ============================================================================
1934    // SECURITY TEST: File Size Limits & Memory Safety
1935    // ============================================================================
1936
1937    proptest! {
1938        #![proptest_config(ProptestConfig {
1939            cases: if cfg!(miri) { 5 } else { 100 },
1940            failure_persistence: None,
1941            .. ProptestConfig::default()
1942        })]
1943
1944        /// Property: File size calculations must not overflow
1945        /// Tests that capacity calculations for file uploads are safe
1946        #[test]
1947        fn proptest_file_upload_capacity_safe(
1948            file_size in 0usize..=1000000,
1949        ) {
1950            // Create file data of specified size
1951            let file_data = vec![0u8; file_size];
1952
1953            // Wrap in Arc like the upload functions do
1954            let file_data_arc = Arc::new(file_data);
1955
1956            // Property 1: Length must match original size
1957            prop_assert_eq!(file_data_arc.len(), file_size);
1958
1959            // Property 2: Arc clone must be cheap (same allocation)
1960            let clone1 = Arc::clone(&file_data_arc);
1961            let clone2 = Arc::clone(&file_data_arc);
1962            prop_assert_eq!(clone1.len(), file_size);
1963            prop_assert_eq!(clone2.len(), file_size);
1964        }
1965
1966        /// Property: Content-Type handling must prevent injection
1967        /// Tests that content types are handled safely without code execution
1968        #[test]
1969        fn proptest_content_type_safe(
1970            content_type in ".*{0,200}",
1971        ) {
1972            // Test Cow allocation strategy
1973            let content_type_cow: Cow<str> = if content_type.len() < 64 {
1974                Cow::Borrowed(&content_type)
1975            } else {
1976                Cow::Owned(content_type.clone())
1977            };
1978
1979            // Property 1: Must not contain script injection attempts
1980            let ct_lower = content_type_cow.to_lowercase();
1981            if ct_lower.contains("<script>") || ct_lower.contains("javascript:") {
1982                // These should be treated as literal strings, not executed
1983                prop_assert!(content_type_cow.as_ref().contains("<script>") ||
1984                           content_type_cow.as_ref().contains("javascript:"));
1985            }
1986
1987            // Property 2: Length must be preserved
1988            prop_assert_eq!(content_type_cow.len(), content_type.len());
1989        }
1990    }
1991
1992    // ============================================================================
1993    // UNIT TESTS: Specific Security Scenarios
1994    // ============================================================================
1995
1996    #[test]
1997    fn test_hmac_signature_with_query_params() {
1998        let config = create_test_config();
1999        let client = VeracodeClient::new(config).expect("valid test client configuration");
2000
2001        // Test URL with query parameters
2002        let url = "https://api.veracode.com/api/test?param1=value1&param2=value2";
2003        let nonce = "0123456789abcdef0123456789abcdef";
2004        let timestamp = 1234567890000;
2005
2006        let result = client.generate_hmac_signature("GET", url, timestamp, nonce);
2007        assert!(result.is_ok());
2008
2009        let signature = result.expect("valid HMAC signature");
2010        // HMAC signature should be 64 hex characters (32 bytes * 2)
2011        assert_eq!(signature.len(), 64);
2012        assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
2013    }
2014
2015    #[test]
2016    fn test_hmac_signature_different_methods() {
2017        let config = create_test_config();
2018        let client = VeracodeClient::new(config).expect("valid test client configuration");
2019
2020        let url = "https://api.veracode.com/api/test";
2021        let nonce = "0123456789abcdef0123456789abcdef";
2022        let timestamp = 1234567890000;
2023
2024        let sig_get = client
2025            .generate_hmac_signature("GET", url, timestamp, nonce)
2026            .expect("valid HMAC signature for GET");
2027        let sig_post = client
2028            .generate_hmac_signature("POST", url, timestamp, nonce)
2029            .expect("valid HMAC signature for POST");
2030
2031        // Different methods should produce different signatures
2032        assert_ne!(sig_get, sig_post);
2033    }
2034
2035    #[test]
2036    fn test_url_encoding_special_characters() {
2037        let config = create_test_config();
2038        let client = VeracodeClient::new(config).expect("valid test client configuration");
2039
2040        // Test that special characters are properly encoded
2041        let params = vec![
2042            ("key1", "value with spaces"),
2043            ("key2", "value&with&ampersands"),
2044            ("key3", "value=with=equals"),
2045            ("key4", "value?with?questions"),
2046        ];
2047
2048        let url = client.build_url_with_params("/api/test", &params);
2049
2050        // URL should contain encoded spaces
2051        assert!(url.contains("value%20with%20spaces") || url.contains("value+with+spaces"));
2052        // URL should contain encoded ampersands
2053        assert!(url.contains("%26"));
2054        // URL should start with base URL
2055        assert!(url.starts_with("https://api.veracode.com/api/test?"));
2056    }
2057
2058    #[test]
2059    fn test_url_encoding_unicode() {
2060        let config = create_test_config();
2061        let client = VeracodeClient::new(config).expect("valid test client configuration");
2062
2063        // Test Unicode handling
2064        let params = vec![
2065            ("key", "δ½ ε₯½δΈ–η•Œ"), // Chinese characters
2066            ("key2", "πŸ”’πŸ›‘οΈ"),    // Emojis
2067        ];
2068
2069        let url = client.build_url_with_params("/api/test", &params);
2070
2071        // Must not panic and should produce valid URL
2072        assert!(url.starts_with("https://api.veracode.com/api/test?"));
2073        // URL should contain percent-encoded Unicode
2074        assert!(url.contains('%'));
2075    }
2076
2077    #[test]
2078    fn test_empty_query_params() {
2079        let config = create_test_config();
2080        let client = VeracodeClient::new(config).expect("valid test client configuration");
2081
2082        let url = client.build_url_with_params("/api/test", &[]);
2083
2084        // Empty params should not add '?'
2085        assert_eq!(url, "https://api.veracode.com/api/test");
2086    }
2087
2088    #[test]
2089    fn test_invalid_api_key_format() {
2090        use crate::{VeracodeCredentials, VeracodeRegion};
2091
2092        // Create config with non-hex API key
2093        let config = VeracodeConfig {
2094            credentials: VeracodeCredentials::new(
2095                "test_api_id".to_string(),
2096                "not_valid_hex_key".to_string(),
2097            ),
2098            base_url: "https://api.veracode.com".to_string(),
2099            rest_base_url: "https://api.veracode.com".to_string(),
2100            xml_base_url: "https://analysiscenter.veracode.com".to_string(),
2101            region: VeracodeRegion::Commercial,
2102            validate_certificates: true,
2103            connect_timeout: 30,
2104            request_timeout: 300,
2105            proxy_url: None,
2106            proxy_username: None,
2107            proxy_password: None,
2108            retry_config: Default::default(),
2109        };
2110
2111        let client = VeracodeClient::new(config).expect("valid test client configuration");
2112        let result = client.generate_auth_header("GET", "https://api.veracode.com/api/test");
2113
2114        // Should return Authentication error for invalid hex key
2115        assert!(matches!(result, Err(VeracodeError::Authentication(_))));
2116    }
2117
2118    #[test]
2119    fn test_auth_header_format() {
2120        let config = create_test_config();
2121        let client = VeracodeClient::new(config).expect("valid test client configuration");
2122
2123        let header = client
2124            .generate_auth_header("GET", "https://api.veracode.com/api/test")
2125            .expect("valid auth header generation");
2126
2127        // Verify format
2128        assert!(header.starts_with("VERACODE-HMAC-SHA-256 "));
2129        assert!(header.contains("id=test_api_id"));
2130        assert!(header.contains("ts="));
2131        assert!(header.contains("nonce="));
2132        assert!(header.contains("sig="));
2133
2134        // Verify structure (should have 4 comma-separated parts after prefix)
2135        let parts: Vec<&str> = header.split(',').collect();
2136        assert_eq!(parts.len(), 4);
2137    }
2138
2139    #[cfg(not(miri))] // Skip under Miri - uses SystemTime
2140    #[test]
2141    fn test_auth_header_timestamp_monotonic() {
2142        let config = create_test_config();
2143        let client = VeracodeClient::new(config).expect("valid test client configuration");
2144
2145        let header1 = client
2146            .generate_auth_header("GET", "https://api.veracode.com/api/test")
2147            .expect("valid auth header generation");
2148        std::thread::sleep(std::time::Duration::from_millis(10));
2149        let header2 = client
2150            .generate_auth_header("GET", "https://api.veracode.com/api/test")
2151            .expect("valid auth header generation");
2152
2153        // Extract timestamps
2154        let extract_ts =
2155            |h: &str| -> Option<u64> { h.split("ts=").nth(1)?.split(',').next()?.parse().ok() };
2156
2157        let ts1 = extract_ts(&header1).expect("valid timestamp extraction");
2158        let ts2 = extract_ts(&header2).expect("valid timestamp extraction");
2159
2160        // Second timestamp should be >= first (monotonic)
2161        assert!(ts2 >= ts1);
2162    }
2163
2164    #[test]
2165    fn test_base_url_accessor() {
2166        let config = create_test_config();
2167        let client = VeracodeClient::new(config).expect("valid test client configuration");
2168
2169        assert_eq!(client.base_url(), "https://api.veracode.com");
2170    }
2171
2172    #[test]
2173    fn test_client_clone() {
2174        let config = create_test_config();
2175        let client1 = VeracodeClient::new(config).expect("valid test client configuration");
2176        let client2 = client1.clone();
2177
2178        // Both clients should have same base URL
2179        assert_eq!(client1.base_url(), client2.base_url());
2180    }
2181
2182    #[test]
2183    fn test_url_capacity_estimation() {
2184        let config = create_test_config();
2185        let client = VeracodeClient::new(config).expect("valid test client configuration");
2186
2187        // Test with large number of parameters
2188        let params: Vec<(&str, &str)> = (0..100).map(|_| ("key", "value")).collect();
2189
2190        let url = client.build_url_with_params("/api/test", &params);
2191
2192        // Should handle large param counts without panic
2193        assert!(url.starts_with("https://api.veracode.com/api/test?"));
2194        assert!(url.len() > 100); // Should contain all params
2195    }
2196
2197    #[test]
2198    fn test_saturating_arithmetic() {
2199        let config = create_test_config();
2200        let client = VeracodeClient::new(config).expect("valid test client configuration");
2201
2202        // Test saturating_add in capacity calculation
2203        let params: Vec<(&str, &str)> = vec![("k", "v"); 1000];
2204
2205        // Should not panic even with large numbers
2206        let url = client.build_url_with_params("/api/test", &params);
2207        assert!(url.len() < usize::MAX);
2208    }
2209}