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::{VeracodeClient, VeracodeError};
11
12/// Plugin version constant to avoid repeated allocations
13const PLUGIN_VERSION: &str = "25.2.0-0";
14
15/// Error types specific to pipeline scan operations
16#[derive(Debug, thiserror::Error)]
17#[must_use = "Need to handle all error enum types."]
18pub enum PipelineError {
19    #[error("Pipeline scan not found")]
20    ScanNotFound,
21    #[error("Permission denied: {0}")]
22    PermissionDenied(String),
23    #[error("Invalid request: {0}")]
24    InvalidRequest(String),
25    #[error("Scan timeout")]
26    ScanTimeout,
27    #[error("Scan findings not ready yet - try again later")]
28    FindingsNotReady,
29    #[error("Application not found: {0}")]
30    ApplicationNotFound(String),
31    #[error(
32        "Multiple applications found with name '{0}'. Please check the application name and ensure it uniquely identifies a single application."
33    )]
34    MultipleApplicationsFound(String),
35    #[error("API error: {0}")]
36    ApiError(#[from] VeracodeError),
37    #[error("HTTP error: {0}")]
38    Http(#[from] reqwest::Error),
39}
40
41/// Pipeline scan development stage
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "UPPERCASE")]
44pub enum DevStage {
45    Development,
46    Testing,
47    Release,
48}
49
50impl std::fmt::Display for DevStage {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            DevStage::Development => write!(f, "DEVELOPMENT"),
54            DevStage::Testing => write!(f, "TESTING"),
55            DevStage::Release => write!(f, "RELEASE"),
56        }
57    }
58}
59
60/// Pipeline scan stage/status
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "UPPERCASE")]
63pub enum ScanStage {
64    Create,
65    Upload,
66    Start,
67    Details,
68    Findings,
69}
70
71/// Pipeline scan execution status  
72#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "UPPERCASE")]
74pub enum ScanStatus {
75    Pending,
76    Uploading,
77    Started,
78    Success,
79    Failure,
80    Cancelled,
81    Timeout,
82    #[serde(rename = "USER_TIMEOUT")]
83    UserTimeout,
84}
85
86impl ScanStatus {
87    /// Check if the scan completed successfully
88    #[must_use]
89    pub fn is_successful(&self) -> bool {
90        matches!(self, ScanStatus::Success)
91    }
92
93    /// Check if the scan failed or was terminated
94    #[must_use]
95    pub fn is_failed(&self) -> bool {
96        matches!(
97            self,
98            ScanStatus::Failure
99                | ScanStatus::Cancelled
100                | ScanStatus::Timeout
101                | ScanStatus::UserTimeout
102        )
103    }
104
105    /// Check if the scan is still in progress
106    #[must_use]
107    pub fn is_in_progress(&self) -> bool {
108        matches!(
109            self,
110            ScanStatus::Pending | ScanStatus::Uploading | ScanStatus::Started
111        )
112    }
113}
114
115impl std::fmt::Display for ScanStatus {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            ScanStatus::Pending => write!(f, "PENDING"),
119            ScanStatus::Uploading => write!(f, "UPLOADING"),
120            ScanStatus::Started => write!(f, "STARTED"),
121            ScanStatus::Success => write!(f, "SUCCESS"),
122            ScanStatus::Failure => write!(f, "FAILURE"),
123            ScanStatus::Cancelled => write!(f, "CANCELLED"),
124            ScanStatus::Timeout => write!(f, "TIMEOUT"),
125            ScanStatus::UserTimeout => write!(f, "USER_TIMEOUT"),
126        }
127    }
128}
129
130/// Security finding severity levels
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub enum Severity {
133    #[serde(rename = "0")]
134    Informational,
135    #[serde(rename = "1")]
136    VeryLow,
137    #[serde(rename = "2")]
138    Low,
139    #[serde(rename = "3")]
140    Medium,
141    #[serde(rename = "4")]
142    High,
143    #[serde(rename = "5")]
144    VeryHigh,
145}
146
147/// Source file information for a finding
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SourceFile {
150    /// File path where the issue was found
151    pub file: String,
152    /// Function name (may be null)
153    pub function_name: Option<String>,
154    /// Function prototype
155    pub function_prototype: String,
156    /// Line number in the file
157    pub line: u32,
158    /// Qualified function name
159    pub qualified_function_name: String,
160    /// Scope information
161    pub scope: String,
162}
163
164/// Files information containing source file details
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct FindingFiles {
167    /// Source file information
168    pub source_file: SourceFile,
169}
170
171/// Stack dump information
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct StackDumps {
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the API request fails, the resource is not found,
178    /// or authentication/authorization fails.
179    /// Array of stack dumps (optional - can be missing when `stack_dumps` is empty object)
180    pub stack_dump: Option<Vec<serde_json::Value>>,
181}
182
183/// Security finding/issue from a pipeline scan
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Finding {
186    /// CWE (Common Weakness Enumeration) ID as string
187    pub cwe_id: String,
188    /// Detailed message with HTML formatting
189    pub display_text: String,
190    /// File and location information
191    pub files: FindingFiles,
192    /// Flaw details link for accessing detailed vulnerability information (optional)
193    pub flaw_details_link: Option<String>,
194    /// Grade of defect (e.g., "B")
195    pub gob: String,
196    /// Issue ID for tracking
197    pub issue_id: u32,
198    /// Type of security issue
199    pub issue_type: String,
200    /// Issue type identifier
201    pub issue_type_id: String,
202    /// Severity level (0-5)
203    pub severity: u32,
204    /// Stack dump information (optional)
205    pub stack_dumps: Option<StackDumps>,
206    /// Short title/summary of the issue
207    pub title: String,
208}
209
210/// Complete findings response from the API
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct FindingsResponse {
213    /// HAL navigation links
214    #[serde(rename = "_links")]
215    pub links: Option<serde_json::Value>,
216    /// Scan ID
217    pub scan_id: String,
218    /// Current scan status
219    pub scan_status: ScanStatus,
220    /// Scan message
221    pub message: String,
222    /// List of modules scanned
223    pub modules: Vec<String>,
224    /// Number of modules
225    pub modules_count: u32,
226    /// List of security findings
227    pub findings: Vec<Finding>,
228    /// Selected modules
229    pub selected_modules: Vec<String>,
230    /// Stack dump information (optional)
231    pub stack_dump: Option<serde_json::Value>,
232}
233
234/// Legacy Finding struct for backwards compatibility
235/// Converts from the new Finding format to a simpler structure
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct LegacyFinding {
238    /// File path where the issue was found
239    pub file: String,
240    /// Line number in the file
241    pub line: u32,
242    /// Type of security issue
243    pub issue_type: String,
244    /// Severity level (0-5)
245    pub severity: u32,
246    /// Descriptive message about the issue (HTML stripped)
247    pub message: String,
248    /// CWE (Common Weakness Enumeration) ID
249    pub cwe_id: u32,
250    /// Optional link to more details
251    pub details_link: Option<String>,
252    /// Issue ID for tracking
253    pub issue_id: Option<String>,
254    /// OWASP category if applicable
255    pub owasp_category: Option<String>,
256    /// SANS category if applicable
257    pub sans_category: Option<String>,
258}
259
260impl Finding {
261    /// Convert to legacy format for backwards compatibility
262    #[must_use]
263    pub fn to_legacy(&self) -> LegacyFinding {
264        LegacyFinding {
265            file: self.files.source_file.file.clone(),
266            line: self.files.source_file.line,
267            issue_type: self.issue_type.clone(),
268            severity: self.severity,
269            message: strip_html_tags(&self.display_text).into_owned(),
270            cwe_id: self.cwe_id.parse().unwrap_or(0),
271            details_link: None,
272            issue_id: Some(self.issue_id.to_string()),
273            owasp_category: None,
274            sans_category: None,
275        }
276    }
277}
278
279/// Strip HTML tags from display text to get plain text message
280///
281/// Security: This function removes `<script>` and `<style>` tags AND their content
282/// to prevent script injection. Other HTML tags are removed but their text content
283/// is preserved.
284fn strip_html_tags(html: &str) -> Cow<'_, str> {
285    // Check if HTML tags are present to avoid unnecessary allocation
286    if !html.contains('<') {
287        return Cow::Borrowed(html);
288    }
289
290    // Simple HTML tag removal without regex dependency
291    let mut result = String::new();
292    let mut in_tag = false;
293    let mut in_script_or_style = false;
294    let mut tag_name = String::new();
295    let mut collecting_tag_name = false;
296    let mut just_closed_tag = false;
297
298    for ch in html.chars() {
299        match ch {
300            '<' => {
301                // Add space before tag if we just had content (for word boundaries)
302                if !result.is_empty() && !result.ends_with(char::is_whitespace) {
303                    result.push(' ');
304                }
305                in_tag = true;
306                collecting_tag_name = true;
307                just_closed_tag = false;
308                tag_name.clear();
309            }
310            '>' => {
311                in_tag = false;
312
313                // Check if we're entering or leaving a script/style block
314                let tag_lower = tag_name.trim().to_lowercase();
315                if tag_lower.starts_with("script") || tag_lower.starts_with("style") {
316                    in_script_or_style = true;
317                } else if tag_lower.starts_with("/script") || tag_lower.starts_with("/style") {
318                    in_script_or_style = false;
319                }
320                collecting_tag_name = false;
321                just_closed_tag = true;
322                tag_name.clear();
323            }
324            ' ' if in_tag && collecting_tag_name => {
325                // Space in tag (e.g., <script type="...">), stop collecting tag name
326                collecting_tag_name = false;
327            }
328            _ if (ch == '/' || ch.is_alphanumeric()) && collecting_tag_name => {
329                // Collect tag name to detect script/style tags
330                tag_name.push(ch);
331            }
332            _ if in_tag || in_script_or_style => {
333                // Skip content inside tags and inside script/style blocks
334            }
335            _ => {
336                // Keep text content (not in tags, not in script/style blocks)
337                // Add space after tag if needed (for word boundaries)
338                if just_closed_tag && !result.is_empty() && !result.ends_with(char::is_whitespace) {
339                    result.push(' ');
340                }
341                just_closed_tag = false;
342                result.push(ch);
343            }
344        }
345    }
346
347    // Clean up extra whitespace (collapse multiple spaces into one)
348    let cleaned = result.split_whitespace().collect::<Vec<&str>>().join(" ");
349    Cow::Owned(cleaned)
350}
351
352/// Pipeline scan request for creating a new scan
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct CreateScanRequest {
355    /// Name of the binary/artifact being scanned (MANDATORY)
356    #[serde(skip_serializing_if = "never_skip_string")]
357    pub binary_name: String,
358    /// Size of the binary in bytes (MANDATORY)
359    #[serde(skip_serializing_if = "never_skip_u64")]
360    pub binary_size: u64,
361    /// SHA-256 hash of the binary (MANDATORY)
362    #[serde(skip_serializing_if = "never_skip_string")]
363    pub binary_hash: String,
364    /// Project name
365    pub project_name: String,
366    /// Project URI (optional)
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub project_uri: Option<String>,
369    /// Development stage
370    pub dev_stage: DevStage,
371    /// Application ID (optional, for linking to existing Veracode app)
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub app_id: Option<String>,
374    /// Project reference/branch/commit (optional)
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub project_ref: Option<String>,
377    /// Scan timeout in minutes (optional)
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub scan_timeout: Option<u32>,
380    /// Plugin version (automatically set)
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub plugin_version: Option<String>,
383    /// Emit stack dump flag (optional)
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub emit_stack_dump: Option<String>,
386    /// Include specific modules (optional)
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub include_modules: Option<String>,
389}
390
391/// Helper function to never skip String fields (ensures mandatory fields are always included)
392fn never_skip_string(_: &String) -> bool {
393    false
394}
395
396/// Helper function to never skip u64 fields (ensures mandatory fields are always included)
397fn never_skip_u64(_: &u64) -> bool {
398    false
399}
400
401/// Result of scan creation containing scan ID and _links for operations
402#[derive(Debug, Clone)]
403pub struct ScanCreationResult {
404    /// The scan ID
405    pub scan_id: String,
406    /// Upload URI from _links.upload.href
407    pub upload_uri: Option<String>,
408    /// Details URI from _links.details.href
409    pub details_uri: Option<String>,
410    /// Start URI from _links.start.href
411    pub start_uri: Option<String>,
412    /// Cancel URI from _links.cancel.href
413    pub cancel_uri: Option<String>,
414    /// Expected number of upload segments
415    pub expected_segments: Option<u32>,
416}
417
418/// Pipeline scan configuration
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct ScanConfig {
421    /// Maximum timeout in minutes (default: 60)
422    pub timeout: Option<u32>,
423    /// Include low severity findings
424    pub include_low_severity: Option<bool>,
425    /// Maximum number of findings to return
426    pub max_findings: Option<u32>,
427}
428
429/// Pipeline scan details/status
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct Scan {
432    /// Unique scan ID
433    pub scan_id: String,
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the API request fails, the resource is not found,
438    /// or authentication/authorization fails.
439    /// Current scan status (UPLOADING, VERIFYING, RUNNING, `RESULTS_READY`, etc.)
440    pub scan_status: ScanStatus,
441    /// API version
442    pub api_version: f64,
443    /// Application ID (may be null)
444    pub app_id: Option<String>,
445    /// Project name
446    pub project_name: String,
447    /// Project URI
448    pub project_uri: Option<String>,
449    /// Project reference
450    pub project_ref: Option<String>,
451    /// Commit hash
452    pub commit_hash: Option<String>,
453    /// Development stage
454    pub dev_stage: String,
455    /// Binary name being scanned
456    pub binary_name: String,
457    /// Binary size in bytes
458    pub binary_size: u64,
459    /// Binary SHA-256 hash
460    pub binary_hash: String,
461    /// Expected number of binary segments
462    pub binary_segments_expected: u32,
463    /// Number of binary segments uploaded
464    pub binary_segments_uploaded: u32,
465    /// Scan timeout in minutes
466    pub scan_timeout: Option<u32>,
467    /// Scan duration in minutes (can be fractional)
468    pub scan_duration: Option<f64>,
469    /// Results size (can be fractional)
470    pub results_size: Option<f64>,
471    /// Status message
472    pub message: Option<String>,
473    /// Scan creation time
474    pub created: String,
475    /// Last changed time
476    pub changed: String,
477    /// Modules information
478    pub modules: Vec<serde_json::Value>,
479    /// Selected modules
480    pub selected_modules: Vec<serde_json::Value>,
481    /// Display modules
482    pub display_modules: Vec<serde_json::Value>,
483    /// Display selected modules
484    pub display_selected_modules: Vec<serde_json::Value>,
485    /// Links for navigation (HAL format)
486    #[serde(rename = "_links")]
487    pub links: Option<serde_json::Value>,
488}
489
490/// Pipeline scan results
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct ScanResults {
493    /// Scan metadata
494    pub scan: Scan,
495    /// List of security findings
496    pub findings: Vec<Finding>,
497    /// Findings summary by severity
498    pub summary: FindingsSummary,
499    /// Security standards compliance
500    pub standards: SecurityStandards,
501}
502
503/// Summary of findings by severity
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct FindingsSummary {
506    /// Number of Very High severity findings
507    pub very_high: u32,
508    /// Number of High severity findings
509    pub high: u32,
510    /// Number of Medium severity findings
511    pub medium: u32,
512    /// Number of Low severity findings
513    pub low: u32,
514    /// Number of Very Low severity findings
515    pub very_low: u32,
516    /// Number of Informational findings
517    pub informational: u32,
518    /// Total number of findings
519    pub total: u32,
520}
521
522/// Security standards compliance information
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct SecurityStandards {
525    /// OWASP compliance
526    pub owasp: Option<StandardCompliance>,
527    /// SANS compliance
528    pub sans: Option<StandardCompliance>,
529    /// PCI compliance
530    pub pci: Option<StandardCompliance>,
531    /// CWE categories
532    pub cwe: Option<StandardCompliance>,
533}
534
535/// Compliance information for a security standard
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct StandardCompliance {
538    /// Total number of applicable rules
539    pub total_rules: u32,
540    /// Number of rules violated
541    pub violations: u32,
542    /// Compliance percentage (0-100)
543    pub compliance_score: f64,
544    /// List of violated rule IDs
545    pub violated_rules: Vec<String>,
546}
547
548/// Pipeline Scan API client
549pub struct PipelineApi {
550    client: VeracodeClient,
551    // Cached base URL to avoid repeated string operations
552    base_url: String,
553}
554
555impl PipelineApi {
556    /// Create a new Pipeline API client
557    #[must_use]
558    pub fn new(client: VeracodeClient) -> Self {
559        let base_url = Self::compute_base_url(&client);
560        Self { client, base_url }
561    }
562
563    /// Compute the pipeline scan v1 base URL for file uploads
564    fn compute_base_url(client: &VeracodeClient) -> String {
565        if client.config().base_url.contains("api.veracode.com") {
566            "https://api.veracode.com/pipeline_scan/v1".to_string()
567        } else {
568            // For other environments, use the configured base URL with pipeline_scan/v1 path
569            format!(
570                "{}/pipeline_scan/v1",
571                client.config().base_url.trim_end_matches('/')
572            )
573        }
574    }
575
576    /// Get the cached pipeline scan v1 base URL
577    fn get_pipeline_base_url(&self) -> &str {
578        &self.base_url
579    }
580
581    /// Look up application ID by application name
582    ///
583    /// # Arguments
584    ///
585    /// * `app_name` - The name of the application to search for
586    ///
587    /// # Returns
588    ///
589    /// A `Result` containing the application ID as a string if found
590    ///
591    /// # Errors
592    ///
593    /// Returns an error if the API request fails, the pipeline scan fails,
594    /// or authentication/authorization fails.
595    pub async fn lookup_app_id_by_name(&self, app_name: &str) -> Result<String, PipelineError> {
596        let applications = self.client.search_applications_by_name(app_name).await?;
597
598        match applications.len() {
599            0 => Err(PipelineError::ApplicationNotFound(app_name.to_owned())),
600            1 => Ok(applications
601                .first()
602                .ok_or_else(|| PipelineError::ApplicationNotFound(app_name.to_owned()))?
603                .id
604                .to_string()),
605            _ => {
606                // Print the found applications to help the user
607                error!(
608                    "❌ Found {} applications matching '{}':",
609                    applications.len(),
610                    app_name
611                );
612                for (i, app) in applications.iter().enumerate() {
613                    if let Some(ref profile) = app.profile {
614                        error!(
615                            "   {}. ID: {} - Name: '{}'",
616                            i.saturating_add(1),
617                            app.id,
618                            profile.name
619                        );
620                    } else {
621                        error!(
622                            "   {}. ID: {} - GUID: {}",
623                            i.saturating_add(1),
624                            app.id,
625                            app.guid
626                        );
627                    }
628                }
629                error!(
630                    "💡 Please provide a more specific application name that matches exactly one application."
631                );
632                Err(PipelineError::MultipleApplicationsFound(
633                    app_name.to_string(),
634                ))
635            }
636        }
637    }
638
639    ///
640    /// # Errors
641    ///
642    /// Returns an error if the API request fails, the pipeline scan fails,
643    /// or authentication/authorization fails.
644    /// Create a new pipeline scan with automatic `app_id` lookup
645    ///
646    /// # Arguments
647    ///
648    /// * `request` - Scan creation request with binary details
649    ///
650    /// # Errors
651    ///
652    /// Returns an error if the API request fails, the pipeline scan fails,
653    /// or authentication/authorization fails.
654    /// * `app_name` - Optional application name to look up `app_id` automatically
655    ///
656    /// # Returns
657    ///
658    /// A `Result` containing the scan details if successful
659    ///
660    /// # Errors
661    ///
662    /// Returns an error if the API request fails, the pipeline scan fails,
663    /// or authentication/authorization fails.
664    pub async fn create_scan_with_app_lookup(
665        &self,
666        request: &mut CreateScanRequest,
667        app_name: Option<&str>,
668    ) -> Result<ScanCreationResult, PipelineError> {
669        // Look up app_id if app_name is provided
670        if let Some(name) = app_name
671            && request.app_id.is_none()
672        {
673            let app_id = self.lookup_app_id_by_name(name).await?;
674            request.app_id = Some(app_id.clone());
675            info!("✅ Found application '{name}' with ID: {app_id}");
676        }
677
678        self.create_scan(request).await
679    }
680
681    /// Create a new pipeline scan
682    ///
683    /// # Arguments
684    ///
685    /// * `request` - Scan creation request with binary details
686    ///
687    /// # Returns
688    ///
689    /// A `Result` containing the scan details if successful
690    ///
691    /// # Errors
692    ///
693    /// Returns an error if the API request fails, the pipeline scan fails,
694    /// or authentication/authorization fails.
695    pub async fn create_scan(
696        &self,
697        request: &mut CreateScanRequest,
698    ) -> Result<ScanCreationResult, PipelineError> {
699        // Set plugin version to match Java implementation (from MANIFEST.MF)
700        if request.plugin_version.is_none() {
701            request.plugin_version = Some(PLUGIN_VERSION.to_string());
702        }
703
704        // Set default scan timeout to 30 minutes if not supplied
705        if request.scan_timeout.is_none() {
706            request.scan_timeout = Some(30);
707        }
708
709        // Generate auth header for debugging
710        let endpoint = "/pipeline_scan/v1/scans";
711        let _full_url = format!("{}{}", self.client.config().base_url, endpoint);
712
713        let response = self
714            .client
715            .post_with_response("/pipeline_scan/v1/scans", Some(request))
716            .await?;
717
718        let response_text = response.text().await?;
719
720        // Parse response to extract scan ID and _links (using actual API response structure)
721        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
722            // Extract scan ID
723            let scan_id = json_value
724                .get("scan_id")
725                .and_then(|id| id.as_str())
726                .ok_or_else(|| {
727                    PipelineError::InvalidRequest("Missing scan_id in response".to_string())
728                })?
729                .to_owned();
730
731            // Extract all useful URIs from _links
732            let links = json_value.get("_links");
733
734            let upload_uri = links
735                .and_then(|links| links.get("upload"))
736                .and_then(|upload| upload.get("href"))
737                .and_then(|href| href.as_str())
738                .map(str::to_owned);
739
740            let details_uri = links
741                .and_then(|links| links.get("details"))
742                .and_then(|details| details.get("href"))
743                .and_then(|href| href.as_str())
744                .map(str::to_owned);
745
746            let start_uri = links
747                .and_then(|links| links.get("start"))
748                .and_then(|start| start.get("href"))
749                .and_then(|href| href.as_str())
750                .map(str::to_owned);
751
752            let cancel_uri = links
753                .and_then(|links| links.get("cancel"))
754                .and_then(|cancel| cancel.get("href"))
755                .and_then(|href| href.as_str())
756                .map(str::to_owned);
757
758            // Extract expected segments
759            #[allow(clippy::cast_possible_truncation)]
760            let expected_segments = json_value
761                .get("binary_segments_expected")
762                .and_then(|segments| segments.as_u64())
763                .map(|s| s as u32);
764
765            debug!("✅ Scan creation response parsed:");
766            debug!("   Scan ID: {scan_id}");
767            if let Some(ref uri) = upload_uri {
768                debug!("   Upload URI: {uri}");
769            }
770            if let Some(ref uri) = details_uri {
771                debug!("   Details URI: {uri}");
772            }
773            if let Some(ref uri) = start_uri {
774                debug!("   Start URI: {uri}");
775            }
776            if let Some(ref uri) = cancel_uri {
777                debug!("   Cancel URI: {uri}");
778            }
779            if let Some(segments) = expected_segments {
780                debug!("   Expected segments: {segments}");
781            }
782
783            return Ok(ScanCreationResult {
784                scan_id,
785                upload_uri,
786                details_uri,
787                start_uri,
788                cancel_uri,
789                expected_segments,
790            });
791        }
792
793        Err(PipelineError::InvalidRequest(
794            "Failed to parse scan creation response".to_string(),
795        ))
796    }
797
798    /// Upload binary data for a scan using segmented upload (matching Java implementation)
799    ///
800    /// The Veracode Pipeline Scan API requires files to be uploaded in a predetermined
801    /// number of segments. This method follows the exact Java implementation pattern:
802    /// 1. Gets segment count and upload URI from scan creation response
803    ///
804    /// # Errors
805    ///
806    /// Returns an error if the API request fails, the pipeline scan fails,
807    /// or authentication/authorization fails.
808    /// 2. Calculates segment size as `file_size` / `num_segments`
809    /// 3. Updates URI after each segment upload based on API response
810    ///
811    /// # Arguments
812    ///
813    /// * `initial_upload_uri` - The upload URI from scan creation response
814    /// * `expected_segments` - Number of segments expected by the API
815    /// * `binary_data` - The binary file data to upload
816    /// * `file_name` - Original file name for the binary
817    ///
818    /// # Returns
819    ///
820    /// A `Result` indicating success or failure
821    ///
822    /// # Errors
823    ///
824    /// Returns an error if the API request fails, the pipeline scan fails,
825    /// or authentication/authorization fails.
826    pub async fn upload_binary_segments(
827        &self,
828        initial_upload_uri: &str,
829        expected_segments: i32,
830        binary_data: &[u8],
831        file_name: &str,
832    ) -> Result<(), PipelineError> {
833        let total_size = binary_data.len();
834        #[allow(
835            clippy::cast_possible_truncation,
836            clippy::cast_sign_loss,
837            clippy::cast_precision_loss
838        )]
839        let segment_size = ((total_size as f64) / (expected_segments as f64)).ceil() as usize;
840
841        debug!("📤 Uploading binary in {expected_segments} segments ({total_size} bytes total)");
842        debug!("   Segment size: {segment_size} bytes each");
843
844        let mut current_upload_uri = initial_upload_uri.to_string();
845
846        for segment_num in 0..expected_segments {
847            #[allow(clippy::cast_sign_loss)]
848            let start_idx = (segment_num as usize).saturating_mul(segment_size);
849            let end_idx = std::cmp::min(start_idx.saturating_add(segment_size), total_size);
850            let segment_data = binary_data.get(start_idx..end_idx).ok_or_else(|| {
851                PipelineError::InvalidRequest(format!(
852                    "Invalid segment range: {}..{}",
853                    start_idx, end_idx
854                ))
855            })?;
856
857            debug!(
858                "   Uploading segment {}/{} ({} bytes)...",
859                segment_num.saturating_add(1),
860                expected_segments,
861                segment_data.len()
862            );
863
864            match self
865                .upload_single_segment(&current_upload_uri, segment_data, file_name)
866                .await
867            {
868                Ok(response_text) => {
869                    debug!(
870                        "   ✅ Segment {} uploaded successfully",
871                        segment_num.saturating_add(1)
872                    );
873
874                    // Parse response to get next upload URI (like Java implementation)
875                    if segment_num < expected_segments.saturating_sub(1) {
876                        match self.extract_next_upload_uri(&response_text) {
877                            Some(next_uri) => {
878                                current_upload_uri = next_uri;
879                                debug!("   📍 Next segment URI: {current_upload_uri}");
880                            }
881                            None => {
882                                warn!("   ⚠️  No next URI found in response, using current");
883                            }
884                        }
885                    }
886                }
887                Err(e) => {
888                    error!(
889                        "   ❌ Failed to upload segment {}: {}",
890                        segment_num.saturating_add(1),
891                        e
892                    );
893                    return Err(e);
894                }
895            }
896        }
897
898        debug!("✅ All {expected_segments} segments uploaded successfully");
899        Ok(())
900    }
901
902    /// Simplified upload method for backwards compatibility
903    ///
904    /// # Errors
905    ///
906    /// Returns an error if the API request fails, the pipeline scan fails,
907    /// or authentication/authorization fails.
908    pub async fn upload_binary(
909        &self,
910        scan_id: &str,
911        binary_data: &[u8],
912    ) -> Result<(), PipelineError> {
913        // For backwards compatibility, use a default approach
914        let upload_uri = format!("/pipeline_scan/scans/{scan_id}/segments/1");
915        let expected_segments = 1; // Default to single segment
916        let file_name = "binary.tar.gz";
917
918        self.upload_binary_segments(&upload_uri, expected_segments, binary_data, file_name)
919            .await
920    }
921
922    /// Upload a single segment using the provided URI (exactly matching Java implementation)
923    async fn upload_single_segment(
924        &self,
925        upload_uri: &str,
926        segment_data: &[u8],
927        file_name: &str,
928    ) -> Result<String, PipelineError> {
929        // Get base URL and create full URL using pipeline scan v1 base URL
930        let url = if upload_uri.starts_with("http") {
931            upload_uri.to_string()
932        } else {
933            format!("{}{}", self.get_pipeline_base_url(), upload_uri)
934        };
935
936        // Prepare additional headers for pipeline scan
937        let mut headers = std::collections::HashMap::new();
938        headers.insert("accept", "application/json");
939        headers.insert("PLUGIN-VERSION", PLUGIN_VERSION); // CRITICAL: Java adds this header! (from MANIFEST.MF)
940
941        // Use the client's multipart PUT upload method
942        let response = self
943            .client
944            .upload_file_multipart_put(
945                &url,
946                "file",
947                file_name,
948                segment_data.to_vec(),
949                Some(headers),
950            )
951            .await
952            .map_err(PipelineError::ApiError)?;
953
954        if response.status().is_success() {
955            let response_text = response.text().await?;
956            Ok(response_text)
957        } else {
958            let status = response.status();
959            let error_text = response
960                .text()
961                .await
962                .unwrap_or_else(|_| "Unknown error".to_string());
963            Err(PipelineError::InvalidRequest(format!(
964                "Segment upload failed with status {status}: {error_text}"
965            )))
966        }
967    }
968
969    /// Extract the next upload URI from the API response (matching Java getUriSuffix)
970    fn extract_next_upload_uri(&self, response_text: &str) -> Option<String> {
971        // Parse JSON response to find the next upload URI
972        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) {
973            // Look for _links.upload.href (HAL format)
974            if let Some(links) = json_value.get("_links")
975                && let Some(upload) = links.get("upload")
976                && let Some(href) = upload.get("href")
977            {
978                return href.as_str().map(str::to_owned);
979            }
980
981            // Alternative: look for upload_url field
982            if let Some(upload_url) = json_value.get("upload_url") {
983                return upload_url.as_str().map(str::to_owned);
984            }
985        }
986
987        None
988    }
989
990    /// Start a pipeline scan using start URI from _links
991    ///
992    /// # Arguments
993    ///
994    /// * `start_uri` - The start URI from _links.start.href
995    /// * `config` - Optional scan configuration
996    ///
997    /// # Returns
998    ///
999    /// A `Result` indicating success or failure
1000    ///
1001    /// # Errors
1002    ///
1003    /// Returns an error if the API request fails, the pipeline scan fails,
1004    /// or authentication/authorization fails.
1005    pub async fn start_scan_with_uri(
1006        &self,
1007        start_uri: &str,
1008        config: Option<ScanConfig>,
1009    ) -> Result<(), PipelineError> {
1010        // Create payload with scan_status: STARTED
1011        let mut payload = serde_json::json!({
1012            "scan_status": "STARTED"
1013        });
1014
1015        // Add scan config fields if provided
1016        if let Some(config) = config {
1017            if let Some(timeout) = config.timeout
1018                && let Some(obj) = payload.as_object_mut()
1019            {
1020                obj.insert(
1021                    "timeout".to_string(),
1022                    serde_json::Value::Number(timeout.into()),
1023                );
1024            }
1025            if let Some(include_low_severity) = config.include_low_severity
1026                && let Some(obj) = payload.as_object_mut()
1027            {
1028                obj.insert(
1029                    "include_low_severity".to_string(),
1030                    serde_json::Value::Bool(include_low_severity),
1031                );
1032            }
1033            if let Some(max_findings) = config.max_findings
1034                && let Some(obj) = payload.as_object_mut()
1035            {
1036                obj.insert(
1037                    "max_findings".to_string(),
1038                    serde_json::Value::Number(max_findings.into()),
1039                );
1040            }
1041        }
1042
1043        // Construct full URL with pipeline_scan/v1 base
1044        let url = if start_uri.starts_with("http") {
1045            start_uri.to_string()
1046        } else {
1047            format!("{}{}", self.get_pipeline_base_url(), start_uri)
1048        };
1049
1050        // Generate auth header for PUT request
1051        let auth_header = self
1052            .client
1053            .generate_auth_header("PUT", &url)
1054            .map_err(PipelineError::ApiError)?;
1055
1056        let response = self
1057            .client
1058            .client()
1059            .put(&url)
1060            .header("Authorization", auth_header)
1061            .header("accept", "application/json")
1062            .header("content-type", "application/json")
1063            .json(&payload)
1064            .send()
1065            .await?;
1066
1067        if response.status().is_success() {
1068            Ok(())
1069        } else {
1070            let error_text = response
1071                .text()
1072                .await
1073                .unwrap_or_else(|_| "Unknown error".to_string());
1074            Err(PipelineError::InvalidRequest(format!(
1075                "Failed to start scan: {error_text}"
1076            )))
1077        }
1078    }
1079
1080    /// Start a pipeline scan (fallback method using scan ID)
1081    ///
1082    /// # Arguments
1083    ///
1084    /// * `scan_id` - The scan ID
1085    /// * `config` - Optional scan configuration
1086    ///
1087    /// # Returns
1088    ///
1089    /// A `Result` indicating success or failure
1090    ///
1091    /// # Errors
1092    ///
1093    /// Returns an error if the API request fails, the pipeline scan fails,
1094    /// or authentication/authorization fails.
1095    pub async fn start_scan(
1096        &self,
1097        scan_id: &str,
1098        config: Option<ScanConfig>,
1099    ) -> Result<(), PipelineError> {
1100        let endpoint = format!("/scans/{scan_id}");
1101        let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1102
1103        // Create payload with scan_status: STARTED
1104        let mut payload = serde_json::json!({
1105            "scan_status": "STARTED"
1106        });
1107
1108        // Add scan config fields if provided
1109        if let Some(config) = config {
1110            if let Some(timeout) = config.timeout
1111                && let Some(obj) = payload.as_object_mut()
1112            {
1113                obj.insert(
1114                    "timeout".to_string(),
1115                    serde_json::Value::Number(timeout.into()),
1116                );
1117            }
1118            if let Some(include_low_severity) = config.include_low_severity
1119                && let Some(obj) = payload.as_object_mut()
1120            {
1121                obj.insert(
1122                    "include_low_severity".to_string(),
1123                    serde_json::Value::Bool(include_low_severity),
1124                );
1125            }
1126            if let Some(max_findings) = config.max_findings
1127                && let Some(obj) = payload.as_object_mut()
1128            {
1129                obj.insert(
1130                    "max_findings".to_string(),
1131                    serde_json::Value::Number(max_findings.into()),
1132                );
1133            }
1134        }
1135
1136        // Generate auth header for PUT request
1137        let auth_header = self
1138            .client
1139            .generate_auth_header("PUT", &url)
1140            .map_err(PipelineError::ApiError)?;
1141
1142        let response = self
1143            .client
1144            .client()
1145            .put(&url)
1146            .header("Authorization", auth_header)
1147            .header("accept", "application/json")
1148            .header("content-type", "application/json")
1149            .json(&payload)
1150            .send()
1151            .await?;
1152
1153        if response.status().is_success() {
1154            Ok(())
1155        } else {
1156            let error_text = response
1157                .text()
1158                .await
1159                .unwrap_or_else(|_| "Unknown error".to_string());
1160            Err(PipelineError::InvalidRequest(format!(
1161                "Failed to start scan: {error_text}"
1162            )))
1163        }
1164    }
1165
1166    /// Get pipeline scan details using details URI from _links
1167    ///
1168    /// # Arguments
1169    ///
1170    /// * `details_uri` - The details URI from _links.details.href
1171    ///
1172    /// # Returns
1173    ///
1174    /// A `Result` containing the scan details
1175    ///
1176    /// # Errors
1177    ///
1178    /// Returns an error if the API request fails, the pipeline scan fails,
1179    /// or authentication/authorization fails.
1180    pub async fn get_scan_with_uri(&self, details_uri: &str) -> Result<Scan, PipelineError> {
1181        // Construct full URL with pipeline_scan/v1 base
1182        let url = if details_uri.starts_with("http") {
1183            details_uri.to_string()
1184        } else {
1185            format!("{}{}", self.get_pipeline_base_url(), details_uri)
1186        };
1187
1188        // Generate auth header for GET request
1189        let auth_header = self
1190            .client
1191            .generate_auth_header("GET", &url)
1192            .map_err(PipelineError::ApiError)?;
1193
1194        let response = self
1195            .client
1196            .client()
1197            .get(&url)
1198            .header("Authorization", auth_header)
1199            .header("accept", "application/json")
1200            .send()
1201            .await?;
1202
1203        let response_text = response.text().await?;
1204
1205        serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1206            PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1207        })
1208    }
1209
1210    /// Get pipeline scan details (fallback method using scan ID)
1211    ///
1212    /// # Arguments
1213    ///
1214    /// * `scan_id` - The scan ID
1215    ///
1216    /// # Returns
1217    ///
1218    /// A `Result` containing the scan details
1219    ///
1220    /// # Errors
1221    ///
1222    /// Returns an error if the API request fails, the pipeline scan fails,
1223    /// or authentication/authorization fails.
1224    pub async fn get_scan(&self, scan_id: &str) -> Result<Scan, PipelineError> {
1225        let endpoint = format!("/scans/{scan_id}");
1226        let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1227
1228        // Generate auth header for GET request
1229        let auth_header = self
1230            .client
1231            .generate_auth_header("GET", &url)
1232            .map_err(PipelineError::ApiError)?;
1233
1234        let response = self
1235            .client
1236            .client()
1237            .get(&url)
1238            .header("Authorization", auth_header)
1239            .header("accept", "application/json")
1240            .send()
1241            .await?;
1242
1243        let response_text = response.text().await?;
1244
1245        serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1246            PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1247        })
1248    }
1249
1250    /// Get pipeline scan findings
1251    ///
1252    /// # Arguments
1253    ///
1254    /// * `scan_id` - The scan ID
1255    ///
1256    /// # Returns
1257    ///
1258    /// A `Result` containing the scan findings
1259    ///
1260    /// # HTTP Status Codes
1261    ///
1262    /// * `200` - Findings are ready and returned
1263    ///
1264    /// # Errors
1265    ///
1266    /// Returns an error if the API request fails, the pipeline scan fails,
1267    /// or authentication/authorization fails.
1268    /// * `202` - Scan accepted but findings not yet available (returns `FindingsNotReady` error)
1269    ///
1270    /// # Errors
1271    ///
1272    /// Returns an error if the API request fails, the pipeline scan fails,
1273    /// or authentication/authorization fails.
1274    pub async fn get_findings(&self, scan_id: &str) -> Result<Vec<Finding>, PipelineError> {
1275        let endpoint = format!("/scans/{scan_id}/findings");
1276        let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1277
1278        debug!("🔍 Debug - get_findings() calling: {url}");
1279
1280        // Generate auth header for GET request
1281        let auth_header = self
1282            .client
1283            .generate_auth_header("GET", &url)
1284            .map_err(PipelineError::ApiError)?;
1285
1286        let response = self
1287            .client
1288            .client()
1289            .get(&url)
1290            .header("Authorization", auth_header)
1291            .header("accept", "application/json")
1292            .send()
1293            .await?;
1294
1295        let status = response.status();
1296        let response_text = response.text().await?;
1297
1298        // Debug: Print findings response summary
1299        debug!("🔍 Debug - Findings API Response:");
1300        debug!("   Status: {status}");
1301        debug!("   Response Length: {} bytes", response_text.len());
1302
1303        match status.as_u16() {
1304            200 => {
1305                // Findings are ready - parse the response as FindingsResponse
1306                match serde_json::from_str::<FindingsResponse>(&response_text) {
1307                    Ok(findings_response) => {
1308                        debug!("🔍 Debug - Successfully parsed findings response:");
1309                        debug!("   Scan Status: {}", findings_response.scan_status);
1310                        debug!("   Message: {}", findings_response.message);
1311                        debug!("   Modules: [{}]", findings_response.modules.join(", "));
1312                        debug!("   Findings Count: {}", findings_response.findings.len());
1313                        Ok(findings_response.findings)
1314                    }
1315                    Err(e) => {
1316                        debug!("❌ Debug - Failed to parse FindingsResponse: {e}");
1317                        // Fallback: try to parse as generic JSON and extract findings array
1318                        if let Ok(json_value) =
1319                            serde_json::from_str::<serde_json::Value>(&response_text)
1320                            && let Some(findings_array) =
1321                                json_value.get("findings").and_then(|f| f.as_array())
1322                        {
1323                            debug!("🔍 Debug - Trying fallback parsing of findings array...");
1324                            let findings: Result<Vec<Finding>, _> = findings_array
1325                                .iter()
1326                                .map(|f| serde_json::from_value(f.clone()))
1327                                .collect();
1328                            return findings.map_err(|e| {
1329                                PipelineError::InvalidRequest(format!(
1330                                    "Failed to parse findings array: {e}"
1331                                ))
1332                            });
1333                        }
1334                        Err(PipelineError::InvalidRequest(format!(
1335                            "Failed to parse findings response: {e}"
1336                        )))
1337                    }
1338                }
1339            }
1340            202 => {
1341                // Findings not ready yet
1342                Err(PipelineError::FindingsNotReady)
1343            }
1344            _ => {
1345                // Other error codes
1346                Err(PipelineError::InvalidRequest(format!(
1347                    "Failed to get findings - HTTP {status}: {response_text}"
1348                )))
1349            }
1350        }
1351    }
1352
1353    /// Get complete scan results (scan details + findings + summary)
1354    ///
1355    /// # Arguments
1356    ///
1357    /// * `scan_id` - The scan ID
1358    ///
1359    /// # Returns
1360    ///
1361    /// A `Result` containing the complete scan results
1362    ///
1363    /// # Note
1364    ///
1365    /// This method will return `FindingsNotReady` error if the scan findings are not yet available.
1366    /// Use `get_scan()` to check scan status before calling this method.
1367    ///
1368    /// # Errors
1369    ///
1370    /// Returns an error if the API request fails, the pipeline scan fails,
1371    /// or authentication/authorization fails.
1372    pub async fn get_results(&self, scan_id: &str) -> Result<ScanResults, PipelineError> {
1373        debug!("🔍 Debug - get_results() getting scan details for: {scan_id}");
1374        let scan = self.get_scan(scan_id).await?;
1375        debug!("🔍 Debug - get_results() scan status: {}", scan.scan_status);
1376        debug!("🔍 Debug - get_results() calling get_findings() for: {scan_id}");
1377        let findings = self.get_findings(scan_id).await?;
1378
1379        // Calculate summary
1380        let summary = self.calculate_summary(&findings);
1381
1382        // Generate standards compliance (placeholder - would need actual implementation)
1383        let standards = SecurityStandards {
1384            owasp: None,
1385            sans: None,
1386            pci: None,
1387            cwe: None,
1388        };
1389
1390        Ok(ScanResults {
1391            scan,
1392            findings,
1393            summary,
1394            standards,
1395        })
1396    }
1397
1398    /// Cancel a running pipeline scan
1399    ///
1400    /// # Arguments
1401    ///
1402    /// * `scan_id` - The scan ID
1403    ///
1404    /// # Returns
1405    ///
1406    /// A `Result` indicating success or failure
1407    ///
1408    /// # Errors
1409    ///
1410    /// Returns an error if the API request fails, the pipeline scan fails,
1411    /// or authentication/authorization fails.
1412    pub async fn cancel_scan(&self, scan_id: &str) -> Result<(), PipelineError> {
1413        let endpoint = format!("/scans/{scan_id}/cancel");
1414
1415        let response = self.client.delete_with_response(&endpoint).await?;
1416
1417        if response.status().is_success() {
1418            Ok(())
1419        } else {
1420            let error_text = response
1421                .text()
1422                .await
1423                .unwrap_or_else(|_| "Unknown error".to_string());
1424            Err(PipelineError::InvalidRequest(format!(
1425                "Failed to cancel scan: {error_text}"
1426            )))
1427        }
1428    }
1429
1430    /// Wait for scan to complete with polling
1431    ///
1432    /// # Arguments
1433    ///
1434    /// * `scan_id` - The scan ID
1435    /// * `timeout_minutes` - Maximum time to wait (default: 60 minutes)
1436    /// * `poll_interval_seconds` - Polling interval (default: 10 seconds)
1437    ///
1438    /// # Returns
1439    ///
1440    /// A `Result` containing the completed scan or timeout error
1441    ///
1442    /// # Errors
1443    ///
1444    /// Returns an error if the API request fails, the pipeline scan fails,
1445    /// or authentication/authorization fails.
1446    pub async fn wait_for_completion(
1447        &self,
1448        scan_id: &str,
1449        timeout_minutes: Option<u32>,
1450        poll_interval_seconds: Option<u32>,
1451    ) -> Result<Scan, PipelineError> {
1452        let timeout = timeout_minutes.unwrap_or(60);
1453        let interval = poll_interval_seconds.unwrap_or(10);
1454        let max_polls = timeout
1455            .saturating_mul(60)
1456            .checked_div(interval)
1457            .unwrap_or(u32::MAX);
1458
1459        for _ in 0..max_polls {
1460            let scan = self.get_scan(scan_id).await?;
1461
1462            // Check if scan is completed based on status
1463            if scan.scan_status.is_successful() || scan.scan_status.is_failed() {
1464                return Ok(scan);
1465            }
1466
1467            // Wait before next poll
1468            tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64)).await;
1469        }
1470
1471        Err(PipelineError::ScanTimeout)
1472    }
1473
1474    /// Calculate findings summary from a list of findings
1475    fn calculate_summary(&self, findings: &[Finding]) -> FindingsSummary {
1476        #[allow(clippy::cast_possible_truncation)]
1477        let mut summary = FindingsSummary {
1478            very_high: 0,
1479            high: 0,
1480            medium: 0,
1481            low: 0,
1482            very_low: 0,
1483            informational: 0,
1484            total: findings.len() as u32,
1485        };
1486
1487        for finding in findings {
1488            match finding.severity {
1489                5 => summary.very_high = summary.very_high.saturating_add(1),
1490                4 => summary.high = summary.high.saturating_add(1),
1491                3 => summary.medium = summary.medium.saturating_add(1),
1492                2 => summary.low = summary.low.saturating_add(1),
1493                1 => summary.very_low = summary.very_low.saturating_add(1),
1494                0 => summary.informational = summary.informational.saturating_add(1),
1495                _ => {} // Unknown severity
1496            }
1497        }
1498
1499        summary
1500    }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506
1507    #[test]
1508    fn test_strip_html_tags_no_tags() {
1509        let input = "This is plain text";
1510        let result = strip_html_tags(input);
1511        assert_eq!(result, "This is plain text");
1512    }
1513
1514    #[test]
1515    fn test_strip_html_tags_simple() {
1516        let input = "This is <b>bold</b> text";
1517        let result = strip_html_tags(input);
1518        assert_eq!(result, "This is bold text");
1519    }
1520
1521    #[test]
1522    fn test_strip_html_tags_removes_script_content() {
1523        // SECURITY: Script content must be removed, not just tags
1524        let input = "<script>alert('XSS')</script>This is safe";
1525        let result = strip_html_tags(input);
1526        assert_eq!(result, "This is safe");
1527    }
1528
1529    #[test]
1530    fn test_strip_html_tags_removes_script_with_attributes() {
1531        let input = "<script type='text/javascript'>alert('XSS')</script>Safe text";
1532        let result = strip_html_tags(input);
1533        assert_eq!(result, "Safe text");
1534    }
1535
1536    #[test]
1537    fn test_strip_html_tags_removes_style_content() {
1538        let input = "<style>body { color: red; }</style>Visible text";
1539        let result = strip_html_tags(input);
1540        assert_eq!(result, "Visible text");
1541    }
1542
1543    #[test]
1544    fn test_strip_html_tags_multiple_scripts() {
1545        let input = "<script>evil1()</script>Good<script>evil2()</script>Text";
1546        let result = strip_html_tags(input);
1547        assert_eq!(result, "Good Text");
1548    }
1549
1550    #[test]
1551    fn test_strip_html_tags_nested_tags() {
1552        let input = "<div><p>Paragraph <b>bold</b> text</p></div>";
1553        let result = strip_html_tags(input);
1554        assert_eq!(result, "Paragraph bold text");
1555    }
1556
1557    #[test]
1558    fn test_strip_html_tags_mixed_content() {
1559        let input = "Before<script>bad()</script>Middle<b>bold</b>After";
1560        let result = strip_html_tags(input);
1561        assert_eq!(result, "Before Middle bold After");
1562    }
1563
1564    #[test]
1565    fn test_strip_html_tags_case_insensitive_script() {
1566        let input = "<SCRIPT>evil()</SCRIPT>Safe";
1567        let result = strip_html_tags(input);
1568        assert_eq!(result, "Safe");
1569    }
1570
1571    #[test]
1572    fn test_strip_html_tags_case_insensitive_style() {
1573        let input = "<STYLE>css</STYLE>Text";
1574        let result = strip_html_tags(input);
1575        assert_eq!(result, "Text");
1576    }
1577
1578    #[test]
1579    fn test_strip_html_tags_whitespace_cleanup() {
1580        // When HTML tags are present, extra whitespace is collapsed
1581        let input = "Text   <b>with</b>    extra     <i>spaces</i>";
1582        let result = strip_html_tags(input);
1583        assert_eq!(result, "Text with extra spaces");
1584    }
1585
1586    #[test]
1587    fn test_strip_html_tags_no_html_preserves_whitespace() {
1588        // When no HTML tags, original whitespace is preserved (Cow::Borrowed)
1589        let input = "Text   with    extra     spaces";
1590        let result = strip_html_tags(input);
1591        assert_eq!(result, "Text   with    extra     spaces");
1592    }
1593
1594    #[test]
1595    fn test_strip_html_tags_preserves_normal_content_order() {
1596        let input = "First <p>Second</p> Third <b>Fourth</b> Fifth";
1597        let result = strip_html_tags(input);
1598        assert_eq!(result, "First Second Third Fourth Fifth");
1599    }
1600}