veracode_platform/
pipeline.rs

1//! Pipeline Scan API functionality for scanning applications with static analysis.
2//!
3//! This module provides functionality to interact with the Veracode Pipeline Scan API,
4//! allowing you to submit applications for static analysis and retrieve scan results.
5
6use log::{debug, error, info, warn};
7use serde::{Deserialize, Serialize};
8use std::borrow::Cow;
9
10use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
11use crate::validation::{validate_scan_id, validate_veracode_url};
12use crate::{VeracodeClient, VeracodeError};
13
14/// Plugin version constant to avoid repeated allocations
15const PLUGIN_VERSION: &str = "25.2.0-0";
16
17/// Error types specific to pipeline scan operations
18#[derive(Debug, thiserror::Error)]
19#[must_use = "Need to handle all error enum types."]
20pub enum PipelineError {
21    #[error("Pipeline scan not found")]
22    ScanNotFound,
23    #[error("Permission denied: {0}")]
24    PermissionDenied(String),
25    #[error("Invalid request: {0}")]
26    InvalidRequest(String),
27    #[error("Scan timeout")]
28    ScanTimeout,
29    #[error("Scan findings not ready yet - try again later")]
30    FindingsNotReady,
31    #[error("Application not found: {0}")]
32    ApplicationNotFound(String),
33    #[error(
34        "Multiple applications found with name '{0}'. Please check the application name and ensure it uniquely identifies a single application."
35    )]
36    MultipleApplicationsFound(String),
37    #[error("API error: {0}")]
38    ApiError(#[from] VeracodeError),
39    #[error("HTTP error: {0}")]
40    Http(#[from] reqwest::Error),
41}
42
43/// Pipeline scan development stage
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "UPPERCASE")]
46pub enum DevStage {
47    Development,
48    Testing,
49    Release,
50}
51
52impl std::fmt::Display for DevStage {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            DevStage::Development => write!(f, "DEVELOPMENT"),
56            DevStage::Testing => write!(f, "TESTING"),
57            DevStage::Release => write!(f, "RELEASE"),
58        }
59    }
60}
61
62/// Pipeline scan stage/status
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "UPPERCASE")]
65pub enum ScanStage {
66    Create,
67    Upload,
68    Start,
69    Details,
70    Findings,
71}
72
73/// Pipeline scan execution status  
74#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
75#[serde(rename_all = "UPPERCASE")]
76pub enum ScanStatus {
77    Pending,
78    Uploading,
79    Started,
80    Success,
81    Failure,
82    Cancelled,
83    Timeout,
84    #[serde(rename = "USER_TIMEOUT")]
85    UserTimeout,
86}
87
88impl ScanStatus {
89    /// Check if the scan completed successfully
90    #[must_use]
91    pub fn is_successful(&self) -> bool {
92        matches!(self, ScanStatus::Success)
93    }
94
95    /// Check if the scan failed or was terminated
96    #[must_use]
97    pub fn is_failed(&self) -> bool {
98        matches!(
99            self,
100            ScanStatus::Failure
101                | ScanStatus::Cancelled
102                | ScanStatus::Timeout
103                | ScanStatus::UserTimeout
104        )
105    }
106
107    /// Check if the scan is still in progress
108    #[must_use]
109    pub fn is_in_progress(&self) -> bool {
110        matches!(
111            self,
112            ScanStatus::Pending | ScanStatus::Uploading | ScanStatus::Started
113        )
114    }
115}
116
117impl std::fmt::Display for ScanStatus {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            ScanStatus::Pending => write!(f, "PENDING"),
121            ScanStatus::Uploading => write!(f, "UPLOADING"),
122            ScanStatus::Started => write!(f, "STARTED"),
123            ScanStatus::Success => write!(f, "SUCCESS"),
124            ScanStatus::Failure => write!(f, "FAILURE"),
125            ScanStatus::Cancelled => write!(f, "CANCELLED"),
126            ScanStatus::Timeout => write!(f, "TIMEOUT"),
127            ScanStatus::UserTimeout => write!(f, "USER_TIMEOUT"),
128        }
129    }
130}
131
132/// Security finding severity levels
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub enum Severity {
135    #[serde(rename = "0")]
136    Informational,
137    #[serde(rename = "1")]
138    VeryLow,
139    #[serde(rename = "2")]
140    Low,
141    #[serde(rename = "3")]
142    Medium,
143    #[serde(rename = "4")]
144    High,
145    #[serde(rename = "5")]
146    VeryHigh,
147}
148
149/// Source file information for a finding
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SourceFile {
152    /// File path where the issue was found
153    pub file: String,
154    /// Function name (may be null)
155    pub function_name: Option<String>,
156    /// Function prototype
157    pub function_prototype: String,
158    /// Line number in the file
159    pub line: u32,
160    /// Qualified function name
161    pub qualified_function_name: String,
162    /// Scope information
163    pub scope: String,
164}
165
166/// Files information containing source file details
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct FindingFiles {
169    /// Source file information
170    pub source_file: SourceFile,
171}
172
173/// Stack dump information
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct StackDumps {
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the API request fails, the resource is not found,
180    /// or authentication/authorization fails.
181    /// Array of stack dumps (optional - can be missing when `stack_dumps` is empty object)
182    pub stack_dump: Option<Vec<serde_json::Value>>,
183}
184
185/// Security finding/issue from a pipeline scan
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Finding {
188    /// CWE (Common Weakness Enumeration) ID as string
189    pub cwe_id: String,
190    /// Detailed message with HTML formatting
191    pub display_text: String,
192    /// File and location information
193    pub files: FindingFiles,
194    /// Flaw details link for accessing detailed vulnerability information (optional)
195    pub flaw_details_link: Option<String>,
196    /// Grade of defect (e.g., "B")
197    pub gob: String,
198    /// Issue ID for tracking
199    pub issue_id: u32,
200    /// Type of security issue
201    pub issue_type: String,
202    /// Issue type identifier
203    pub issue_type_id: String,
204    /// Severity level (0-5)
205    pub severity: u32,
206    /// Stack dump information (optional)
207    pub stack_dumps: Option<StackDumps>,
208    /// Short title/summary of the issue
209    pub title: String,
210}
211
212/// Complete findings response from the API
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct FindingsResponse {
215    /// HAL navigation links
216    #[serde(rename = "_links")]
217    pub links: Option<serde_json::Value>,
218    /// Scan ID
219    pub scan_id: String,
220    /// Current scan status
221    pub scan_status: ScanStatus,
222    /// Scan message
223    pub message: String,
224    /// List of modules scanned
225    pub modules: Vec<String>,
226    /// Number of modules
227    pub modules_count: u32,
228    /// List of security findings
229    pub findings: Vec<Finding>,
230    /// Selected modules
231    pub selected_modules: Vec<String>,
232    /// Stack dump information (optional)
233    pub stack_dump: Option<serde_json::Value>,
234}
235
236/// Legacy Finding struct for backwards compatibility
237/// Converts from the new Finding format to a simpler structure
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct LegacyFinding {
240    /// File path where the issue was found
241    pub file: String,
242    /// Line number in the file
243    pub line: u32,
244    /// Type of security issue
245    pub issue_type: String,
246    /// Severity level (0-5)
247    pub severity: u32,
248    /// Descriptive message about the issue (HTML stripped)
249    pub message: String,
250    /// CWE (Common Weakness Enumeration) ID
251    pub cwe_id: u32,
252    /// Optional link to more details
253    pub details_link: Option<String>,
254    /// Issue ID for tracking
255    pub issue_id: Option<String>,
256    /// OWASP category if applicable
257    pub owasp_category: Option<String>,
258    /// SANS category if applicable
259    pub sans_category: Option<String>,
260}
261
262impl Finding {
263    /// Convert to legacy format for backwards compatibility
264    #[must_use]
265    pub fn to_legacy(&self) -> LegacyFinding {
266        LegacyFinding {
267            file: self.files.source_file.file.clone(),
268            line: self.files.source_file.line,
269            issue_type: self.issue_type.clone(),
270            severity: self.severity,
271            message: strip_html_tags(&self.display_text).into_owned(),
272            cwe_id: self.cwe_id.parse().unwrap_or(0),
273            details_link: None,
274            issue_id: Some(self.issue_id.to_string()),
275            owasp_category: None,
276            sans_category: None,
277        }
278    }
279}
280
281/// Strip HTML tags from display text to get plain text message
282///
283/// Security: This function removes `<script>` and `<style>` tags AND their content
284/// to prevent script injection. Other HTML tags are removed but their text content
285/// is preserved.
286fn strip_html_tags(html: &str) -> Cow<'_, str> {
287    // Check if HTML tags are present to avoid unnecessary allocation
288    if !html.contains('<') {
289        return Cow::Borrowed(html);
290    }
291
292    // Simple HTML tag removal without regex dependency
293    let mut result = String::new();
294    let mut in_tag = false;
295    let mut in_script_or_style = false;
296    let mut tag_name = String::new();
297    let mut collecting_tag_name = false;
298    let mut just_closed_tag = false;
299
300    for ch in html.chars() {
301        match ch {
302            '<' => {
303                // Add space before tag if we just had content (for word boundaries)
304                if !result.is_empty() && !result.ends_with(char::is_whitespace) {
305                    result.push(' ');
306                }
307                in_tag = true;
308                collecting_tag_name = true;
309                just_closed_tag = false;
310                tag_name.clear();
311            }
312            '>' => {
313                in_tag = false;
314
315                // Check if we're entering or leaving a script/style block
316                let tag_lower = tag_name.trim().to_lowercase();
317                if tag_lower.starts_with("script") || tag_lower.starts_with("style") {
318                    in_script_or_style = true;
319                } else if tag_lower.starts_with("/script") || tag_lower.starts_with("/style") {
320                    in_script_or_style = false;
321                }
322                collecting_tag_name = false;
323                just_closed_tag = true;
324                tag_name.clear();
325            }
326            ' ' if in_tag && collecting_tag_name => {
327                // Space in tag (e.g., <script type="...">), stop collecting tag name
328                collecting_tag_name = false;
329            }
330            _ if (ch == '/' || ch.is_alphanumeric()) && collecting_tag_name => {
331                // Collect tag name to detect script/style tags
332                tag_name.push(ch);
333            }
334            _ if in_tag || in_script_or_style => {
335                // Skip content inside tags and inside script/style blocks
336            }
337            _ => {
338                // Keep text content (not in tags, not in script/style blocks)
339                // Add space after tag if needed (for word boundaries)
340                if just_closed_tag && !result.is_empty() && !result.ends_with(char::is_whitespace) {
341                    result.push(' ');
342                }
343                just_closed_tag = false;
344                result.push(ch);
345            }
346        }
347    }
348
349    // Clean up extra whitespace (collapse multiple spaces into one)
350    let cleaned = result.split_whitespace().collect::<Vec<&str>>().join(" ");
351    Cow::Owned(cleaned)
352}
353
354/// Pipeline scan request for creating a new scan
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct CreateScanRequest {
357    /// Name of the binary/artifact being scanned (MANDATORY)
358    #[serde(skip_serializing_if = "never_skip_string")]
359    pub binary_name: String,
360    /// Size of the binary in bytes (MANDATORY)
361    #[serde(skip_serializing_if = "never_skip_u64")]
362    pub binary_size: u64,
363    /// SHA-256 hash of the binary (MANDATORY)
364    #[serde(skip_serializing_if = "never_skip_string")]
365    pub binary_hash: String,
366    /// Project name
367    pub project_name: String,
368    /// Project URI (optional)
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub project_uri: Option<String>,
371    /// Development stage
372    pub dev_stage: DevStage,
373    /// Application ID (optional, for linking to existing Veracode app)
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub app_id: Option<String>,
376    /// Project reference/branch/commit (optional)
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub project_ref: Option<String>,
379    /// Scan timeout in minutes (optional)
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub scan_timeout: Option<u32>,
382    /// Plugin version (automatically set)
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub plugin_version: Option<String>,
385    /// Emit stack dump flag (optional)
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub emit_stack_dump: Option<String>,
388    /// Include specific modules (optional)
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub include_modules: Option<String>,
391}
392
393/// Helper function to never skip String fields (ensures mandatory fields are always included)
394fn never_skip_string(_: &String) -> bool {
395    false
396}
397
398/// Helper function to never skip u64 fields (ensures mandatory fields are always included)
399fn never_skip_u64(_: &u64) -> bool {
400    false
401}
402
403/// Result of scan creation containing scan ID and _links for operations
404#[derive(Debug, Clone)]
405pub struct ScanCreationResult {
406    /// The scan ID
407    pub scan_id: String,
408    /// Upload URI from _links.upload.href
409    pub upload_uri: Option<String>,
410    /// Details URI from _links.details.href
411    pub details_uri: Option<String>,
412    /// Start URI from _links.start.href
413    pub start_uri: Option<String>,
414    /// Cancel URI from _links.cancel.href
415    pub cancel_uri: Option<String>,
416    /// Expected number of upload segments
417    pub expected_segments: Option<u32>,
418}
419
420/// Pipeline scan configuration
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct ScanConfig {
423    /// Maximum timeout in minutes (default: 60)
424    pub timeout: Option<u32>,
425    /// Include low severity findings
426    pub include_low_severity: Option<bool>,
427    /// Maximum number of findings to return
428    pub max_findings: Option<u32>,
429}
430
431/// Pipeline scan details/status
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct Scan {
434    /// Unique scan ID
435    pub scan_id: String,
436    ///
437    /// # Errors
438    ///
439    /// Returns an error if the API request fails, the resource is not found,
440    /// or authentication/authorization fails.
441    /// Current scan status (UPLOADING, VERIFYING, RUNNING, `RESULTS_READY`, etc.)
442    pub scan_status: ScanStatus,
443    /// API version
444    pub api_version: f64,
445    /// Application ID (may be null)
446    pub app_id: Option<String>,
447    /// Project name
448    pub project_name: String,
449    /// Project URI
450    pub project_uri: Option<String>,
451    /// Project reference
452    pub project_ref: Option<String>,
453    /// Commit hash
454    pub commit_hash: Option<String>,
455    /// Development stage
456    pub dev_stage: String,
457    /// Binary name being scanned
458    pub binary_name: String,
459    /// Binary size in bytes
460    pub binary_size: u64,
461    /// Binary SHA-256 hash
462    pub binary_hash: String,
463    /// Expected number of binary segments
464    pub binary_segments_expected: u32,
465    /// Number of binary segments uploaded
466    pub binary_segments_uploaded: u32,
467    /// Scan timeout in minutes
468    pub scan_timeout: Option<u32>,
469    /// Scan duration in minutes (can be fractional)
470    pub scan_duration: Option<f64>,
471    /// Results size (can be fractional)
472    pub results_size: Option<f64>,
473    /// Status message
474    pub message: Option<String>,
475    /// Scan creation time
476    pub created: String,
477    /// Last changed time
478    pub changed: String,
479    /// Modules information
480    pub modules: Vec<serde_json::Value>,
481    /// Selected modules
482    pub selected_modules: Vec<serde_json::Value>,
483    /// Display modules
484    pub display_modules: Vec<serde_json::Value>,
485    /// Display selected modules
486    pub display_selected_modules: Vec<serde_json::Value>,
487    /// Links for navigation (HAL format)
488    #[serde(rename = "_links")]
489    pub links: Option<serde_json::Value>,
490}
491
492/// Pipeline scan results
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct ScanResults {
495    /// Scan metadata
496    pub scan: Scan,
497    /// List of security findings
498    pub findings: Vec<Finding>,
499    /// Findings summary by severity
500    pub summary: FindingsSummary,
501    /// Security standards compliance
502    pub standards: SecurityStandards,
503}
504
505/// Summary of findings by severity
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct FindingsSummary {
508    /// Number of Very High severity findings
509    pub very_high: u32,
510    /// Number of High severity findings
511    pub high: u32,
512    /// Number of Medium severity findings
513    pub medium: u32,
514    /// Number of Low severity findings
515    pub low: u32,
516    /// Number of Very Low severity findings
517    pub very_low: u32,
518    /// Number of Informational findings
519    pub informational: u32,
520    /// Total number of findings
521    pub total: u32,
522}
523
524/// Security standards compliance information
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct SecurityStandards {
527    /// OWASP compliance
528    pub owasp: Option<StandardCompliance>,
529    /// SANS compliance
530    pub sans: Option<StandardCompliance>,
531    /// PCI compliance
532    pub pci: Option<StandardCompliance>,
533    /// CWE categories
534    pub cwe: Option<StandardCompliance>,
535}
536
537/// Compliance information for a security standard
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct StandardCompliance {
540    /// Total number of applicable rules
541    pub total_rules: u32,
542    /// Number of rules violated
543    pub violations: u32,
544    /// Compliance percentage (0-100)
545    pub compliance_score: f64,
546    /// List of violated rule IDs
547    pub violated_rules: Vec<String>,
548}
549
550/// Pipeline Scan API client
551pub struct PipelineApi {
552    client: VeracodeClient,
553    // Cached base URL to avoid repeated string operations
554    base_url: String,
555}
556
557impl PipelineApi {
558    /// Create a new Pipeline API client
559    #[must_use]
560    pub fn new(client: VeracodeClient) -> Self {
561        let base_url = Self::compute_base_url(&client);
562        Self { client, base_url }
563    }
564
565    /// Compute the pipeline scan v1 base URL for file uploads
566    fn compute_base_url(client: &VeracodeClient) -> String {
567        if client.config().base_url.contains("api.veracode.com") {
568            "https://api.veracode.com/pipeline_scan/v1".to_string()
569        } else {
570            // For other environments, use the configured base URL with pipeline_scan/v1 path
571            format!(
572                "{}/pipeline_scan/v1",
573                client.config().base_url.trim_end_matches('/')
574            )
575        }
576    }
577
578    /// Get the cached pipeline scan v1 base URL
579    fn get_pipeline_base_url(&self) -> &str {
580        &self.base_url
581    }
582
583    /// Look up application ID by application name
584    ///
585    /// # Arguments
586    ///
587    /// * `app_name` - The name of the application to search for
588    ///
589    /// # Returns
590    ///
591    /// A `Result` containing the application ID as a string if found
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if the API request fails, the pipeline scan fails,
596    /// or authentication/authorization fails.
597    pub async fn lookup_app_id_by_name(&self, app_name: &str) -> Result<String, PipelineError> {
598        let applications = self.client.search_applications_by_name(app_name).await?;
599
600        match applications.len() {
601            0 => Err(PipelineError::ApplicationNotFound(app_name.to_owned())),
602            1 => Ok(applications
603                .first()
604                .ok_or_else(|| PipelineError::ApplicationNotFound(app_name.to_owned()))?
605                .id
606                .to_string()),
607            _ => {
608                // Print the found applications to help the user
609                error!(
610                    "❌ Found {} applications matching '{}':",
611                    applications.len(),
612                    app_name
613                );
614                for (i, app) in applications.iter().enumerate() {
615                    if let Some(ref profile) = app.profile {
616                        error!(
617                            "   {}. ID: {} - Name: '{}'",
618                            i.saturating_add(1),
619                            app.id,
620                            profile.name
621                        );
622                    } else {
623                        error!(
624                            "   {}. ID: {} - GUID: {}",
625                            i.saturating_add(1),
626                            app.id,
627                            app.guid
628                        );
629                    }
630                }
631                error!(
632                    "💡 Please provide a more specific application name that matches exactly one application."
633                );
634                Err(PipelineError::MultipleApplicationsFound(
635                    app_name.to_string(),
636                ))
637            }
638        }
639    }
640
641    ///
642    /// # Errors
643    ///
644    /// Returns an error if the API request fails, the pipeline scan fails,
645    /// or authentication/authorization fails.
646    /// Create a new pipeline scan with automatic `app_id` lookup
647    ///
648    /// # Arguments
649    ///
650    /// * `request` - Scan creation request with binary details
651    ///
652    /// # Errors
653    ///
654    /// Returns an error if the API request fails, the pipeline scan fails,
655    /// or authentication/authorization fails.
656    /// * `app_name` - Optional application name to look up `app_id` automatically
657    ///
658    /// # Returns
659    ///
660    /// A `Result` containing the scan details if successful
661    ///
662    /// # Errors
663    ///
664    /// Returns an error if the API request fails, the pipeline scan fails,
665    /// or authentication/authorization fails.
666    pub async fn create_scan_with_app_lookup(
667        &self,
668        request: &mut CreateScanRequest,
669        app_name: Option<&str>,
670    ) -> Result<ScanCreationResult, PipelineError> {
671        // Look up app_id if app_name is provided
672        if let Some(name) = app_name
673            && request.app_id.is_none()
674        {
675            let app_id = self.lookup_app_id_by_name(name).await?;
676            request.app_id = Some(app_id.clone());
677            info!("✅ Found application '{name}' with ID: {app_id}");
678        }
679
680        self.create_scan(request).await
681    }
682
683    /// Create a new pipeline scan
684    ///
685    /// # Arguments
686    ///
687    /// * `request` - Scan creation request with binary details
688    ///
689    /// # Returns
690    ///
691    /// A `Result` containing the scan details if successful
692    ///
693    /// # Errors
694    ///
695    /// Returns an error if the API request fails, the pipeline scan fails,
696    /// or authentication/authorization fails.
697    pub async fn create_scan(
698        &self,
699        request: &mut CreateScanRequest,
700    ) -> Result<ScanCreationResult, PipelineError> {
701        // Set plugin version to match Java implementation (from MANIFEST.MF)
702        if request.plugin_version.is_none() {
703            request.plugin_version = Some(PLUGIN_VERSION.to_string());
704        }
705
706        // Set default scan timeout to 30 minutes if not supplied
707        if request.scan_timeout.is_none() {
708            request.scan_timeout = Some(30);
709        }
710
711        // Generate auth header for debugging
712        let endpoint = "/pipeline_scan/v1/scans";
713        let _full_url = format!("{}{}", self.client.config().base_url, endpoint);
714
715        let response = self
716            .client
717            .post_with_response("/pipeline_scan/v1/scans", Some(request))
718            .await?;
719
720        let response_text = response.text().await?;
721
722        // Validate JSON depth before parsing to prevent DoS attacks
723        validate_json_depth(&response_text, MAX_JSON_DEPTH)
724            .map_err(|e| PipelineError::InvalidRequest(format!("JSON validation failed: {}", e)))?;
725
726        // Parse response to extract scan ID and _links (using actual API response structure)
727        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
728            // Extract scan ID
729            let scan_id = json_value
730                .get("scan_id")
731                .and_then(|id| id.as_str())
732                .ok_or_else(|| {
733                    PipelineError::InvalidRequest("Missing scan_id in response".to_string())
734                })?
735                .to_owned();
736
737            // Extract all useful URIs from _links
738            let links = json_value.get("_links");
739
740            let upload_uri = links
741                .and_then(|links| links.get("upload"))
742                .and_then(|upload| upload.get("href"))
743                .and_then(|href| href.as_str())
744                .map(str::to_owned);
745
746            let details_uri = links
747                .and_then(|links| links.get("details"))
748                .and_then(|details| details.get("href"))
749                .and_then(|href| href.as_str())
750                .map(str::to_owned);
751
752            let start_uri = links
753                .and_then(|links| links.get("start"))
754                .and_then(|start| start.get("href"))
755                .and_then(|href| href.as_str())
756                .map(str::to_owned);
757
758            let cancel_uri = links
759                .and_then(|links| links.get("cancel"))
760                .and_then(|cancel| cancel.get("href"))
761                .and_then(|href| href.as_str())
762                .map(str::to_owned);
763
764            // Extract expected segments
765            #[allow(clippy::cast_possible_truncation)]
766            let expected_segments = json_value
767                .get("binary_segments_expected")
768                .and_then(|segments| segments.as_u64())
769                .map(|s| s as u32);
770
771            debug!("✅ Scan creation response parsed:");
772            debug!("   Scan ID: {scan_id}");
773            if let Some(ref uri) = upload_uri {
774                debug!("   Upload URI: {uri}");
775            }
776            if let Some(ref uri) = details_uri {
777                debug!("   Details URI: {uri}");
778            }
779            if let Some(ref uri) = start_uri {
780                debug!("   Start URI: {uri}");
781            }
782            if let Some(ref uri) = cancel_uri {
783                debug!("   Cancel URI: {uri}");
784            }
785            if let Some(segments) = expected_segments {
786                debug!("   Expected segments: {segments}");
787            }
788
789            return Ok(ScanCreationResult {
790                scan_id,
791                upload_uri,
792                details_uri,
793                start_uri,
794                cancel_uri,
795                expected_segments,
796            });
797        }
798
799        Err(PipelineError::InvalidRequest(
800            "Failed to parse scan creation response".to_string(),
801        ))
802    }
803
804    /// Upload binary data for a scan using segmented upload (matching Java implementation)
805    ///
806    /// The Veracode Pipeline Scan API requires files to be uploaded in a predetermined
807    /// number of segments. This method follows the exact Java implementation pattern:
808    /// 1. Gets segment count and upload URI from scan creation response
809    ///
810    /// # Errors
811    ///
812    /// Returns an error if the API request fails, the pipeline scan fails,
813    /// or authentication/authorization fails.
814    /// 2. Calculates segment size as `file_size` / `num_segments`
815    /// 3. Updates URI after each segment upload based on API response
816    ///
817    /// # Arguments
818    ///
819    /// * `initial_upload_uri` - The upload URI from scan creation response
820    /// * `expected_segments` - Number of segments expected by the API
821    /// * `binary_data` - The binary file data to upload
822    /// * `file_name` - Original file name for the binary
823    ///
824    /// # Returns
825    ///
826    /// A `Result` indicating success or failure
827    ///
828    /// # Errors
829    ///
830    /// Returns an error if the API request fails, the pipeline scan fails,
831    /// or authentication/authorization fails.
832    pub async fn upload_binary_segments(
833        &self,
834        initial_upload_uri: &str,
835        expected_segments: i32,
836        binary_data: &[u8],
837        file_name: &str,
838    ) -> Result<(), PipelineError> {
839        // Validate expected_segments to prevent division by zero DoS
840        if expected_segments <= 0 {
841            return Err(PipelineError::InvalidRequest(format!(
842                "Invalid segment count: {}. Must be a positive number.",
843                expected_segments
844            )));
845        }
846
847        let total_size = binary_data.len();
848        #[allow(
849            clippy::cast_possible_truncation,
850            clippy::cast_sign_loss,
851            clippy::cast_precision_loss
852        )]
853        let segment_size = ((total_size as f64) / (expected_segments as f64)).ceil() as usize;
854
855        debug!("📤 Uploading binary in {expected_segments} segments ({total_size} bytes total)");
856        debug!("   Segment size: {segment_size} bytes each");
857
858        let mut current_upload_uri = initial_upload_uri.to_string();
859
860        for segment_num in 0..expected_segments {
861            #[allow(clippy::cast_sign_loss)]
862            let start_idx = (segment_num as usize).saturating_mul(segment_size);
863            let end_idx = std::cmp::min(start_idx.saturating_add(segment_size), total_size);
864            let segment_data = binary_data.get(start_idx..end_idx).ok_or_else(|| {
865                PipelineError::InvalidRequest(format!(
866                    "Invalid segment range: {}..{}",
867                    start_idx, end_idx
868                ))
869            })?;
870
871            debug!(
872                "   Uploading segment {}/{} ({} bytes)...",
873                segment_num.saturating_add(1),
874                expected_segments,
875                segment_data.len()
876            );
877
878            match self
879                .upload_single_segment(&current_upload_uri, segment_data, file_name)
880                .await
881            {
882                Ok(response_text) => {
883                    debug!(
884                        "   ✅ Segment {} uploaded successfully",
885                        segment_num.saturating_add(1)
886                    );
887
888                    // Parse response to get next upload URI (like Java implementation)
889                    if segment_num < expected_segments.saturating_sub(1) {
890                        match self.extract_next_upload_uri(&response_text) {
891                            Some(next_uri) => {
892                                current_upload_uri = next_uri;
893                                debug!("   📍 Next segment URI: {current_upload_uri}");
894                            }
895                            None => {
896                                warn!("   ⚠️  No next URI found in response, using current");
897                            }
898                        }
899                    }
900                }
901                Err(e) => {
902                    error!(
903                        "   ❌ Failed to upload segment {}: {}",
904                        segment_num.saturating_add(1),
905                        e
906                    );
907                    return Err(e);
908                }
909            }
910        }
911
912        debug!("✅ All {expected_segments} segments uploaded successfully");
913        Ok(())
914    }
915
916    /// Simplified upload method for backwards compatibility
917    ///
918    /// # Errors
919    ///
920    /// Returns an error if the API request fails, the pipeline scan fails,
921    /// or authentication/authorization fails.
922    pub async fn upload_binary(
923        &self,
924        scan_id: &str,
925        binary_data: &[u8],
926    ) -> Result<(), PipelineError> {
927        // Path traversal protection: validate scan_id before URL construction
928        validate_scan_id(scan_id)
929            .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
930
931        // For backwards compatibility, use a default approach
932        let upload_uri = format!("/pipeline_scan/scans/{scan_id}/segments/1");
933        let expected_segments = 1; // Default to single segment
934        let file_name = "binary.tar.gz";
935
936        self.upload_binary_segments(&upload_uri, expected_segments, binary_data, file_name)
937            .await
938    }
939
940    /// Upload a single segment using the provided URI (exactly matching Java implementation)
941    async fn upload_single_segment(
942        &self,
943        upload_uri: &str,
944        segment_data: &[u8],
945        file_name: &str,
946    ) -> Result<String, PipelineError> {
947        // Get base URL and create full URL using pipeline scan v1 base URL
948        let url = if upload_uri.starts_with("http") {
949            // SSRF protection: validate full URLs against allowlist
950            validate_veracode_url(upload_uri)
951                .map_err(|e| PipelineError::InvalidRequest(format!("Invalid upload URI: {}", e)))?;
952            upload_uri.to_string()
953        } else {
954            format!("{}{}", self.get_pipeline_base_url(), upload_uri)
955        };
956
957        // Prepare additional headers for pipeline scan
958        let mut headers = std::collections::HashMap::new();
959        headers.insert("accept", "application/json");
960        headers.insert("PLUGIN-VERSION", PLUGIN_VERSION); // CRITICAL: Java adds this header! (from MANIFEST.MF)
961
962        // Use the client's multipart PUT upload method
963        let response = self
964            .client
965            .upload_file_multipart_put(
966                &url,
967                "file",
968                file_name,
969                segment_data.to_vec(),
970                Some(headers),
971            )
972            .await
973            .map_err(PipelineError::ApiError)?;
974
975        if response.status().is_success() {
976            let response_text = response.text().await?;
977            Ok(response_text)
978        } else {
979            let status = response.status();
980            let error_text = response
981                .text()
982                .await
983                .unwrap_or_else(|_| "Unknown error".to_string());
984            Err(PipelineError::InvalidRequest(format!(
985                "Segment upload failed with status {status}: {error_text}"
986            )))
987        }
988    }
989
990    /// Extract the next upload URI from the API response (matching Java getUriSuffix)
991    fn extract_next_upload_uri(&self, response_text: &str) -> Option<String> {
992        // Validate JSON depth before parsing to prevent DoS attacks
993        if validate_json_depth(response_text, MAX_JSON_DEPTH).is_err() {
994            warn!("JSON validation failed in extract_next_upload_uri");
995            return None;
996        }
997
998        // Parse JSON response to find the next upload URI
999        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) {
1000            // Look for _links.upload.href (HAL format)
1001            if let Some(links) = json_value.get("_links")
1002                && let Some(upload) = links.get("upload")
1003                && let Some(href) = upload.get("href")
1004            {
1005                return href.as_str().map(str::to_owned);
1006            }
1007
1008            // Alternative: look for upload_url field
1009            if let Some(upload_url) = json_value.get("upload_url") {
1010                return upload_url.as_str().map(str::to_owned);
1011            }
1012        }
1013
1014        None
1015    }
1016
1017    /// Start a pipeline scan using start URI from _links
1018    ///
1019    /// # Arguments
1020    ///
1021    /// * `start_uri` - The start URI from _links.start.href
1022    /// * `config` - Optional scan configuration
1023    ///
1024    /// # Returns
1025    ///
1026    /// A `Result` indicating success or failure
1027    ///
1028    /// # Errors
1029    ///
1030    /// Returns an error if the API request fails, the pipeline scan fails,
1031    /// or authentication/authorization fails.
1032    pub async fn start_scan_with_uri(
1033        &self,
1034        start_uri: &str,
1035        config: Option<ScanConfig>,
1036    ) -> Result<(), PipelineError> {
1037        // Create payload with scan_status: STARTED
1038        let mut payload = serde_json::json!({
1039            "scan_status": "STARTED"
1040        });
1041
1042        // Add scan config fields if provided
1043        if let Some(config) = config {
1044            if let Some(timeout) = config.timeout
1045                && let Some(obj) = payload.as_object_mut()
1046            {
1047                obj.insert(
1048                    "timeout".to_string(),
1049                    serde_json::Value::Number(timeout.into()),
1050                );
1051            }
1052            if let Some(include_low_severity) = config.include_low_severity
1053                && let Some(obj) = payload.as_object_mut()
1054            {
1055                obj.insert(
1056                    "include_low_severity".to_string(),
1057                    serde_json::Value::Bool(include_low_severity),
1058                );
1059            }
1060            if let Some(max_findings) = config.max_findings
1061                && let Some(obj) = payload.as_object_mut()
1062            {
1063                obj.insert(
1064                    "max_findings".to_string(),
1065                    serde_json::Value::Number(max_findings.into()),
1066                );
1067            }
1068        }
1069
1070        // Construct full URL with pipeline_scan/v1 base
1071        let url = if start_uri.starts_with("http") {
1072            // SSRF protection: validate full URLs against allowlist
1073            validate_veracode_url(start_uri)
1074                .map_err(|e| PipelineError::InvalidRequest(format!("Invalid start URI: {}", e)))?;
1075            start_uri.to_string()
1076        } else {
1077            format!("{}{}", self.get_pipeline_base_url(), start_uri)
1078        };
1079
1080        // Generate auth header for PUT request
1081        let auth_header = self
1082            .client
1083            .generate_auth_header("PUT", &url)
1084            .map_err(PipelineError::ApiError)?;
1085
1086        let response = self
1087            .client
1088            .client()
1089            .put(&url)
1090            .header("Authorization", auth_header)
1091            .header("accept", "application/json")
1092            .header("content-type", "application/json")
1093            .json(&payload)
1094            .send()
1095            .await?;
1096
1097        if response.status().is_success() {
1098            Ok(())
1099        } else {
1100            let error_text = response
1101                .text()
1102                .await
1103                .unwrap_or_else(|_| "Unknown error".to_string());
1104            Err(PipelineError::InvalidRequest(format!(
1105                "Failed to start scan: {error_text}"
1106            )))
1107        }
1108    }
1109
1110    /// Start a pipeline scan (fallback method using scan ID)
1111    ///
1112    /// # Arguments
1113    ///
1114    /// * `scan_id` - The scan ID
1115    /// * `config` - Optional scan configuration
1116    ///
1117    /// # Returns
1118    ///
1119    /// A `Result` indicating success or failure
1120    ///
1121    /// # Errors
1122    ///
1123    /// Returns an error if the API request fails, the pipeline scan fails,
1124    /// or authentication/authorization fails.
1125    pub async fn start_scan(
1126        &self,
1127        scan_id: &str,
1128        config: Option<ScanConfig>,
1129    ) -> Result<(), PipelineError> {
1130        // Path traversal protection: validate scan_id before URL construction
1131        validate_scan_id(scan_id)
1132            .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1133
1134        let endpoint = format!("/scans/{scan_id}");
1135        let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1136
1137        // Create payload with scan_status: STARTED
1138        let mut payload = serde_json::json!({
1139            "scan_status": "STARTED"
1140        });
1141
1142        // Add scan config fields if provided
1143        if let Some(config) = config {
1144            if let Some(timeout) = config.timeout
1145                && let Some(obj) = payload.as_object_mut()
1146            {
1147                obj.insert(
1148                    "timeout".to_string(),
1149                    serde_json::Value::Number(timeout.into()),
1150                );
1151            }
1152            if let Some(include_low_severity) = config.include_low_severity
1153                && let Some(obj) = payload.as_object_mut()
1154            {
1155                obj.insert(
1156                    "include_low_severity".to_string(),
1157                    serde_json::Value::Bool(include_low_severity),
1158                );
1159            }
1160            if let Some(max_findings) = config.max_findings
1161                && let Some(obj) = payload.as_object_mut()
1162            {
1163                obj.insert(
1164                    "max_findings".to_string(),
1165                    serde_json::Value::Number(max_findings.into()),
1166                );
1167            }
1168        }
1169
1170        // Generate auth header for PUT request
1171        let auth_header = self
1172            .client
1173            .generate_auth_header("PUT", &url)
1174            .map_err(PipelineError::ApiError)?;
1175
1176        let response = self
1177            .client
1178            .client()
1179            .put(&url)
1180            .header("Authorization", auth_header)
1181            .header("accept", "application/json")
1182            .header("content-type", "application/json")
1183            .json(&payload)
1184            .send()
1185            .await?;
1186
1187        if response.status().is_success() {
1188            Ok(())
1189        } else {
1190            let error_text = response
1191                .text()
1192                .await
1193                .unwrap_or_else(|_| "Unknown error".to_string());
1194            Err(PipelineError::InvalidRequest(format!(
1195                "Failed to start scan: {error_text}"
1196            )))
1197        }
1198    }
1199
1200    /// Get pipeline scan details using details URI from _links
1201    ///
1202    /// # Arguments
1203    ///
1204    /// * `details_uri` - The details URI from _links.details.href
1205    ///
1206    /// # Returns
1207    ///
1208    /// A `Result` containing the scan details
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns an error if the API request fails, the pipeline scan fails,
1213    /// or authentication/authorization fails.
1214    pub async fn get_scan_with_uri(&self, details_uri: &str) -> Result<Scan, PipelineError> {
1215        // Construct full URL with pipeline_scan/v1 base
1216        let url = if details_uri.starts_with("http") {
1217            // SSRF protection: validate full URLs against allowlist
1218            validate_veracode_url(details_uri).map_err(|e| {
1219                PipelineError::InvalidRequest(format!("Invalid details URI: {}", e))
1220            })?;
1221            details_uri.to_string()
1222        } else {
1223            format!("{}{}", self.get_pipeline_base_url(), details_uri)
1224        };
1225
1226        // Generate auth header for GET request
1227        let auth_header = self
1228            .client
1229            .generate_auth_header("GET", &url)
1230            .map_err(PipelineError::ApiError)?;
1231
1232        let response = self
1233            .client
1234            .client()
1235            .get(&url)
1236            .header("Authorization", auth_header)
1237            .header("accept", "application/json")
1238            .send()
1239            .await?;
1240
1241        let response_text = response.text().await?;
1242
1243        // Validate JSON depth before parsing to prevent DoS attacks
1244        validate_json_depth(&response_text, MAX_JSON_DEPTH)
1245            .map_err(|e| PipelineError::InvalidRequest(format!("JSON validation failed: {}", e)))?;
1246
1247        serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1248            PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1249        })
1250    }
1251
1252    /// Get pipeline scan details (fallback method using scan ID)
1253    ///
1254    /// # Arguments
1255    ///
1256    /// * `scan_id` - The scan ID
1257    ///
1258    /// # Returns
1259    ///
1260    /// A `Result` containing the scan details
1261    ///
1262    /// # Errors
1263    ///
1264    /// Returns an error if the API request fails, the pipeline scan fails,
1265    /// or authentication/authorization fails.
1266    pub async fn get_scan(&self, scan_id: &str) -> Result<Scan, PipelineError> {
1267        // Path traversal protection: validate scan_id before URL construction
1268        validate_scan_id(scan_id)
1269            .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1270
1271        let endpoint = format!("/scans/{scan_id}");
1272        let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1273
1274        // Generate auth header for GET request
1275        let auth_header = self
1276            .client
1277            .generate_auth_header("GET", &url)
1278            .map_err(PipelineError::ApiError)?;
1279
1280        let response = self
1281            .client
1282            .client()
1283            .get(&url)
1284            .header("Authorization", auth_header)
1285            .header("accept", "application/json")
1286            .send()
1287            .await?;
1288
1289        let response_text = response.text().await?;
1290
1291        // Validate JSON depth before parsing to prevent DoS attacks
1292        validate_json_depth(&response_text, MAX_JSON_DEPTH)
1293            .map_err(|e| PipelineError::InvalidRequest(format!("JSON validation failed: {}", e)))?;
1294
1295        serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1296            PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1297        })
1298    }
1299
1300    /// Get pipeline scan findings
1301    ///
1302    /// # Arguments
1303    ///
1304    /// * `scan_id` - The scan ID
1305    ///
1306    /// # Returns
1307    ///
1308    /// A `Result` containing the scan findings
1309    ///
1310    /// # HTTP Status Codes
1311    ///
1312    /// * `200` - Findings are ready and returned
1313    ///
1314    /// # Errors
1315    ///
1316    /// Returns an error if the API request fails, the pipeline scan fails,
1317    /// or authentication/authorization fails.
1318    /// * `202` - Scan accepted but findings not yet available (returns `FindingsNotReady` error)
1319    ///
1320    /// # Errors
1321    ///
1322    /// Returns an error if the API request fails, the pipeline scan fails,
1323    /// or authentication/authorization fails.
1324    pub async fn get_findings(&self, scan_id: &str) -> Result<Vec<Finding>, PipelineError> {
1325        // Path traversal protection: validate scan_id before URL construction
1326        validate_scan_id(scan_id)
1327            .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1328
1329        let endpoint = format!("/scans/{scan_id}/findings");
1330        let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1331
1332        debug!("🔍 Debug - get_findings() calling: {url}");
1333
1334        // Generate auth header for GET request
1335        let auth_header = self
1336            .client
1337            .generate_auth_header("GET", &url)
1338            .map_err(PipelineError::ApiError)?;
1339
1340        let response = self
1341            .client
1342            .client()
1343            .get(&url)
1344            .header("Authorization", auth_header)
1345            .header("accept", "application/json")
1346            .send()
1347            .await?;
1348
1349        let status = response.status();
1350        let response_text = response.text().await?;
1351
1352        // Debug: Print findings response summary
1353        debug!("🔍 Debug - Findings API Response:");
1354        debug!("   Status: {status}");
1355        debug!("   Response Length: {} bytes", response_text.len());
1356
1357        match status.as_u16() {
1358            200 => {
1359                // Validate JSON depth before parsing to prevent DoS attacks
1360                validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
1361                    PipelineError::InvalidRequest(format!("JSON validation failed: {}", e))
1362                })?;
1363
1364                // Findings are ready - parse the response as FindingsResponse
1365                match serde_json::from_str::<FindingsResponse>(&response_text) {
1366                    Ok(findings_response) => {
1367                        debug!("🔍 Debug - Successfully parsed findings response:");
1368                        debug!("   Scan Status: {}", findings_response.scan_status);
1369                        debug!("   Message: {}", findings_response.message);
1370                        debug!("   Modules: [{}]", findings_response.modules.join(", "));
1371                        debug!("   Findings Count: {}", findings_response.findings.len());
1372                        Ok(findings_response.findings)
1373                    }
1374                    Err(e) => {
1375                        debug!("❌ Debug - Failed to parse FindingsResponse: {e}");
1376                        // Fallback: try to parse as generic JSON and extract findings array
1377                        if let Ok(json_value) =
1378                            serde_json::from_str::<serde_json::Value>(&response_text)
1379                            && let Some(findings_array) =
1380                                json_value.get("findings").and_then(|f| f.as_array())
1381                        {
1382                            debug!("🔍 Debug - Trying fallback parsing of findings array...");
1383                            let findings: Result<Vec<Finding>, _> = findings_array
1384                                .iter()
1385                                .map(|f| serde_json::from_value(f.clone()))
1386                                .collect();
1387                            return findings.map_err(|e| {
1388                                PipelineError::InvalidRequest(format!(
1389                                    "Failed to parse findings array: {e}"
1390                                ))
1391                            });
1392                        }
1393                        Err(PipelineError::InvalidRequest(format!(
1394                            "Failed to parse findings response: {e}"
1395                        )))
1396                    }
1397                }
1398            }
1399            202 => {
1400                // Findings not ready yet
1401                Err(PipelineError::FindingsNotReady)
1402            }
1403            _ => {
1404                // Other error codes
1405                Err(PipelineError::InvalidRequest(format!(
1406                    "Failed to get findings - HTTP {status}: {response_text}"
1407                )))
1408            }
1409        }
1410    }
1411
1412    /// Get complete scan results (scan details + findings + summary)
1413    ///
1414    /// # Arguments
1415    ///
1416    /// * `scan_id` - The scan ID
1417    ///
1418    /// # Returns
1419    ///
1420    /// A `Result` containing the complete scan results
1421    ///
1422    /// # Note
1423    ///
1424    /// This method will return `FindingsNotReady` error if the scan findings are not yet available.
1425    /// Use `get_scan()` to check scan status before calling this method.
1426    ///
1427    /// # Errors
1428    ///
1429    /// Returns an error if the API request fails, the pipeline scan fails,
1430    /// or authentication/authorization fails.
1431    pub async fn get_results(&self, scan_id: &str) -> Result<ScanResults, PipelineError> {
1432        debug!("🔍 Debug - get_results() getting scan details for: {scan_id}");
1433        let scan = self.get_scan(scan_id).await?;
1434        debug!("🔍 Debug - get_results() scan status: {}", scan.scan_status);
1435        debug!("🔍 Debug - get_results() calling get_findings() for: {scan_id}");
1436        let findings = self.get_findings(scan_id).await?;
1437
1438        // Calculate summary
1439        let summary = self.calculate_summary(&findings);
1440
1441        // Generate standards compliance (placeholder - would need actual implementation)
1442        let standards = SecurityStandards {
1443            owasp: None,
1444            sans: None,
1445            pci: None,
1446            cwe: None,
1447        };
1448
1449        Ok(ScanResults {
1450            scan,
1451            findings,
1452            summary,
1453            standards,
1454        })
1455    }
1456
1457    /// Cancel a running pipeline scan
1458    ///
1459    /// # Arguments
1460    ///
1461    /// * `scan_id` - The scan ID
1462    ///
1463    /// # Returns
1464    ///
1465    /// A `Result` indicating success or failure
1466    ///
1467    /// # Errors
1468    ///
1469    /// Returns an error if the API request fails, the pipeline scan fails,
1470    /// or authentication/authorization fails.
1471    pub async fn cancel_scan(&self, scan_id: &str) -> Result<(), PipelineError> {
1472        // Path traversal protection: validate scan_id before URL construction
1473        validate_scan_id(scan_id)
1474            .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1475
1476        let endpoint = format!("/scans/{scan_id}/cancel");
1477
1478        let response = self.client.delete_with_response(&endpoint).await?;
1479
1480        if response.status().is_success() {
1481            Ok(())
1482        } else {
1483            let error_text = response
1484                .text()
1485                .await
1486                .unwrap_or_else(|_| "Unknown error".to_string());
1487            Err(PipelineError::InvalidRequest(format!(
1488                "Failed to cancel scan: {error_text}"
1489            )))
1490        }
1491    }
1492
1493    /// Wait for scan to complete with polling
1494    ///
1495    /// # Arguments
1496    ///
1497    /// * `scan_id` - The scan ID
1498    /// * `timeout_minutes` - Maximum time to wait (default: 60 minutes)
1499    /// * `poll_interval_seconds` - Polling interval (default: 10 seconds)
1500    ///
1501    /// # Returns
1502    ///
1503    /// A `Result` containing the completed scan or timeout error
1504    ///
1505    /// # Errors
1506    ///
1507    /// Returns an error if the API request fails, the pipeline scan fails,
1508    /// or authentication/authorization fails.
1509    pub async fn wait_for_completion(
1510        &self,
1511        scan_id: &str,
1512        timeout_minutes: Option<u32>,
1513        poll_interval_seconds: Option<u32>,
1514    ) -> Result<Scan, PipelineError> {
1515        let timeout = timeout_minutes.unwrap_or(60);
1516        let interval = poll_interval_seconds.unwrap_or(10);
1517        let max_polls = timeout
1518            .saturating_mul(60)
1519            .checked_div(interval)
1520            .unwrap_or(u32::MAX);
1521
1522        for _ in 0..max_polls {
1523            let scan = self.get_scan(scan_id).await?;
1524
1525            // Check if scan is completed based on status
1526            if scan.scan_status.is_successful() || scan.scan_status.is_failed() {
1527                return Ok(scan);
1528            }
1529
1530            // Wait before next poll
1531            tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64)).await;
1532        }
1533
1534        Err(PipelineError::ScanTimeout)
1535    }
1536
1537    /// Calculate findings summary from a list of findings
1538    fn calculate_summary(&self, findings: &[Finding]) -> FindingsSummary {
1539        #[allow(clippy::cast_possible_truncation)]
1540        let mut summary = FindingsSummary {
1541            very_high: 0,
1542            high: 0,
1543            medium: 0,
1544            low: 0,
1545            very_low: 0,
1546            informational: 0,
1547            total: findings.len() as u32,
1548        };
1549
1550        for finding in findings {
1551            match finding.severity {
1552                5 => summary.very_high = summary.very_high.saturating_add(1),
1553                4 => summary.high = summary.high.saturating_add(1),
1554                3 => summary.medium = summary.medium.saturating_add(1),
1555                2 => summary.low = summary.low.saturating_add(1),
1556                1 => summary.very_low = summary.very_low.saturating_add(1),
1557                0 => summary.informational = summary.informational.saturating_add(1),
1558                _ => {} // Unknown severity
1559            }
1560        }
1561
1562        summary
1563    }
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568    use super::*;
1569
1570    #[test]
1571    fn test_strip_html_tags_no_tags() {
1572        let input = "This is plain text";
1573        let result = strip_html_tags(input);
1574        assert_eq!(result, "This is plain text");
1575    }
1576
1577    #[test]
1578    fn test_strip_html_tags_simple() {
1579        let input = "This is <b>bold</b> text";
1580        let result = strip_html_tags(input);
1581        assert_eq!(result, "This is bold text");
1582    }
1583
1584    #[test]
1585    fn test_strip_html_tags_removes_script_content() {
1586        // SECURITY: Script content must be removed, not just tags
1587        let input = "<script>alert('XSS')</script>This is safe";
1588        let result = strip_html_tags(input);
1589        assert_eq!(result, "This is safe");
1590    }
1591
1592    #[test]
1593    fn test_strip_html_tags_removes_script_with_attributes() {
1594        let input = "<script type='text/javascript'>alert('XSS')</script>Safe text";
1595        let result = strip_html_tags(input);
1596        assert_eq!(result, "Safe text");
1597    }
1598
1599    #[test]
1600    fn test_strip_html_tags_removes_style_content() {
1601        let input = "<style>body { color: red; }</style>Visible text";
1602        let result = strip_html_tags(input);
1603        assert_eq!(result, "Visible text");
1604    }
1605
1606    #[test]
1607    fn test_strip_html_tags_multiple_scripts() {
1608        let input = "<script>evil1()</script>Good<script>evil2()</script>Text";
1609        let result = strip_html_tags(input);
1610        assert_eq!(result, "Good Text");
1611    }
1612
1613    #[test]
1614    fn test_strip_html_tags_nested_tags() {
1615        let input = "<div><p>Paragraph <b>bold</b> text</p></div>";
1616        let result = strip_html_tags(input);
1617        assert_eq!(result, "Paragraph bold text");
1618    }
1619
1620    #[test]
1621    fn test_strip_html_tags_mixed_content() {
1622        let input = "Before<script>bad()</script>Middle<b>bold</b>After";
1623        let result = strip_html_tags(input);
1624        assert_eq!(result, "Before Middle bold After");
1625    }
1626
1627    #[test]
1628    fn test_strip_html_tags_case_insensitive_script() {
1629        let input = "<SCRIPT>evil()</SCRIPT>Safe";
1630        let result = strip_html_tags(input);
1631        assert_eq!(result, "Safe");
1632    }
1633
1634    #[test]
1635    fn test_strip_html_tags_case_insensitive_style() {
1636        let input = "<STYLE>css</STYLE>Text";
1637        let result = strip_html_tags(input);
1638        assert_eq!(result, "Text");
1639    }
1640
1641    #[test]
1642    fn test_strip_html_tags_whitespace_cleanup() {
1643        // When HTML tags are present, extra whitespace is collapsed
1644        let input = "Text   <b>with</b>    extra     <i>spaces</i>";
1645        let result = strip_html_tags(input);
1646        assert_eq!(result, "Text with extra spaces");
1647    }
1648
1649    #[test]
1650    fn test_strip_html_tags_no_html_preserves_whitespace() {
1651        // When no HTML tags, original whitespace is preserved (Cow::Borrowed)
1652        let input = "Text   with    extra     spaces";
1653        let result = strip_html_tags(input);
1654        assert_eq!(result, "Text   with    extra     spaces");
1655    }
1656
1657    #[test]
1658    fn test_strip_html_tags_preserves_normal_content_order() {
1659        let input = "First <p>Second</p> Third <b>Fourth</b> Fifth";
1660        let result = strip_html_tags(input);
1661        assert_eq!(result, "First Second Third Fourth Fifth");
1662    }
1663
1664    #[test]
1665    fn test_scan_status_classifications() {
1666        // Test success classification
1667        assert!(ScanStatus::Success.is_successful());
1668        assert!(!ScanStatus::Success.is_failed());
1669        assert!(!ScanStatus::Success.is_in_progress());
1670
1671        // Test failure classifications
1672        assert!(ScanStatus::Failure.is_failed());
1673        assert!(ScanStatus::Cancelled.is_failed());
1674        assert!(ScanStatus::Timeout.is_failed());
1675        assert!(ScanStatus::UserTimeout.is_failed());
1676
1677        // Test in-progress classifications
1678        assert!(ScanStatus::Pending.is_in_progress());
1679        assert!(ScanStatus::Uploading.is_in_progress());
1680        assert!(ScanStatus::Started.is_in_progress());
1681    }
1682
1683    #[test]
1684    fn test_finding_to_legacy_conversion() {
1685        let finding = Finding {
1686            cwe_id: "89".to_string(),
1687            display_text: "<b>SQL Injection</b> vulnerability found".to_string(),
1688            files: FindingFiles {
1689                source_file: SourceFile {
1690                    file: "app.rs".to_string(),
1691                    function_name: Some("query".to_string()),
1692                    function_prototype: "fn query()".to_string(),
1693                    line: 42,
1694                    qualified_function_name: "app::query".to_string(),
1695                    scope: "local".to_string(),
1696                },
1697            },
1698            flaw_details_link: Some("https://veracode.com/details".to_string()),
1699            gob: "A".to_string(),
1700            issue_id: 12345,
1701            issue_type: "SQL Injection".to_string(),
1702            issue_type_id: "89".to_string(),
1703            severity: 5,
1704            stack_dumps: None,
1705            title: "SQL Injection Flaw".to_string(),
1706        };
1707
1708        let legacy = finding.to_legacy();
1709
1710        // Verify conversion
1711        assert_eq!(legacy.file, "app.rs");
1712        assert_eq!(legacy.line, 42);
1713        assert_eq!(legacy.severity, 5);
1714        assert_eq!(legacy.cwe_id, 89);
1715        assert_eq!(legacy.issue_id, Some("12345".to_string()));
1716        // HTML should be stripped
1717        assert_eq!(legacy.message, "SQL Injection vulnerability found");
1718    }
1719
1720    #[test]
1721    fn test_finding_to_legacy_with_invalid_cwe() {
1722        let finding = Finding {
1723            cwe_id: "not-a-number".to_string(),
1724            display_text: "Test".to_string(),
1725            files: FindingFiles {
1726                source_file: SourceFile {
1727                    file: "app.rs".to_string(),
1728                    function_name: None,
1729                    function_prototype: "fn test()".to_string(),
1730                    line: 1,
1731                    qualified_function_name: "app::test".to_string(),
1732                    scope: "local".to_string(),
1733                },
1734            },
1735            flaw_details_link: None,
1736            gob: "B".to_string(),
1737            issue_id: 1,
1738            issue_type: "Test".to_string(),
1739            issue_type_id: "0".to_string(),
1740            severity: 1,
1741            stack_dumps: None,
1742            title: "Test".to_string(),
1743        };
1744
1745        let legacy = finding.to_legacy();
1746        // Invalid CWE should default to 0
1747        assert_eq!(legacy.cwe_id, 0);
1748    }
1749}
1750
1751// ============================================================================
1752// TIER 1: PROPERTY-BASED SECURITY TESTS (Fast, High ROI)
1753// ============================================================================
1754//
1755// These tests use proptest to validate security properties against adversarial
1756// inputs. They run 1000 test cases in normal mode and 10 under Miri for UB detection.
1757
1758#[cfg(test)]
1759#[allow(clippy::expect_used)]
1760mod proptest_security {
1761    use super::*;
1762    use proptest::prelude::*;
1763
1764    // ============================================================================
1765    // SECURITY TEST: HTML Sanitization (XSS Prevention)
1766    // ============================================================================
1767
1768    proptest! {
1769        #![proptest_config(ProptestConfig {
1770            cases: if cfg!(miri) { 5 } else { 1000 },
1771            failure_persistence: None,
1772            .. ProptestConfig::default()
1773        })]
1774
1775        /// Property: Script tags and their content must ALWAYS be removed
1776        /// This is critical for XSS prevention - script content must not appear in output
1777        #[test]
1778        fn proptest_html_script_content_removed(
1779            script_content in "[a-zA-Z0-9()';,.]{3,100}",
1780            prefix in "[a-zA-Z0-9]{3,50}",
1781            suffix in "[a-zA-Z0-9]{0,50}",
1782        ) {
1783            let input = format!("{}<script>{}</script>{}", prefix, script_content, suffix);
1784            let result = strip_html_tags(&input);
1785
1786            // SECURITY: Script content must be completely removed
1787            // The script content should not appear in the output
1788            // We use unique multi-character strings to avoid false positives
1789            prop_assert!(
1790                !result.contains(&script_content),
1791                "Script content '{}' must not appear in sanitized output: '{}'",
1792                script_content,
1793                result
1794            );
1795
1796            // Legitimate content before/after script should be preserved
1797            prop_assert!(result.contains(&prefix));
1798            if !suffix.is_empty() {
1799                prop_assert!(result.contains(&suffix));
1800            }
1801        }
1802
1803        /// Property: Style tags and their content must ALWAYS be removed
1804        /// Prevents CSS injection attacks
1805        #[test]
1806        fn proptest_html_style_content_removed(
1807            style_content in "[a-zA-Z0-9:;{}]{3,100}",
1808            text_content in "[a-zA-Z0-9]{3,50}",
1809        ) {
1810            let input = format!("<style>{}</style>{}", style_content, text_content);
1811            let result = strip_html_tags(&input);
1812
1813            // SECURITY: Style content must be completely removed
1814            // Use unique multi-character strings to avoid false positives
1815            prop_assert!(
1816                !result.contains(&style_content),
1817                "Style content '{}' must not appear in sanitized output: '{}'",
1818                style_content,
1819                result
1820            );
1821
1822            // Legitimate text after style should be preserved
1823            prop_assert!(result.contains(&text_content));
1824        }
1825
1826        /// Property: HTML without tags returns input unchanged (zero-copy optimization)
1827        /// Tests Cow::Borrowed optimization path
1828        #[test]
1829        fn proptest_html_no_tags_unchanged(
1830            plain_text in "[a-zA-Z0-9 ,.!?]{1,200}",
1831        ) {
1832            // Ensure no angle brackets
1833            let plain_text = plain_text.replace(['<', '>'], "");
1834            if plain_text.is_empty() {
1835                return Ok(());
1836            }
1837
1838            let result = strip_html_tags(&plain_text);
1839
1840            // Property 1: Content must be identical
1841            prop_assert_eq!(&result, &plain_text);
1842
1843            // Property 2: Should use Cow::Borrowed (same pointer)
1844            match result {
1845                std::borrow::Cow::Borrowed(s) => {
1846                    prop_assert_eq!(s, plain_text.as_str());
1847                }
1848                std::borrow::Cow::Owned(_) => {
1849                    prop_assert!(false, "Should use Cow::Borrowed for plain text");
1850                }
1851            }
1852        }
1853
1854        /// Property: Nested tags must be completely removed
1855        /// Tests recursive tag removal
1856        #[test]
1857        fn proptest_html_nested_tags_removed(
1858            depth in 1usize..=10,
1859            content in "[a-zA-Z0-9]{1,20}",
1860        ) {
1861            // Create nested tags
1862            let mut input = String::new();
1863            for _ in 0..depth {
1864                input.push_str("<div>");
1865            }
1866            input.push_str(&content);
1867            for _ in 0..depth {
1868                input.push_str("</div>");
1869            }
1870
1871            let result = strip_html_tags(&input);
1872
1873            // All tags should be removed, content preserved
1874            prop_assert!(!result.contains("<div>"));
1875            prop_assert!(!result.contains("</div>"));
1876            // Content should be preserved (whitespace may be normalized)
1877            let content_trimmed = content.trim();
1878            if !content_trimmed.is_empty() {
1879                prop_assert!(result.contains(content_trimmed));
1880            }
1881        }
1882
1883        /// Property: Case-insensitive script/style detection
1884        /// Tests that XSS attempts with various casings are blocked
1885        #[test]
1886        fn proptest_html_case_insensitive_script(
1887            uppercase_ratio in 0.0..=1.0,
1888        ) {
1889            // Create "script" with random casing
1890            let script_word = randomize_case("script", uppercase_ratio);
1891            let input = format!("<{}>alert('xss')</{}>Safe", script_word, script_word);
1892            let result = strip_html_tags(&input);
1893
1894            // Script content must be removed regardless of case
1895            prop_assert!(!result.contains("alert"));
1896            prop_assert!(!result.contains("xss"));
1897            prop_assert!(result.contains("Safe"));
1898        }
1899
1900        /// Property: Whitespace normalization must not lose word boundaries
1901        /// Tests that words remain separated after tag removal
1902        #[test]
1903        fn proptest_html_preserves_word_boundaries(
1904            word1 in "[a-zA-Z]{1,20}",
1905            word2 in "[a-zA-Z]{1,20}",
1906            word3 in "[a-zA-Z]{1,20}",
1907        ) {
1908            let input = format!("{}<b>{}</b>{}", word1, word2, word3);
1909            let result = strip_html_tags(&input);
1910
1911            // All words should be present
1912            prop_assert!(result.contains(&word1));
1913            prop_assert!(result.contains(&word2));
1914            prop_assert!(result.contains(&word3));
1915
1916            // Words should be separated by whitespace
1917            let words: Vec<&str> = result.split_whitespace().collect();
1918            prop_assert!(words.contains(&word1.as_str()));
1919            prop_assert!(words.contains(&word2.as_str()));
1920            prop_assert!(words.contains(&word3.as_str()));
1921        }
1922    }
1923
1924    // ============================================================================
1925    // SECURITY TEST: Upload Segment Validation (DoS Prevention)
1926    // ============================================================================
1927
1928    proptest! {
1929        #![proptest_config(ProptestConfig {
1930            cases: if cfg!(miri) { 5 } else { 1000 },
1931            failure_persistence: None,
1932            .. ProptestConfig::default()
1933        })]
1934
1935        /// Property: Zero or negative segment count must be rejected
1936        /// Prevents division-by-zero DoS attacks
1937        #[test]
1938        fn proptest_segment_count_validation_rejects_invalid(
1939            invalid_count in -1000i32..=0i32,
1940        ) {
1941            // Create mock PipelineApi (we'll test the validation logic directly)
1942            // The validation happens at the start of upload_binary_segments
1943
1944            // Test the validation condition directly
1945            let is_valid = invalid_count > 0;
1946            prop_assert!(!is_valid, "Segment count {} should be rejected", invalid_count);
1947        }
1948
1949        /// Property: Segment size calculation must not overflow
1950        /// Tests that extreme file sizes and segment counts are handled safely
1951        #[test]
1952        fn proptest_segment_size_calculation_safe(
1953            file_size in 0usize..=10_000_000,
1954            segment_count in 1i32..=1000,
1955        ) {
1956            // Simulate the segment size calculation from upload_binary_segments
1957            #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1958            let segment_size = ((file_size as f64) / (segment_count as f64)).ceil() as usize;
1959
1960            // Property 1: Segment size must not exceed file size
1961            prop_assert!(segment_size <= file_size.saturating_add(1));
1962
1963            // Property 2: Must not overflow
1964            prop_assert!(segment_size < usize::MAX);
1965
1966            // Property 3: All segments must fit within file
1967            let total_bytes = segment_size.saturating_mul(segment_count.try_into().unwrap_or(0));
1968            prop_assert!(total_bytes >= file_size);
1969        }
1970
1971        /// Property: Segment boundary calculation must be safe
1972        /// Tests that start_idx and end_idx calculations don't overflow or panic
1973        #[test]
1974        fn proptest_segment_boundaries_safe(
1975            file_size in 1usize..=1_000_000,
1976            segment_num in 0i32..=100,
1977            segment_count in 1i32..=100,
1978        ) {
1979            // Only test valid segment numbers
1980            if segment_num >= segment_count {
1981                return Ok(());
1982            }
1983
1984            #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1985            let segment_size = ((file_size as f64) / (segment_count as f64)).ceil() as usize;
1986
1987            // Simulate boundary calculation from upload_binary_segments
1988            #[allow(clippy::cast_sign_loss)]
1989            let start_idx = (segment_num as usize).saturating_mul(segment_size);
1990            let end_idx = std::cmp::min(start_idx.saturating_add(segment_size), file_size);
1991
1992            // Property 1: Start must be before or at end
1993            prop_assert!(start_idx <= end_idx);
1994
1995            // Property 2: End must not exceed file size
1996            prop_assert!(end_idx <= file_size);
1997
1998            // Property 3: Segment must have non-negative size
1999            let segment_len = end_idx.saturating_sub(start_idx);
2000            prop_assert!(segment_len <= file_size);
2001        }
2002    }
2003
2004    // ============================================================================
2005    // SECURITY TEST: Summary Calculation (Integer Overflow Prevention)
2006    // ============================================================================
2007
2008    proptest! {
2009        #![proptest_config(ProptestConfig {
2010            cases: if cfg!(miri) { 5 } else { 1000 },
2011            failure_persistence: None,
2012            .. ProptestConfig::default()
2013        })]
2014
2015        /// Property: Summary calculation must not overflow with extreme finding counts
2016        /// Tests that saturating arithmetic prevents integer overflow
2017        #[test]
2018        fn proptest_summary_calculation_no_overflow(
2019            severity_0_count in 0usize..=10000,
2020            severity_1_count in 0usize..=10000,
2021            severity_2_count in 0usize..=10000,
2022            severity_3_count in 0usize..=10000,
2023            severity_4_count in 0usize..=10000,
2024            severity_5_count in 0usize..=10000,
2025        ) {
2026            // Create findings with specified severity counts
2027            let mut findings = Vec::new();
2028
2029            for _ in 0..severity_0_count {
2030                findings.push(create_test_finding(0));
2031            }
2032            for _ in 0..severity_1_count {
2033                findings.push(create_test_finding(1));
2034            }
2035            for _ in 0..severity_2_count {
2036                findings.push(create_test_finding(2));
2037            }
2038            for _ in 0..severity_3_count {
2039                findings.push(create_test_finding(3));
2040            }
2041            for _ in 0..severity_4_count {
2042                findings.push(create_test_finding(4));
2043            }
2044            for _ in 0..severity_5_count {
2045                findings.push(create_test_finding(5));
2046            }
2047
2048            // Create mock client to call calculate_summary
2049            let config = create_test_config();
2050            let client = VeracodeClient::new(config).expect("valid test config");
2051            let api = PipelineApi::new(client);
2052
2053            let summary = api.calculate_summary(&findings);
2054
2055            // Property 1: Total must equal sum of all categories
2056            let expected_total = findings.len();
2057            #[allow(clippy::cast_possible_truncation)]
2058            let expected_total_u32 = expected_total as u32;
2059            prop_assert_eq!(summary.total, expected_total_u32);
2060
2061            // Property 2: Each severity count must match input
2062            #[allow(clippy::cast_possible_truncation)]
2063            {
2064                let sev0 = severity_0_count as u32;
2065                let sev1 = severity_1_count as u32;
2066                let sev2 = severity_2_count as u32;
2067                let sev3 = severity_3_count as u32;
2068                let sev4 = severity_4_count as u32;
2069                let sev5 = severity_5_count as u32;
2070                prop_assert_eq!(summary.informational, sev0);
2071                prop_assert_eq!(summary.very_low, sev1);
2072                prop_assert_eq!(summary.low, sev2);
2073                prop_assert_eq!(summary.medium, sev3);
2074                prop_assert_eq!(summary.high, sev4);
2075                prop_assert_eq!(summary.very_high, sev5);
2076            }
2077
2078            // Property 3: Sum of severity counts must equal total
2079            let sum = summary.informational
2080                .saturating_add(summary.very_low)
2081                .saturating_add(summary.low)
2082                .saturating_add(summary.medium)
2083                .saturating_add(summary.high)
2084                .saturating_add(summary.very_high);
2085            prop_assert_eq!(sum, summary.total);
2086        }
2087
2088        /// Property: Unknown severity values must be handled gracefully
2089        /// Tests that invalid severity values don't cause panics or incorrect counts
2090        #[test]
2091        fn proptest_summary_handles_unknown_severity(
2092            valid_count in 0usize..=100,
2093            unknown_severity in 6u32..=255,
2094        ) {
2095            let mut findings = Vec::new();
2096
2097            // Add valid findings
2098            for _ in 0..valid_count {
2099                findings.push(create_test_finding(3));
2100            }
2101
2102            // Add finding with unknown severity
2103            findings.push(create_test_finding(unknown_severity));
2104
2105            let config = create_test_config();
2106            let client = VeracodeClient::new(config).expect("valid test config");
2107            let api = PipelineApi::new(client);
2108
2109            let summary = api.calculate_summary(&findings);
2110
2111            // Property: Total should include all findings
2112            #[allow(clippy::cast_possible_truncation)]
2113            let expected_total = findings.len() as u32;
2114            #[allow(clippy::cast_possible_truncation)]
2115            let expected_medium = valid_count as u32;
2116            prop_assert_eq!(summary.total, expected_total);
2117
2118            // Property: Valid findings should be counted correctly
2119            prop_assert_eq!(summary.medium, expected_medium);
2120        }
2121    }
2122
2123    // ============================================================================
2124    // Helper Functions for Property Tests
2125    // ============================================================================
2126
2127    /// Randomize the case of a string based on a ratio (0.0 = all lowercase, 1.0 = all uppercase)
2128    fn randomize_case(s: &str, uppercase_ratio: f64) -> String {
2129        s.chars()
2130            .map(|c| {
2131                if c.is_alphabetic() {
2132                    // Use deterministic check based on character position
2133                    let char_hash = (c as u32 as f64) / 256.0;
2134                    if char_hash < uppercase_ratio {
2135                        c.to_uppercase().to_string()
2136                    } else {
2137                        c.to_lowercase().to_string()
2138                    }
2139                } else {
2140                    c.to_string()
2141                }
2142            })
2143            .collect()
2144    }
2145
2146    /// Create a test finding with specified severity
2147    fn create_test_finding(severity: u32) -> Finding {
2148        Finding {
2149            cwe_id: "89".to_string(),
2150            display_text: "Test finding".to_string(),
2151            files: FindingFiles {
2152                source_file: SourceFile {
2153                    file: "test.rs".to_string(),
2154                    function_name: Some("test".to_string()),
2155                    function_prototype: "fn test()".to_string(),
2156                    line: 1,
2157                    qualified_function_name: "test::test".to_string(),
2158                    scope: "local".to_string(),
2159                },
2160            },
2161            flaw_details_link: None,
2162            gob: "C".to_string(),
2163            issue_id: 1,
2164            issue_type: "Test".to_string(),
2165            issue_type_id: "0".to_string(),
2166            severity,
2167            stack_dumps: None,
2168            title: "Test".to_string(),
2169        }
2170    }
2171
2172    /// Create a test Veracode config for testing
2173    fn create_test_config() -> crate::VeracodeConfig {
2174        use crate::{VeracodeCredentials, VeracodeRegion};
2175
2176        crate::VeracodeConfig {
2177            credentials: VeracodeCredentials::new(
2178                "test_api_id".to_string(),
2179                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
2180            ),
2181            base_url: "https://api.veracode.com".to_string(),
2182            rest_base_url: "https://api.veracode.com".to_string(),
2183            xml_base_url: "https://analysiscenter.veracode.com".to_string(),
2184            region: VeracodeRegion::Commercial,
2185            validate_certificates: true,
2186            connect_timeout: 30,
2187            request_timeout: 300,
2188            proxy_url: None,
2189            proxy_username: None,
2190            proxy_password: None,
2191            retry_config: Default::default(),
2192        }
2193    }
2194}
2195
2196// ============================================================================
2197// TIER 2: MIRI TESTS (Memory Safety & UB Detection)
2198// ============================================================================
2199//
2200// These tests verify memory safety and detect undefined behavior.
2201// They leverage the proptest infrastructure above but run under Miri's interpreter.
2202//
2203// Run with: cargo +nightly miri test
2204//
2205// The proptest configs above already use cfg!(miri) for adaptive case counts.
2206
2207#[cfg(test)]
2208mod miri_tests {
2209    use super::*;
2210
2211    /// MIRI TEST: HTML sanitization must not have undefined behavior
2212    /// Tests for out-of-bounds access, use-after-free, etc.
2213    #[test]
2214    fn test_miri_html_sanitization_memory_safety() {
2215        // Test various edge cases that might trigger UB
2216        let angle_brackets_left = "<".repeat(1000);
2217        let angle_brackets_right = ">".repeat(1000);
2218        let repeated_tags = "<a>".repeat(100);
2219
2220        let test_cases = vec![
2221            "",
2222            "x",
2223            "<",
2224            ">",
2225            "<>",
2226            "<script>",
2227            "</script>",
2228            "<script></script>",
2229            "<SCRIPT>alert('XSS')</SCRIPT>Safe",
2230            "Text<b>bold</b>more",
2231            angle_brackets_left.as_str(),
2232            angle_brackets_right.as_str(),
2233            repeated_tags.as_str(),
2234        ];
2235
2236        for input in test_cases {
2237            let result = strip_html_tags(input);
2238            // Must not panic or cause UB
2239            assert!(result.len() <= input.len() + input.len()); // Reasonable bounds
2240        }
2241    }
2242
2243    /// MIRI TEST: Finding to legacy conversion must be memory safe
2244    /// Tests string cloning and parsing operations
2245    #[test]
2246    fn test_miri_finding_conversion_memory_safety() {
2247        let finding = Finding {
2248            cwe_id: "123".to_string(),
2249            display_text: "<b>Test</b>".to_string(),
2250            files: FindingFiles {
2251                source_file: SourceFile {
2252                    file: "test.rs".to_string(),
2253                    function_name: None,
2254                    function_prototype: "fn test()".to_string(),
2255                    line: 1,
2256                    qualified_function_name: "test".to_string(),
2257                    scope: "local".to_string(),
2258                },
2259            },
2260            flaw_details_link: None,
2261            gob: "A".to_string(),
2262            issue_id: 1,
2263            issue_type: "Test".to_string(),
2264            issue_type_id: "0".to_string(),
2265            severity: 3,
2266            stack_dumps: None,
2267            title: "Test".to_string(),
2268        };
2269
2270        // Must not cause UB during conversion
2271        let legacy = finding.to_legacy();
2272        assert_eq!(legacy.cwe_id, 123);
2273        assert_eq!(legacy.message, "Test");
2274    }
2275
2276    /// MIRI TEST: Scan status state machine transitions must be memory safe
2277    /// Tests enum matching and boolean logic
2278    #[test]
2279    fn test_miri_scan_status_state_machine() {
2280        let statuses = vec![
2281            ScanStatus::Pending,
2282            ScanStatus::Uploading,
2283            ScanStatus::Started,
2284            ScanStatus::Success,
2285            ScanStatus::Failure,
2286            ScanStatus::Cancelled,
2287            ScanStatus::Timeout,
2288            ScanStatus::UserTimeout,
2289        ];
2290
2291        for status in statuses {
2292            // These operations must not cause UB
2293            let _ = status.is_successful();
2294            let _ = status.is_failed();
2295            let _ = status.is_in_progress();
2296            let _ = status.to_string();
2297
2298            // State invariant: exactly one of these must be true
2299            let states = [
2300                status.is_successful(),
2301                status.is_failed(),
2302                status.is_in_progress(),
2303            ];
2304            let true_count = states.iter().filter(|&&x| x).count();
2305            assert_eq!(
2306                true_count, 1,
2307                "Status {:?} must be in exactly one state",
2308                status
2309            );
2310        }
2311    }
2312}