1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::{VeracodeClient, VeracodeError};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ApiErrorResponse {
10 #[serde(rename = "_embedded")]
11 pub embedded: Option<ApiErrorEmbedded>,
12 pub fallback_type: Option<String>,
13 pub full_type: Option<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ApiErrorEmbedded {
19 pub api_errors: Vec<ApiError>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ApiError {
25 pub id: String,
26 pub code: String,
27 pub title: String,
28 pub status: String,
29 pub source: Option<ApiErrorSource>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ApiErrorSource {
35 pub pointer: String,
36 pub parameter: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Sandbox {
42 pub id: Option<u64>,
43 pub guid: String,
44 pub name: String,
45 pub description: Option<String>,
46 pub created: DateTime<Utc>,
47 pub modified: DateTime<Utc>,
48 pub auto_recreate: bool,
49 pub custom_fields: Option<HashMap<String, String>>,
50 pub owner: Option<String>,
51 pub owner_username: Option<String>,
52 pub organization_id: Option<u64>,
53 pub application_guid: Option<String>,
54 pub team_identifiers: Option<Vec<String>>,
55 pub scan_url: Option<String>,
56 pub last_scan_date: Option<DateTime<Utc>>,
57 pub status: Option<String>,
58 #[serde(rename = "_links")]
59 pub links: Option<serde_json::Value>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CreateSandboxRequest {
65 pub name: String,
66 pub description: Option<String>,
67 pub auto_recreate: Option<bool>,
68 pub custom_fields: Option<HashMap<String, String>>,
69 pub team_identifiers: Option<Vec<String>>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct UpdateSandboxRequest {
75 pub name: Option<String>,
76 pub description: Option<String>,
77 pub auto_recreate: Option<bool>,
78 pub custom_fields: Option<HashMap<String, String>>,
79 pub team_identifiers: Option<Vec<String>>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SandboxListResponse {
85 #[serde(rename = "_embedded")]
86 pub embedded: Option<SandboxEmbedded>,
87 pub page: Option<PageInfo>,
88 pub total: Option<u64>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct SandboxEmbedded {
94 pub sandboxes: Vec<Sandbox>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PageInfo {
100 pub size: u64,
101 pub number: u64,
102 pub total_elements: u64,
103 pub total_pages: u64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SandboxScan {
109 pub scan_id: u64,
110 pub scan_type: String,
111 pub status: String,
112 pub created: DateTime<Utc>,
113 pub modified: DateTime<Utc>,
114 pub scan_url: Option<String>,
115 pub results_ready: bool,
116 pub engine_version: Option<String>,
117}
118
119#[derive(Debug, Clone, Default)]
121pub struct SandboxListParams {
122 pub name: Option<String>,
123 pub owner: Option<String>,
124 pub team: Option<String>,
125 pub page: Option<u64>,
126 pub size: Option<u64>,
127 pub modified_after: Option<DateTime<Utc>>,
128 pub modified_before: Option<DateTime<Utc>>,
129}
130
131impl SandboxListParams {
132 pub fn to_query_params(&self) -> Vec<(String, String)> {
134 let mut params = Vec::new();
135
136 if let Some(name) = &self.name {
137 params.push(("name".to_string(), name.clone()));
138 }
139 if let Some(owner) = &self.owner {
140 params.push(("owner".to_string(), owner.clone()));
141 }
142 if let Some(team) = &self.team {
143 params.push(("team".to_string(), team.clone()));
144 }
145 if let Some(page) = self.page {
146 params.push(("page".to_string(), page.to_string()));
147 }
148 if let Some(size) = self.size {
149 params.push(("size".to_string(), size.to_string()));
150 }
151 if let Some(modified_after) = self.modified_after {
152 params.push(("modified_after".to_string(), modified_after.to_rfc3339()));
153 }
154 if let Some(modified_before) = self.modified_before {
155 params.push(("modified_before".to_string(), modified_before.to_rfc3339()));
156 }
157
158 params
159 }
160}
161
162#[derive(Debug)]
164pub enum SandboxError {
165 Api(VeracodeError),
167 NotFound,
169 InvalidInput(String),
171 LimitExceeded,
173 OperationNotAllowed(String),
175 AlreadyExists(String),
177}
178
179impl std::fmt::Display for SandboxError {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 match self {
182 SandboxError::Api(err) => write!(f, "API error: {err}"),
183 SandboxError::NotFound => write!(f, "Sandbox not found"),
184 SandboxError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
185 SandboxError::LimitExceeded => write!(f, "Maximum number of sandboxes reached"),
186 SandboxError::OperationNotAllowed(msg) => write!(f, "Operation not allowed: {msg}"),
187 SandboxError::AlreadyExists(msg) => write!(f, "Sandbox already exists: {msg}"),
188 }
189 }
190}
191
192impl std::error::Error for SandboxError {}
193
194impl From<VeracodeError> for SandboxError {
195 fn from(err: VeracodeError) -> Self {
196 SandboxError::Api(err)
197 }
198}
199
200impl From<reqwest::Error> for SandboxError {
201 fn from(err: reqwest::Error) -> Self {
202 SandboxError::Api(VeracodeError::Http(err))
203 }
204}
205
206impl From<serde_json::Error> for SandboxError {
207 fn from(err: serde_json::Error) -> Self {
208 SandboxError::Api(VeracodeError::Serialization(err))
209 }
210}
211
212pub struct SandboxApi<'a> {
214 client: &'a VeracodeClient,
215}
216
217impl<'a> SandboxApi<'a> {
218 pub fn new(client: &'a VeracodeClient) -> Self {
220 Self { client }
221 }
222
223 pub async fn list_sandboxes(
234 &self,
235 application_guid: &str,
236 params: Option<SandboxListParams>,
237 ) -> Result<Vec<Sandbox>, SandboxError> {
238 let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
239
240 let query_params = params.map(|p| p.to_query_params());
241
242 let response = self.client.get(&endpoint, query_params.as_deref()).await?;
243
244 let status = response.status().as_u16();
245 match status {
246 200 => {
247 let sandbox_response: SandboxListResponse = response.json().await?;
248 Ok(sandbox_response
249 .embedded
250 .map(|e| e.sandboxes)
251 .unwrap_or_default())
252 }
253 404 => Err(SandboxError::NotFound),
254 _ => {
255 let error_text = response.text().await.unwrap_or_default();
256 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
257 "HTTP {status}: {error_text}"
258 ))))
259 }
260 }
261 }
262
263 pub async fn get_sandbox(
274 &self,
275 application_guid: &str,
276 sandbox_guid: &str,
277 ) -> Result<Sandbox, SandboxError> {
278 let endpoint =
279 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
280
281 let response = self.client.get(&endpoint, None).await?;
282
283 let status = response.status().as_u16();
284 match status {
285 200 => {
286 let sandbox: Sandbox = response.json().await?;
287 Ok(sandbox)
288 }
289 404 => Err(SandboxError::NotFound),
290 _ => {
291 let error_text = response.text().await.unwrap_or_default();
292 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
293 "HTTP {status}: {error_text}"
294 ))))
295 }
296 }
297 }
298
299 pub async fn create_sandbox(
310 &self,
311 application_guid: &str,
312 request: CreateSandboxRequest,
313 ) -> Result<Sandbox, SandboxError> {
314 Self::validate_create_request(&request)?;
316
317 let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
318
319 let response = self.client.post(&endpoint, Some(&request)).await?;
320
321 let status = response.status().as_u16();
322 match status {
323 200 | 201 => {
324 let sandbox: Sandbox = response.json().await?;
325 Ok(sandbox)
326 }
327 400 => {
328 let error_text = response.text().await.unwrap_or_default();
329
330 if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&error_text) {
332 if let Some(embedded) = error_response.embedded {
333 for api_error in embedded.api_errors {
334 if api_error.title.contains("already exists") {
335 return Err(SandboxError::AlreadyExists(api_error.title));
336 }
337 if api_error.title.contains("limit")
338 || api_error.title.contains("maximum")
339 {
340 return Err(SandboxError::LimitExceeded);
341 }
342 if api_error.title.contains("Json Parse Error")
343 || api_error.title.contains("Cannot deserialize")
344 {
345 return Err(SandboxError::InvalidInput(format!(
346 "JSON parsing error: {}",
347 api_error.title
348 )));
349 }
350 }
351 }
352 }
353
354 if error_text.contains("limit") || error_text.contains("maximum") {
356 Err(SandboxError::LimitExceeded)
357 } else if error_text.contains("already exists") {
358 Err(SandboxError::AlreadyExists(error_text))
359 } else {
360 Err(SandboxError::InvalidInput(error_text))
361 }
362 }
363 404 => Err(SandboxError::NotFound),
364 _ => {
365 let error_text = response.text().await.unwrap_or_default();
366 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
367 "HTTP {status}: {error_text}"
368 ))))
369 }
370 }
371 }
372
373 pub async fn update_sandbox(
385 &self,
386 application_guid: &str,
387 sandbox_guid: &str,
388 request: UpdateSandboxRequest,
389 ) -> Result<Sandbox, SandboxError> {
390 Self::validate_update_request(&request)?;
392
393 let endpoint =
394 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
395
396 let response = self.client.put(&endpoint, Some(&request)).await?;
397
398 let status = response.status().as_u16();
399 match status {
400 200 => {
401 let sandbox: Sandbox = response.json().await?;
402 Ok(sandbox)
403 }
404 400 => {
405 let error_text = response.text().await.unwrap_or_default();
406 Err(SandboxError::InvalidInput(error_text))
407 }
408 404 => Err(SandboxError::NotFound),
409 _ => {
410 let error_text = response.text().await.unwrap_or_default();
411 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
412 "HTTP {status}: {error_text}"
413 ))))
414 }
415 }
416 }
417
418 pub async fn delete_sandbox(
429 &self,
430 application_guid: &str,
431 sandbox_guid: &str,
432 ) -> Result<(), SandboxError> {
433 let endpoint =
434 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
435
436 let response = self.client.delete(&endpoint).await?;
437
438 let status = response.status().as_u16();
439 match status {
440 204 => Ok(()),
441 404 => Err(SandboxError::NotFound),
442 409 => {
443 let error_text = response.text().await.unwrap_or_default();
444 Err(SandboxError::OperationNotAllowed(error_text))
445 }
446 _ => {
447 let error_text = response.text().await.unwrap_or_default();
448 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
449 "HTTP {status}: {error_text}"
450 ))))
451 }
452 }
453 }
454
455 pub async fn promote_sandbox_scan(
467 &self,
468 application_guid: &str,
469 sandbox_guid: &str,
470 delete_on_promote: bool,
471 ) -> Result<(), SandboxError> {
472 let endpoint = if delete_on_promote {
473 format!(
474 "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
475 )
476 } else {
477 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote")
478 };
479
480 let response = self.client.post(&endpoint, None::<&()>).await?;
481
482 let status = response.status().as_u16();
483 match status {
484 200 | 204 => Ok(()),
485 404 => Err(SandboxError::NotFound),
486 409 => {
487 let error_text = response.text().await.unwrap_or_default();
488 Err(SandboxError::OperationNotAllowed(error_text))
489 }
490 _ => {
491 let error_text = response.text().await.unwrap_or_default();
492 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
493 "HTTP {status}: {error_text}"
494 ))))
495 }
496 }
497 }
498
499 pub async fn get_sandbox_scans(
510 &self,
511 application_guid: &str,
512 sandbox_guid: &str,
513 ) -> Result<Vec<SandboxScan>, SandboxError> {
514 let endpoint =
515 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans");
516
517 let response = self.client.get(&endpoint, None).await?;
518
519 let status = response.status().as_u16();
520 match status {
521 200 => {
522 let scans: Vec<SandboxScan> = response.json().await?;
523 Ok(scans)
524 }
525 404 => Err(SandboxError::NotFound),
526 _ => {
527 let error_text = response.text().await.unwrap_or_default();
528 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
529 "HTTP {status}: {error_text}"
530 ))))
531 }
532 }
533 }
534
535 pub async fn sandbox_exists(
546 &self,
547 application_guid: &str,
548 sandbox_guid: &str,
549 ) -> Result<bool, SandboxError> {
550 match self.get_sandbox(application_guid, sandbox_guid).await {
551 Ok(_) => Ok(true),
552 Err(SandboxError::NotFound) => Ok(false),
553 Err(e) => Err(e),
554 }
555 }
556
557 pub async fn get_sandbox_by_name(
568 &self,
569 application_guid: &str,
570 name: &str,
571 ) -> Result<Option<Sandbox>, SandboxError> {
572 let params = SandboxListParams {
573 name: Some(name.to_string()),
574 ..Default::default()
575 };
576
577 let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
578 Ok(sandboxes.into_iter().find(|s| s.name == name))
579 }
580
581 fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
583 if request.name.is_empty() {
584 return Err(SandboxError::InvalidInput(
585 "Sandbox name cannot be empty".to_string(),
586 ));
587 }
588 if request.name.len() > 256 {
589 return Err(SandboxError::InvalidInput(
590 "Sandbox name too long (max 256 characters)".to_string(),
591 ));
592 }
593
594 if request.name.contains(['<', '>', '"', '&', '\'']) {
596 return Err(SandboxError::InvalidInput(
597 "Sandbox name contains invalid characters".to_string(),
598 ));
599 }
600
601 Ok(())
602 }
603
604 fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
606 if let Some(name) = &request.name {
607 if name.is_empty() {
608 return Err(SandboxError::InvalidInput(
609 "Sandbox name cannot be empty".to_string(),
610 ));
611 }
612 if name.len() > 256 {
613 return Err(SandboxError::InvalidInput(
614 "Sandbox name too long (max 256 characters)".to_string(),
615 ));
616 }
617
618 if name.contains(['<', '>', '"', '&', '\'']) {
620 return Err(SandboxError::InvalidInput(
621 "Sandbox name contains invalid characters".to_string(),
622 ));
623 }
624 }
625
626 Ok(())
627 }
628}
629
630impl<'a> SandboxApi<'a> {
632 pub async fn create_simple_sandbox(
643 &self,
644 application_guid: &str,
645 name: &str,
646 ) -> Result<Sandbox, SandboxError> {
647 let request = CreateSandboxRequest {
648 name: name.to_string(),
649 description: None,
650 auto_recreate: None,
651 custom_fields: None,
652 team_identifiers: None,
653 };
654
655 self.create_sandbox(application_guid, request).await
656 }
657
658 pub async fn create_auto_recreate_sandbox(
670 &self,
671 application_guid: &str,
672 name: &str,
673 description: Option<String>,
674 ) -> Result<Sandbox, SandboxError> {
675 let request = CreateSandboxRequest {
676 name: name.to_string(),
677 description,
678 auto_recreate: Some(true),
679 custom_fields: None,
680 team_identifiers: None,
681 };
682
683 self.create_sandbox(application_guid, request).await
684 }
685
686 pub async fn update_sandbox_name(
698 &self,
699 application_guid: &str,
700 sandbox_guid: &str,
701 new_name: &str,
702 ) -> Result<Sandbox, SandboxError> {
703 let request = UpdateSandboxRequest {
704 name: Some(new_name.to_string()),
705 description: None,
706 auto_recreate: None,
707 custom_fields: None,
708 team_identifiers: None,
709 };
710
711 self.update_sandbox(application_guid, sandbox_guid, request)
712 .await
713 }
714
715 pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
725 let sandboxes = self.list_sandboxes(application_guid, None).await?;
726 Ok(sandboxes.len())
727 }
728
729 pub async fn get_sandbox_id_from_guid(
742 &self,
743 application_guid: &str,
744 sandbox_guid: &str,
745 ) -> Result<String, SandboxError> {
746 let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
747 match sandbox.id {
748 Some(id) => Ok(id.to_string()),
749 None => Err(SandboxError::InvalidInput(
750 "Sandbox has no numeric ID".to_string(),
751 )),
752 }
753 }
754
755 pub async fn create_sandbox_if_not_exists(
770 &self,
771 application_guid: &str,
772 name: &str,
773 description: Option<String>,
774 ) -> Result<Sandbox, SandboxError> {
775 if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
777 return Ok(existing_sandbox);
778 }
779
780 let create_request = CreateSandboxRequest {
782 name: name.to_string(),
783 description,
784 auto_recreate: Some(true), custom_fields: None,
786 team_identifiers: None,
787 };
788
789 self.create_sandbox(application_guid, create_request).await
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[test]
798 fn test_validate_create_request() {
799 let valid_request = CreateSandboxRequest {
801 name: "valid-sandbox".to_string(),
802 description: None,
803 auto_recreate: None,
804 custom_fields: None,
805 team_identifiers: None,
806 };
807 assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
808
809 let empty_name_request = CreateSandboxRequest {
811 name: String::new(),
812 description: None,
813 auto_recreate: None,
814 custom_fields: None,
815 team_identifiers: None,
816 };
817 assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
818
819 let long_name_request = CreateSandboxRequest {
821 name: "x".repeat(300),
822 description: None,
823 auto_recreate: None,
824 custom_fields: None,
825 team_identifiers: None,
826 };
827 assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
828
829 let invalid_char_request = CreateSandboxRequest {
831 name: "invalid<name>".to_string(),
832 description: None,
833 auto_recreate: None,
834 custom_fields: None,
835 team_identifiers: None,
836 };
837 assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
838 }
839
840 #[test]
841 fn test_sandbox_list_params_to_query() {
842 let params = SandboxListParams {
843 name: Some("test".to_string()),
844 page: Some(1),
845 size: Some(10),
846 ..Default::default()
847 };
848
849 let query_params = params.to_query_params();
850 assert_eq!(query_params.len(), 3);
851 assert!(query_params.contains(&("name".to_string(), "test".to_string())));
852 assert!(query_params.contains(&("page".to_string(), "1".to_string())));
853 assert!(query_params.contains(&("size".to_string(), "10".to_string())));
854 }
855
856 #[test]
857 fn test_sandbox_error_display() {
858 let error = SandboxError::NotFound;
859 assert_eq!(error.to_string(), "Sandbox not found");
860
861 let error = SandboxError::InvalidInput("test".to_string());
862 assert_eq!(error.to_string(), "Invalid input: test");
863
864 let error = SandboxError::LimitExceeded;
865 assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
866 }
867}