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 #[serde(skip_serializing_if = "Option::is_none")]
67 pub description: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub auto_recreate: Option<bool>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub custom_fields: Option<HashMap<String, String>>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub team_identifiers: Option<Vec<String>>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct UpdateSandboxRequest {
79 pub name: Option<String>,
80 pub description: Option<String>,
81 pub auto_recreate: Option<bool>,
82 pub custom_fields: Option<HashMap<String, String>>,
83 pub team_identifiers: Option<Vec<String>>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SandboxListResponse {
89 #[serde(rename = "_embedded")]
90 pub embedded: Option<SandboxEmbedded>,
91 pub page: Option<PageInfo>,
92 pub total: Option<u64>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SandboxEmbedded {
98 pub sandboxes: Vec<Sandbox>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PageInfo {
104 pub size: u64,
105 pub number: u64,
106 pub total_elements: u64,
107 pub total_pages: u64,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SandboxScan {
113 pub scan_id: u64,
114 pub scan_type: String,
115 pub status: String,
116 pub created: DateTime<Utc>,
117 pub modified: DateTime<Utc>,
118 pub scan_url: Option<String>,
119 pub results_ready: bool,
120 pub engine_version: Option<String>,
121}
122
123#[derive(Debug, Clone, Default)]
125pub struct SandboxListParams {
126 pub name: Option<String>,
127 pub owner: Option<String>,
128 pub team: Option<String>,
129 pub page: Option<u64>,
130 pub size: Option<u64>,
131 pub modified_after: Option<DateTime<Utc>>,
132 pub modified_before: Option<DateTime<Utc>>,
133}
134
135impl SandboxListParams {
136 #[must_use]
138 pub fn to_query_params(&self) -> Vec<(String, String)> {
139 Vec::from(self) }
141}
142
143impl From<&SandboxListParams> for Vec<(String, String)> {
145 fn from(query: &SandboxListParams) -> Self {
146 let mut params = Vec::new();
147
148 if let Some(ref name) = query.name {
149 params.push(("name".to_string(), name.clone())); }
151 if let Some(ref owner) = query.owner {
152 params.push(("owner".to_string(), owner.clone()));
153 }
154 if let Some(ref team) = query.team {
155 params.push(("team".to_string(), team.clone()));
156 }
157 if let Some(page) = query.page {
158 params.push(("page".to_string(), page.to_string()));
159 }
160 if let Some(size) = query.size {
161 params.push(("size".to_string(), size.to_string()));
162 }
163 if let Some(modified_after) = query.modified_after {
164 params.push(("modified_after".to_string(), modified_after.to_rfc3339()));
165 }
166 if let Some(modified_before) = query.modified_before {
167 params.push(("modified_before".to_string(), modified_before.to_rfc3339()));
168 }
169
170 params
171 }
172}
173
174impl From<SandboxListParams> for Vec<(String, String)> {
175 fn from(query: SandboxListParams) -> Self {
176 let mut params = Vec::new();
177
178 if let Some(name) = query.name {
179 params.push(("name".to_string(), name)); }
181 if let Some(owner) = query.owner {
182 params.push(("owner".to_string(), owner)); }
184 if let Some(team) = query.team {
185 params.push(("team".to_string(), team)); }
187 if let Some(page) = query.page {
188 params.push(("page".to_string(), page.to_string()));
189 }
190 if let Some(size) = query.size {
191 params.push(("size".to_string(), size.to_string()));
192 }
193 if let Some(modified_after) = query.modified_after {
194 params.push(("modified_after".to_string(), modified_after.to_rfc3339()));
195 }
196 if let Some(modified_before) = query.modified_before {
197 params.push(("modified_before".to_string(), modified_before.to_rfc3339()));
198 }
199
200 params
201 }
202}
203
204#[derive(Debug)]
206pub enum SandboxError {
207 Api(VeracodeError),
209 NotFound,
211 InvalidInput(String),
213 LimitExceeded,
215 OperationNotAllowed(String),
217 AlreadyExists(String),
219}
220
221impl std::fmt::Display for SandboxError {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 match self {
224 SandboxError::Api(err) => write!(f, "API error: {err}"),
225 SandboxError::NotFound => write!(f, "Sandbox not found"),
226 SandboxError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
227 SandboxError::LimitExceeded => write!(f, "Maximum number of sandboxes reached"),
228 SandboxError::OperationNotAllowed(msg) => write!(f, "Operation not allowed: {msg}"),
229 SandboxError::AlreadyExists(msg) => write!(f, "Sandbox already exists: {msg}"),
230 }
231 }
232}
233
234impl std::error::Error for SandboxError {}
235
236impl From<VeracodeError> for SandboxError {
237 fn from(err: VeracodeError) -> Self {
238 SandboxError::Api(err)
239 }
240}
241
242impl From<reqwest::Error> for SandboxError {
243 fn from(err: reqwest::Error) -> Self {
244 SandboxError::Api(VeracodeError::Http(err))
245 }
246}
247
248impl From<serde_json::Error> for SandboxError {
249 fn from(err: serde_json::Error) -> Self {
250 SandboxError::Api(VeracodeError::Serialization(err))
251 }
252}
253
254pub struct SandboxApi<'a> {
256 client: &'a VeracodeClient,
257}
258
259impl<'a> SandboxApi<'a> {
260 #[must_use]
262 pub fn new(client: &'a VeracodeClient) -> Self {
263 Self { client }
264 }
265
266 pub async fn list_sandboxes(
277 &self,
278 application_guid: &str,
279 params: Option<SandboxListParams>,
280 ) -> Result<Vec<Sandbox>, SandboxError> {
281 let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
282
283 let query_params = params.as_ref().map(Vec::from);
284
285 let response = self.client.get(&endpoint, query_params.as_deref()).await?;
286
287 let status = response.status().as_u16();
288 match status {
289 200 => {
290 let sandbox_response: SandboxListResponse = response.json().await?;
291 Ok(sandbox_response
292 .embedded
293 .map(|e| e.sandboxes)
294 .unwrap_or_default())
295 }
296 404 => Err(SandboxError::NotFound),
297 _ => {
298 let error_text = response.text().await.unwrap_or_default();
299 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
300 "HTTP {status}: {error_text}"
301 ))))
302 }
303 }
304 }
305
306 pub async fn get_sandbox(
317 &self,
318 application_guid: &str,
319 sandbox_guid: &str,
320 ) -> Result<Sandbox, SandboxError> {
321 let endpoint =
322 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
323
324 let response = self.client.get(&endpoint, None).await?;
325
326 let status = response.status().as_u16();
327 match status {
328 200 => {
329 let sandbox: Sandbox = response.json().await?;
330 Ok(sandbox)
331 }
332 404 => Err(SandboxError::NotFound),
333 _ => {
334 let error_text = response.text().await.unwrap_or_default();
335 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
336 "HTTP {status}: {error_text}"
337 ))))
338 }
339 }
340 }
341
342 pub async fn create_sandbox(
353 &self,
354 application_guid: &str,
355 request: CreateSandboxRequest,
356 ) -> Result<Sandbox, SandboxError> {
357 Self::validate_create_request(&request)?;
359
360 let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
361
362 let response = self.client.post(&endpoint, Some(&request)).await?;
363
364 let status = response.status().as_u16();
365 match status {
366 200 | 201 => {
367 let sandbox: Sandbox = response.json().await?;
368 Ok(sandbox)
369 }
370 400 => {
371 let error_text = response.text().await.unwrap_or_default();
372
373 if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&error_text)
375 && let Some(embedded) = error_response.embedded
376 {
377 for api_error in embedded.api_errors {
378 if api_error.title.contains("already exists") {
379 return Err(SandboxError::AlreadyExists(api_error.title));
380 }
381 if api_error.title.contains("limit") || api_error.title.contains("maximum")
382 {
383 return Err(SandboxError::LimitExceeded);
384 }
385 if api_error.title.contains("Json Parse Error")
386 || api_error.title.contains("Cannot deserialize")
387 {
388 return Err(SandboxError::InvalidInput(format!(
389 "JSON parsing error: {}",
390 api_error.title
391 )));
392 }
393 }
394 }
395
396 if error_text.contains("limit") || error_text.contains("maximum") {
398 Err(SandboxError::LimitExceeded)
399 } else if error_text.contains("already exists") {
400 Err(SandboxError::AlreadyExists(error_text))
401 } else {
402 Err(SandboxError::InvalidInput(error_text))
403 }
404 }
405 404 => Err(SandboxError::NotFound),
406 _ => {
407 let error_text = response.text().await.unwrap_or_default();
408 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
409 "HTTP {status}: {error_text}"
410 ))))
411 }
412 }
413 }
414
415 pub async fn update_sandbox(
427 &self,
428 application_guid: &str,
429 sandbox_guid: &str,
430 request: UpdateSandboxRequest,
431 ) -> Result<Sandbox, SandboxError> {
432 Self::validate_update_request(&request)?;
434
435 let endpoint =
436 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
437
438 let response = self.client.put(&endpoint, Some(&request)).await?;
439
440 let status = response.status().as_u16();
441 match status {
442 200 => {
443 let sandbox: Sandbox = response.json().await?;
444 Ok(sandbox)
445 }
446 400 => {
447 let error_text = response.text().await.unwrap_or_default();
448 Err(SandboxError::InvalidInput(error_text))
449 }
450 404 => Err(SandboxError::NotFound),
451 _ => {
452 let error_text = response.text().await.unwrap_or_default();
453 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
454 "HTTP {status}: {error_text}"
455 ))))
456 }
457 }
458 }
459
460 pub async fn delete_sandbox(
471 &self,
472 application_guid: &str,
473 sandbox_guid: &str,
474 ) -> Result<(), SandboxError> {
475 let endpoint =
476 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
477
478 let response = self.client.delete(&endpoint).await?;
479
480 let status = response.status().as_u16();
481 match status {
482 204 => Ok(()),
483 404 => Err(SandboxError::NotFound),
484 409 => {
485 let error_text = response.text().await.unwrap_or_default();
486 Err(SandboxError::OperationNotAllowed(error_text))
487 }
488 _ => {
489 let error_text = response.text().await.unwrap_or_default();
490 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
491 "HTTP {status}: {error_text}"
492 ))))
493 }
494 }
495 }
496
497 pub async fn promote_sandbox_scan(
509 &self,
510 application_guid: &str,
511 sandbox_guid: &str,
512 delete_on_promote: bool,
513 ) -> Result<(), SandboxError> {
514 let endpoint = if delete_on_promote {
515 format!(
516 "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
517 )
518 } else {
519 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote")
520 };
521
522 let response = self.client.post(&endpoint, None::<&()>).await?;
523
524 let status = response.status().as_u16();
525 match status {
526 200 | 204 => Ok(()),
527 404 => Err(SandboxError::NotFound),
528 409 => {
529 let error_text = response.text().await.unwrap_or_default();
530 Err(SandboxError::OperationNotAllowed(error_text))
531 }
532 _ => {
533 let error_text = response.text().await.unwrap_or_default();
534 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
535 "HTTP {status}: {error_text}"
536 ))))
537 }
538 }
539 }
540
541 pub async fn get_sandbox_scans(
552 &self,
553 application_guid: &str,
554 sandbox_guid: &str,
555 ) -> Result<Vec<SandboxScan>, SandboxError> {
556 let endpoint =
557 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans");
558
559 let response = self.client.get(&endpoint, None).await?;
560
561 let status = response.status().as_u16();
562 match status {
563 200 => {
564 let scans: Vec<SandboxScan> = response.json().await?;
565 Ok(scans)
566 }
567 404 => Err(SandboxError::NotFound),
568 _ => {
569 let error_text = response.text().await.unwrap_or_default();
570 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
571 "HTTP {status}: {error_text}"
572 ))))
573 }
574 }
575 }
576
577 pub async fn sandbox_exists(
588 &self,
589 application_guid: &str,
590 sandbox_guid: &str,
591 ) -> Result<bool, SandboxError> {
592 match self.get_sandbox(application_guid, sandbox_guid).await {
593 Ok(_) => Ok(true),
594 Err(SandboxError::NotFound) => Ok(false),
595 Err(e) => Err(e),
596 }
597 }
598
599 pub async fn get_sandbox_by_name(
610 &self,
611 application_guid: &str,
612 name: &str,
613 ) -> Result<Option<Sandbox>, SandboxError> {
614 let params = SandboxListParams {
615 name: Some(name.to_string()),
616 ..Default::default()
617 };
618
619 let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
620 Ok(sandboxes.into_iter().find(|s| s.name == name))
621 }
622
623 fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
625 if request.name.is_empty() {
626 return Err(SandboxError::InvalidInput(
627 "Sandbox name cannot be empty".to_string(),
628 ));
629 }
630 if request.name.len() > 256 {
631 return Err(SandboxError::InvalidInput(
632 "Sandbox name too long (max 256 characters)".to_string(),
633 ));
634 }
635
636 if request.name.contains(['<', '>', '"', '&', '\'']) {
638 return Err(SandboxError::InvalidInput(
639 "Sandbox name contains invalid characters".to_string(),
640 ));
641 }
642
643 Ok(())
644 }
645
646 fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
648 if let Some(name) = &request.name {
649 if name.is_empty() {
650 return Err(SandboxError::InvalidInput(
651 "Sandbox name cannot be empty".to_string(),
652 ));
653 }
654 if name.len() > 256 {
655 return Err(SandboxError::InvalidInput(
656 "Sandbox name too long (max 256 characters)".to_string(),
657 ));
658 }
659
660 if name.contains(['<', '>', '"', '&', '\'']) {
662 return Err(SandboxError::InvalidInput(
663 "Sandbox name contains invalid characters".to_string(),
664 ));
665 }
666 }
667
668 Ok(())
669 }
670}
671
672impl<'a> SandboxApi<'a> {
674 pub async fn create_simple_sandbox(
685 &self,
686 application_guid: &str,
687 name: &str,
688 ) -> Result<Sandbox, SandboxError> {
689 let request = CreateSandboxRequest {
690 name: name.to_string(),
691 description: None,
692 auto_recreate: None,
693 custom_fields: None,
694 team_identifiers: None,
695 };
696
697 self.create_sandbox(application_guid, request).await
698 }
699
700 pub async fn create_auto_recreate_sandbox(
712 &self,
713 application_guid: &str,
714 name: &str,
715 description: Option<String>,
716 ) -> Result<Sandbox, SandboxError> {
717 let request = CreateSandboxRequest {
718 name: name.to_string(),
719 description,
720 auto_recreate: Some(true),
721 custom_fields: None,
722 team_identifiers: None,
723 };
724
725 self.create_sandbox(application_guid, request).await
726 }
727
728 pub async fn update_sandbox_name(
740 &self,
741 application_guid: &str,
742 sandbox_guid: &str,
743 new_name: &str,
744 ) -> Result<Sandbox, SandboxError> {
745 let request = UpdateSandboxRequest {
746 name: Some(new_name.to_string()),
747 description: None,
748 auto_recreate: None,
749 custom_fields: None,
750 team_identifiers: None,
751 };
752
753 self.update_sandbox(application_guid, sandbox_guid, request)
754 .await
755 }
756
757 pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
767 let sandboxes = self.list_sandboxes(application_guid, None).await?;
768 Ok(sandboxes.len())
769 }
770
771 pub async fn get_sandbox_id_from_guid(
784 &self,
785 application_guid: &str,
786 sandbox_guid: &str,
787 ) -> Result<String, SandboxError> {
788 let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
789 match sandbox.id {
790 Some(id) => Ok(id.to_string()),
791 None => Err(SandboxError::InvalidInput(
792 "Sandbox has no numeric ID".to_string(),
793 )),
794 }
795 }
796
797 pub async fn create_sandbox_if_not_exists(
812 &self,
813 application_guid: &str,
814 name: &str,
815 description: Option<String>,
816 ) -> Result<Sandbox, SandboxError> {
817 if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
819 return Ok(existing_sandbox);
820 }
821
822 let create_request = CreateSandboxRequest {
824 name: name.to_string(),
825 description,
826 auto_recreate: Some(true), custom_fields: None,
828 team_identifiers: None,
829 };
830
831 self.create_sandbox(application_guid, create_request).await
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn test_validate_create_request() {
841 let valid_request = CreateSandboxRequest {
843 name: "valid-sandbox".to_string(),
844 description: None,
845 auto_recreate: None,
846 custom_fields: None,
847 team_identifiers: None,
848 };
849 assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
850
851 let empty_name_request = CreateSandboxRequest {
853 name: String::new(),
854 description: None,
855 auto_recreate: None,
856 custom_fields: None,
857 team_identifiers: None,
858 };
859 assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
860
861 let long_name_request = CreateSandboxRequest {
863 name: "x".repeat(300),
864 description: None,
865 auto_recreate: None,
866 custom_fields: None,
867 team_identifiers: None,
868 };
869 assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
870
871 let invalid_char_request = CreateSandboxRequest {
873 name: "invalid<name>".to_string(),
874 description: None,
875 auto_recreate: None,
876 custom_fields: None,
877 team_identifiers: None,
878 };
879 assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
880 }
881
882 #[test]
883 fn test_sandbox_list_params_to_query() {
884 let params = SandboxListParams {
885 name: Some("test".to_string()),
886 page: Some(1),
887 size: Some(10),
888 ..Default::default()
889 };
890
891 let query_params: Vec<_> = params.into();
892 assert_eq!(query_params.len(), 3);
893 assert!(query_params.contains(&("name".to_string(), "test".to_string())));
894 assert!(query_params.contains(&("page".to_string(), "1".to_string())));
895 assert!(query_params.contains(&("size".to_string(), "10".to_string())));
896 }
897
898 #[test]
899 fn test_sandbox_error_display() {
900 let error = SandboxError::NotFound;
901 assert_eq!(error.to_string(), "Sandbox not found");
902
903 let error = SandboxError::InvalidInput("test".to_string());
904 assert_eq!(error.to_string(), "Invalid input: test");
905
906 let error = SandboxError::LimitExceeded;
907 assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
908 }
909}