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