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