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)]
211#[must_use = "Need to handle all error enum types."]
212pub enum SandboxError {
213 Api(VeracodeError),
215 NotFound,
217 InvalidInput(String),
219 LimitExceeded,
221 OperationNotAllowed(String),
223 AlreadyExists(String),
225}
226
227impl std::fmt::Display for SandboxError {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 match self {
230 SandboxError::Api(err) => write!(f, "API error: {err}"),
231 SandboxError::NotFound => write!(f, "Sandbox not found"),
232 SandboxError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
233 SandboxError::LimitExceeded => write!(f, "Maximum number of sandboxes reached"),
234 SandboxError::OperationNotAllowed(msg) => write!(f, "Operation not allowed: {msg}"),
235 SandboxError::AlreadyExists(msg) => write!(f, "Sandbox already exists: {msg}"),
236 }
237 }
238}
239
240impl std::error::Error for SandboxError {}
241
242impl From<VeracodeError> for SandboxError {
243 fn from(err: VeracodeError) -> Self {
244 SandboxError::Api(err)
245 }
246}
247
248impl From<reqwest::Error> for SandboxError {
249 fn from(err: reqwest::Error) -> Self {
250 SandboxError::Api(VeracodeError::Http(err))
251 }
252}
253
254impl From<serde_json::Error> for SandboxError {
255 fn from(err: serde_json::Error) -> Self {
256 SandboxError::Api(VeracodeError::Serialization(err))
257 }
258}
259
260pub struct SandboxApi<'a> {
262 client: &'a VeracodeClient,
263}
264
265impl<'a> SandboxApi<'a> {
266 #[must_use]
273 pub fn new(client: &'a VeracodeClient) -> Self {
274 Self { client }
275 }
276
277 pub async fn list_sandboxes(
293 &self,
294 application_guid: &str,
295 params: Option<SandboxListParams>,
296 ) -> Result<Vec<Sandbox>, SandboxError> {
297 let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
298
299 let query_params = params.as_ref().map(Vec::from);
300
301 let response = self.client.get(&endpoint, query_params.as_deref()).await?;
302
303 let status = response.status().as_u16();
304 match status {
305 200 => {
306 let sandbox_response: SandboxListResponse = response.json().await?;
307 Ok(sandbox_response
308 .embedded
309 .map(|e| e.sandboxes)
310 .unwrap_or_default())
311 }
312 404 => Err(SandboxError::NotFound),
313 _ => {
314 let error_text = response.text().await.unwrap_or_default();
315 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
316 "HTTP {status}: {error_text}"
317 ))))
318 }
319 }
320 }
321
322 pub async fn get_sandbox(
338 &self,
339 application_guid: &str,
340 sandbox_guid: &str,
341 ) -> Result<Sandbox, SandboxError> {
342 let endpoint =
343 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
344
345 let response = self.client.get(&endpoint, None).await?;
346
347 let status = response.status().as_u16();
348 match status {
349 200 => {
350 let sandbox: Sandbox = response.json().await?;
351 Ok(sandbox)
352 }
353 404 => Err(SandboxError::NotFound),
354 _ => {
355 let error_text = response.text().await.unwrap_or_default();
356 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
357 "HTTP {status}: {error_text}"
358 ))))
359 }
360 }
361 }
362
363 pub async fn create_sandbox(
379 &self,
380 application_guid: &str,
381 request: CreateSandboxRequest,
382 ) -> Result<Sandbox, SandboxError> {
383 Self::validate_create_request(&request)?;
385
386 let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
387
388 let response = self.client.post(&endpoint, Some(&request)).await?;
389
390 let status = response.status().as_u16();
391 match status {
392 200 | 201 => {
393 let sandbox: Sandbox = response.json().await?;
394 Ok(sandbox)
395 }
396 400 => {
397 let error_text = response.text().await.unwrap_or_default();
398
399 if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&error_text)
401 && let Some(embedded) = error_response.embedded
402 {
403 for api_error in embedded.api_errors {
404 if api_error.title.contains("already exists") {
405 return Err(SandboxError::AlreadyExists(api_error.title));
406 }
407 if api_error.title.contains("limit") || api_error.title.contains("maximum")
408 {
409 return Err(SandboxError::LimitExceeded);
410 }
411 if api_error.title.contains("Json Parse Error")
412 || api_error.title.contains("Cannot deserialize")
413 {
414 return Err(SandboxError::InvalidInput(format!(
415 "JSON parsing error: {}",
416 api_error.title
417 )));
418 }
419 }
420 }
421
422 if error_text.contains("limit") || error_text.contains("maximum") {
424 Err(SandboxError::LimitExceeded)
425 } else if error_text.contains("already exists") {
426 Err(SandboxError::AlreadyExists(error_text))
427 } else {
428 Err(SandboxError::InvalidInput(error_text))
429 }
430 }
431 404 => Err(SandboxError::NotFound),
432 _ => {
433 let error_text = response.text().await.unwrap_or_default();
434 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
435 "HTTP {status}: {error_text}"
436 ))))
437 }
438 }
439 }
440
441 pub async fn update_sandbox(
458 &self,
459 application_guid: &str,
460 sandbox_guid: &str,
461 request: UpdateSandboxRequest,
462 ) -> Result<Sandbox, SandboxError> {
463 Self::validate_update_request(&request)?;
465
466 let endpoint =
467 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
468
469 let response = self.client.put(&endpoint, Some(&request)).await?;
470
471 let status = response.status().as_u16();
472 match status {
473 200 => {
474 let sandbox: Sandbox = response.json().await?;
475 Ok(sandbox)
476 }
477 400 => {
478 let error_text = response.text().await.unwrap_or_default();
479 Err(SandboxError::InvalidInput(error_text))
480 }
481 404 => Err(SandboxError::NotFound),
482 _ => {
483 let error_text = response.text().await.unwrap_or_default();
484 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
485 "HTTP {status}: {error_text}"
486 ))))
487 }
488 }
489 }
490
491 pub async fn delete_sandbox(
507 &self,
508 application_guid: &str,
509 sandbox_guid: &str,
510 ) -> Result<(), SandboxError> {
511 let endpoint =
512 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
513
514 let response = self.client.delete(&endpoint).await?;
515
516 let status = response.status().as_u16();
517 match status {
518 204 => Ok(()),
519 404 => Err(SandboxError::NotFound),
520 409 => {
521 let error_text = response.text().await.unwrap_or_default();
522 Err(SandboxError::OperationNotAllowed(error_text))
523 }
524 _ => {
525 let error_text = response.text().await.unwrap_or_default();
526 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
527 "HTTP {status}: {error_text}"
528 ))))
529 }
530 }
531 }
532
533 pub async fn promote_sandbox_scan(
550 &self,
551 application_guid: &str,
552 sandbox_guid: &str,
553 delete_on_promote: bool,
554 ) -> Result<(), SandboxError> {
555 let endpoint = if delete_on_promote {
556 format!(
557 "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
558 )
559 } else {
560 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote")
561 };
562
563 let response = self.client.post(&endpoint, None::<&()>).await?;
564
565 let status = response.status().as_u16();
566 match status {
567 200 | 204 => Ok(()),
568 404 => Err(SandboxError::NotFound),
569 409 => {
570 let error_text = response.text().await.unwrap_or_default();
571 Err(SandboxError::OperationNotAllowed(error_text))
572 }
573 _ => {
574 let error_text = response.text().await.unwrap_or_default();
575 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
576 "HTTP {status}: {error_text}"
577 ))))
578 }
579 }
580 }
581
582 pub async fn get_sandbox_scans(
598 &self,
599 application_guid: &str,
600 sandbox_guid: &str,
601 ) -> Result<Vec<SandboxScan>, SandboxError> {
602 let endpoint =
603 format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans");
604
605 let response = self.client.get(&endpoint, None).await?;
606
607 let status = response.status().as_u16();
608 match status {
609 200 => {
610 let scans: Vec<SandboxScan> = response.json().await?;
611 Ok(scans)
612 }
613 404 => Err(SandboxError::NotFound),
614 _ => {
615 let error_text = response.text().await.unwrap_or_default();
616 Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
617 "HTTP {status}: {error_text}"
618 ))))
619 }
620 }
621 }
622
623 pub async fn sandbox_exists(
639 &self,
640 application_guid: &str,
641 sandbox_guid: &str,
642 ) -> Result<bool, SandboxError> {
643 match self.get_sandbox(application_guid, sandbox_guid).await {
644 Ok(_) => Ok(true),
645 Err(SandboxError::NotFound) => Ok(false),
646 Err(e) => Err(e),
647 }
648 }
649
650 pub async fn get_sandbox_by_name(
666 &self,
667 application_guid: &str,
668 name: &str,
669 ) -> Result<Option<Sandbox>, SandboxError> {
670 let params = SandboxListParams {
671 name: Some(name.to_string()),
672 ..Default::default()
673 };
674
675 let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
676 Ok(sandboxes.into_iter().find(|s| s.name == name))
677 }
678
679 fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
681 if request.name.is_empty() {
682 return Err(SandboxError::InvalidInput(
683 "Sandbox name cannot be empty".to_string(),
684 ));
685 }
686 if request.name.len() > 256 {
687 return Err(SandboxError::InvalidInput(
688 "Sandbox name too long (max 256 characters)".to_string(),
689 ));
690 }
691
692 if request.name.contains(['<', '>', '"', '&', '\'']) {
694 return Err(SandboxError::InvalidInput(
695 "Sandbox name contains invalid characters".to_string(),
696 ));
697 }
698
699 Ok(())
700 }
701
702 fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
704 if let Some(name) = &request.name {
705 if name.is_empty() {
706 return Err(SandboxError::InvalidInput(
707 "Sandbox name cannot be empty".to_string(),
708 ));
709 }
710 if name.len() > 256 {
711 return Err(SandboxError::InvalidInput(
712 "Sandbox name too long (max 256 characters)".to_string(),
713 ));
714 }
715
716 if name.contains(['<', '>', '"', '&', '\'']) {
718 return Err(SandboxError::InvalidInput(
719 "Sandbox name contains invalid characters".to_string(),
720 ));
721 }
722 }
723
724 Ok(())
725 }
726}
727
728impl<'a> SandboxApi<'a> {
730 pub async fn create_simple_sandbox(
746 &self,
747 application_guid: &str,
748 name: &str,
749 ) -> Result<Sandbox, SandboxError> {
750 let request = CreateSandboxRequest {
751 name: name.to_string(),
752 description: None,
753 auto_recreate: None,
754 custom_fields: None,
755 team_identifiers: None,
756 };
757
758 self.create_sandbox(application_guid, request).await
759 }
760
761 pub async fn create_auto_recreate_sandbox(
778 &self,
779 application_guid: &str,
780 name: &str,
781 description: Option<String>,
782 ) -> Result<Sandbox, SandboxError> {
783 let request = CreateSandboxRequest {
784 name: name.to_string(),
785 description,
786 auto_recreate: Some(true),
787 custom_fields: None,
788 team_identifiers: None,
789 };
790
791 self.create_sandbox(application_guid, request).await
792 }
793
794 pub async fn update_sandbox_name(
811 &self,
812 application_guid: &str,
813 sandbox_guid: &str,
814 new_name: &str,
815 ) -> Result<Sandbox, SandboxError> {
816 let request = UpdateSandboxRequest {
817 name: Some(new_name.to_string()),
818 description: None,
819 auto_recreate: None,
820 custom_fields: None,
821 team_identifiers: None,
822 };
823
824 self.update_sandbox(application_guid, sandbox_guid, request)
825 .await
826 }
827
828 pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
843 let sandboxes = self.list_sandboxes(application_guid, None).await?;
844 Ok(sandboxes.len())
845 }
846
847 pub async fn get_sandbox_id_from_guid(
875 &self,
876 application_guid: &str,
877 sandbox_guid: &str,
878 ) -> Result<String, SandboxError> {
879 let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
880 match sandbox.id {
881 Some(id) => Ok(id.to_string()),
882 None => Err(SandboxError::InvalidInput(
883 "Sandbox has no numeric ID".to_string(),
884 )),
885 }
886 }
887
888 pub async fn create_sandbox_if_not_exists(
908 &self,
909 application_guid: &str,
910 name: &str,
911 description: Option<String>,
912 ) -> Result<Sandbox, SandboxError> {
913 if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
915 return Ok(existing_sandbox);
916 }
917
918 let create_request = CreateSandboxRequest {
920 name: name.to_string(),
921 description,
922 auto_recreate: Some(true), custom_fields: None,
924 team_identifiers: None,
925 };
926
927 self.create_sandbox(application_guid, create_request).await
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934
935 #[test]
936 fn test_validate_create_request() {
937 let valid_request = CreateSandboxRequest {
939 name: "valid-sandbox".to_string(),
940 description: None,
941 auto_recreate: None,
942 custom_fields: None,
943 team_identifiers: None,
944 };
945 assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
946
947 let empty_name_request = CreateSandboxRequest {
949 name: String::new(),
950 description: None,
951 auto_recreate: None,
952 custom_fields: None,
953 team_identifiers: None,
954 };
955 assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
956
957 let long_name_request = CreateSandboxRequest {
959 name: "x".repeat(300),
960 description: None,
961 auto_recreate: None,
962 custom_fields: None,
963 team_identifiers: None,
964 };
965 assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
966
967 let invalid_char_request = CreateSandboxRequest {
969 name: "invalid<name>".to_string(),
970 description: None,
971 auto_recreate: None,
972 custom_fields: None,
973 team_identifiers: None,
974 };
975 assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
976 }
977
978 #[test]
979 fn test_sandbox_list_params_to_query() {
980 let params = SandboxListParams {
981 name: Some("test".to_string()),
982 page: Some(1),
983 size: Some(10),
984 ..Default::default()
985 };
986
987 let query_params: Vec<_> = params.into();
988 assert_eq!(query_params.len(), 3);
989 assert!(query_params.contains(&("name".to_string(), "test".to_string())));
990 assert!(query_params.contains(&("page".to_string(), "1".to_string())));
991 assert!(query_params.contains(&("size".to_string(), "10".to_string())));
992 }
993
994 #[test]
995 fn test_sandbox_error_display() {
996 let error = SandboxError::NotFound;
997 assert_eq!(error.to_string(), "Sandbox not found");
998
999 let error = SandboxError::InvalidInput("test".to_string());
1000 assert_eq!(error.to_string(), "Invalid input: test");
1001
1002 let error = SandboxError::LimitExceeded;
1003 assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
1004 }
1005}