1use crate::error::{ErrorData, Result};
11use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
12use crate::resources::ToolchainConfig;
13use crate::Platform;
14use alien_error::AlienError;
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::fmt::Debug;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase", tag = "type")]
24pub enum SandboxCode {
25 #[serde(rename_all = "camelCase")]
27 Image {
28 image: String,
33 },
34 #[serde(rename_all = "camelCase")]
36 Source {
37 src: String,
39 toolchain: ToolchainConfig,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct SandboxLimits {
53 pub cpu: String,
55 pub memory: String,
57 pub disk: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub max_processes: Option<u32>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct MicrovmTier {
75 pub baseline_memory_mib: i64,
77 pub peak_memory_mib: i64,
79 pub peak_vcpu: u32,
81 pub max_disk_mib: i64,
83}
84
85const AWS_MAX_SESSION_LIFETIME_SECONDS: u32 = 28_800;
89
90const MICROVM_TIERS: &[MicrovmTier] = &[
91 MicrovmTier {
92 baseline_memory_mib: 512,
93 peak_memory_mib: 2048,
94 peak_vcpu: 1,
95 max_disk_mib: 8192,
96 },
97 MicrovmTier {
98 baseline_memory_mib: 1024,
99 peak_memory_mib: 4096,
100 peak_vcpu: 2,
101 max_disk_mib: 8192,
102 },
103 MicrovmTier {
104 baseline_memory_mib: 2048,
105 peak_memory_mib: 8192,
106 peak_vcpu: 4,
107 max_disk_mib: 8192,
108 },
109 MicrovmTier {
110 baseline_memory_mib: 4096,
111 peak_memory_mib: 16384,
112 peak_vcpu: 8,
113 max_disk_mib: 16384,
114 },
115 MicrovmTier {
116 baseline_memory_mib: 8192,
117 peak_memory_mib: 32768,
118 peak_vcpu: 16,
119 max_disk_mib: 32768,
120 },
121];
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
126#[serde(rename_all = "camelCase", tag = "mode")]
127pub enum SandboxEgress {
128 Deny,
133 Allow,
140 #[serde(rename_all = "camelCase")]
145 AllowDomains {
146 domains: Vec<String>,
148 },
149}
150
151impl SandboxEgress {
152 pub fn internet_access_switch(&self) -> Option<bool> {
159 match self {
160 SandboxEgress::Allow => Some(true),
161 SandboxEgress::Deny => Some(false),
162 SandboxEgress::AllowDomains { .. } => None,
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct SandboxSessionPolicy {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub max_lifetime_seconds: Option<u32>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub idle_suspend_seconds: Option<u32>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct SandboxCapabilities {
194 pub files: bool,
196 pub reconnect: bool,
198 pub preview: bool,
200 pub suspend_resume: bool,
202 pub snapshot: bool,
204 pub domain_egress_rules: bool,
206 pub egress_deny: bool,
208 pub enforced_limits: bool,
210 pub process_limit: bool,
212 pub session_lifetime: bool,
214 pub supervisor_pid_namespace: bool,
220 pub supervisor_isolation: bool,
226}
227
228impl SandboxCapabilities {
229 pub fn for_platform(platform: Platform) -> Result<Self> {
235 match platform {
236 Platform::Aws => Ok(Self {
237 files: true,
238 reconnect: true,
239 preview: true,
240 suspend_resume: true,
241 snapshot: false,
242 domain_egress_rules: false,
243 egress_deny: true,
244 enforced_limits: true,
245 process_limit: false,
247 session_lifetime: true,
250 supervisor_pid_namespace: false,
255 supervisor_isolation: true,
258 }),
259 Platform::Azure => Ok(Self {
260 files: true,
261 reconnect: true,
262 preview: false,
267 suspend_resume: true,
268 snapshot: false,
274 domain_egress_rules: true,
275 egress_deny: true,
276 enforced_limits: false,
277 process_limit: false,
278 session_lifetime: false,
282 supervisor_pid_namespace: false,
284 supervisor_isolation: false,
287 }),
288 Platform::Gcp => Ok(Self::gcp_agent_platform()),
289 Platform::Kubernetes => Ok(Self {
292 files: true,
293 reconnect: true,
294 preview: false,
295 suspend_resume: false,
296 snapshot: false,
297 domain_egress_rules: false,
298 egress_deny: true,
299 enforced_limits: true,
300 process_limit: false,
302 session_lifetime: true,
304 supervisor_pid_namespace: false,
308 supervisor_isolation: false,
313 }),
314 Platform::Local => Ok(Self {
315 files: true,
316 reconnect: true,
317 preview: true,
318 suspend_resume: false,
319 snapshot: false,
320 domain_egress_rules: false,
321 egress_deny: true,
322 enforced_limits: true,
323 process_limit: true,
325 session_lifetime: false,
326 supervisor_pid_namespace: false,
329 supervisor_isolation: true,
333 }),
334 Platform::Machines | Platform::Test => {
335 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
336 platform: platform.to_string(),
337 }))
338 }
339 }
340 }
341
342 pub fn gcp_agent_platform() -> Self {
344 Self {
345 files: true,
347 reconnect: true,
351 preview: false,
353 suspend_resume: true,
355 snapshot: true,
357 domain_egress_rules: false,
359 egress_deny: true,
361 enforced_limits: true,
365 process_limit: false,
367 session_lifetime: true,
369 supervisor_pid_namespace: false,
371 supervisor_isolation: false,
374 }
375 }
376
377 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
379 let available = match capability {
380 SandboxCapability::Files => self.files,
381 SandboxCapability::Reconnect => self.reconnect,
382 SandboxCapability::Preview => self.preview,
383 SandboxCapability::SuspendResume => self.suspend_resume,
384 SandboxCapability::Snapshot => self.snapshot,
385 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
386 SandboxCapability::EgressDeny => self.egress_deny,
387 SandboxCapability::EnforcedLimits => self.enforced_limits,
388 SandboxCapability::ProcessLimit => self.process_limit,
389 SandboxCapability::SessionLifetime => self.session_lifetime,
390 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
391 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
392 };
393
394 if available {
395 return Ok(());
396 }
397
398 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
399 capability: capability.as_str().to_string(),
400 platform: platform.to_string(),
401 }))
402 }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
408#[serde(rename_all = "camelCase")]
409pub enum SandboxCapability {
410 Files,
412 Reconnect,
414 Preview,
416 SuspendResume,
418 Snapshot,
420 DomainEgressRules,
422 EgressDeny,
424 EnforcedLimits,
426 ProcessLimit,
428 SessionLifetime,
430 SupervisorPidNamespace,
432 SupervisorIsolation,
434}
435
436impl SandboxCapability {
437 pub fn as_str(&self) -> &'static str {
439 match self {
440 Self::Files => "files",
441 Self::Reconnect => "reconnect",
442 Self::Preview => "preview",
443 Self::SuspendResume => "suspendResume",
444 Self::Snapshot => "snapshot",
445 Self::DomainEgressRules => "domainEgressRules",
446 Self::EgressDeny => "egressDeny",
447 Self::EnforcedLimits => "enforcedLimits",
448 Self::ProcessLimit => "processLimit",
449 Self::SessionLifetime => "sessionLifetime",
450 Self::SupervisorPidNamespace => "supervisorPidNamespace",
451 Self::SupervisorIsolation => "supervisorIsolation",
452 }
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
458#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
459#[serde(rename_all = "camelCase", deny_unknown_fields)]
460#[builder(start_fn = new)]
461pub struct Sandbox {
462 #[builder(start_fn)]
465 pub id: String,
466 pub code: SandboxCode,
468 #[serde(skip_serializing_if = "Option::is_none")]
474 pub limits: Option<SandboxLimits>,
475 pub egress: SandboxEgress,
477 pub session: SandboxSessionPolicy,
479 #[builder(default)]
483 #[serde(default, skip_serializing_if = "Vec::is_empty")]
484 pub preview_ports: Vec<u16>,
485}
486
487pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
494 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
495}
496
497pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
504 stack.resources().any(|(_resource_id, resource)| {
505 resource
506 .config
507 .downcast_ref::<Sandbox>()
508 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
509 })
510}
511
512impl Sandbox {
513 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
515
516 pub fn id(&self) -> &str {
518 &self.id
519 }
520
521 pub fn resolved_limits(&self) -> SandboxLimits {
527 self.limits.clone().unwrap_or_else(default_limits)
528 }
529
530 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
535 let capabilities = SandboxCapabilities::for_platform(platform)?;
536
537 if let SandboxCode::Source { .. } = &self.code {
541 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
542 resource_id: self.id.clone(),
543 field: "code".to_string(),
544 value: "source".to_string(),
545 reason: "no sandbox backend builds an image from source yet; give code.image a \
546 prebuilt reference"
547 .to_string(),
548 }));
549 }
550
551 if platform == Platform::Azure {
553 self.azure_catalog_image()?;
554 }
555
556 let Some(limits) = self.limits.as_ref() else {
557 return self.validate_capabilities(&capabilities, platform);
559 };
560
561 validate_quantity(&self.id, "cpu", &limits.cpu)?;
562 validate_quantity(&self.id, "memory", &limits.memory)?;
563 validate_quantity(&self.id, "disk", &limits.disk)?;
564
565 if let Some(max_processes) = limits.max_processes {
566 if max_processes == 0 {
567 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
568 resource_id: self.id.clone(),
569 field: "maxProcesses".to_string(),
570 value: "0".to_string(),
571 reason: "a sandbox that may run no processes cannot run code".to_string(),
572 }));
573 }
574 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
575 }
576
577 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
580
581 if platform == Platform::Aws {
582 self.microvm_tier()?;
585
586 if let Some(seconds) = self.session.max_lifetime_seconds {
591 if !(1..=AWS_MAX_SESSION_LIFETIME_SECONDS).contains(&seconds) {
592 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
593 resource_id: self.id.clone(),
594 field: "maxLifetimeSeconds".to_string(),
595 value: seconds.to_string(),
596 reason: format!(
597 "AWS runs a MicroVM for between 1 and \
598 {AWS_MAX_SESSION_LIFETIME_SECONDS} seconds"
599 ),
600 }));
601 }
602 }
603 }
604
605 self.validate_capabilities(&capabilities, platform)
606 }
607
608 pub fn azure_catalog_image(&self) -> Result<&str> {
614 let refused = |value: &str, reason: &str| {
615 AlienError::new(ErrorData::SandboxLimitInvalid {
616 resource_id: self.id.clone(),
617 field: "code.image".to_string(),
618 value: value.to_string(),
619 reason: reason.to_string(),
620 })
621 };
622
623 let SandboxCode::Image { image } = &self.code else {
624 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
625 resource_id: self.id.clone(),
626 field: "code".to_string(),
627 value: "source".to_string(),
628 reason: "no sandbox backend builds an image from source yet".to_string(),
629 }));
630 };
631
632 let image = image.trim();
633 if image.is_empty() {
634 return Err(refused(image, "a sandbox has to name an image"));
635 }
636 if !image
637 .chars()
638 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
639 {
640 return Err(refused(
641 image,
642 "Azure creates a session from a public catalog disk image, so code.image must be \
643 a bare catalog name such as 'ubuntu'",
644 ));
645 }
646 Ok(image)
647 }
648
649 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
656 let Some(limits) = self.limits.as_ref() else {
657 return Ok(MICROVM_TIERS[2]);
659 };
660
661 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
662 AlienError::new(ErrorData::SandboxLimitInvalid {
663 resource_id: self.id.clone(),
664 field: "memory".to_string(),
665 value: limits.memory.clone(),
666 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
667 })
668 })?;
669 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
670 AlienError::new(ErrorData::SandboxLimitInvalid {
671 resource_id: self.id.clone(),
672 field: "disk".to_string(),
673 value: limits.disk.clone(),
674 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
675 })
676 })?;
677 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
678 AlienError::new(ErrorData::SandboxLimitInvalid {
679 resource_id: self.id.clone(),
680 field: "cpu".to_string(),
681 value: limits.cpu.clone(),
682 reason: "expected cores or millicores".to_string(),
683 })
684 })?;
685
686 let sized = |tier: &&MicrovmTier| {
691 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
692 };
693
694 let tier = MICROVM_TIERS
695 .iter()
696 .rev()
697 .find(sized)
698 .copied()
699 .ok_or_else(|| {
700 AlienError::new(ErrorData::SandboxLimitInvalid {
701 resource_id: self.id.clone(),
702 field: "memory".to_string(),
703 value: limits.memory.clone(),
704 reason: format!(
705 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
706 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
707 disk fit no size",
708 limits.memory, limits.disk
709 ),
710 })
711 })?;
712
713 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
714 if cpu_millicores < required_millicores {
715 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
716 resource_id: self.id.clone(),
717 field: "cpu".to_string(),
718 value: limits.cpu.clone(),
719 reason: format!(
720 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
721 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
722 limits.memory, tier.peak_vcpu, tier.peak_vcpu
723 ),
724 }));
725 }
726
727 Ok(tier)
728 }
729
730 fn validate_capabilities(
732 &self,
733 capabilities: &SandboxCapabilities,
734 platform: Platform,
735 ) -> Result<()> {
736 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
737 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
738 }
739
740 if let SandboxEgress::AllowDomains { domains } = &self.egress {
746 if domains.is_empty() {
747 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
748 resource_id: self.id.clone(),
749 field: "egress.domains".to_string(),
750 value: "[]".to_string(),
751 reason: "an allowlist naming no domain denies everything; declare \
752 egress: deny if that is what was meant"
753 .to_string(),
754 }));
755 }
756 }
757
758 if matches!(self.egress, SandboxEgress::Deny) {
759 capabilities.require(SandboxCapability::EgressDeny, platform)?;
760 }
761
762 if !self.preview_ports.is_empty() {
763 capabilities.require(SandboxCapability::Preview, platform)?;
764 }
765
766 if self.session.idle_suspend_seconds.is_some() {
767 capabilities.require(SandboxCapability::SuspendResume, platform)?;
768 }
769
770 if self.session.max_lifetime_seconds.is_some() {
771 capabilities.require(SandboxCapability::SessionLifetime, platform)?;
772 }
773
774 Ok(())
775 }
776}
777
778fn default_limits() -> SandboxLimits {
783 SandboxLimits {
784 cpu: "1".to_string(),
785 memory: "2Gi".to_string(),
786 disk: "8Gi".to_string(),
787 max_processes: None,
788 }
789}
790
791fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
793 let invalid = |reason: &str| {
794 AlienError::new(ErrorData::SandboxLimitInvalid {
795 resource_id: resource_id.to_string(),
796 field: field.to_string(),
797 value: value.to_string(),
798 reason: reason.to_string(),
799 })
800 };
801
802 let digits_end = value
803 .find(|c: char| !c.is_ascii_digit() && c != '.')
804 .unwrap_or(value.len());
805 let (number, suffix) = value.split_at(digits_end);
806
807 let parsed: f64 = number
808 .parse()
809 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
810
811 if parsed <= 0.0 {
812 return Err(invalid("must be greater than zero"));
813 }
814
815 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
816 if !SUFFIXES.contains(&suffix) {
817 return Err(invalid(
818 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
819 ));
820 }
821
822 Ok(())
823}
824
825fn split_quantity(value: &str) -> Option<(f64, &str)> {
827 let trimmed = value.trim();
828 let digits_end = trimmed
829 .find(|c: char| !c.is_ascii_digit() && c != '.')
830 .unwrap_or(trimmed.len());
831 let (number, suffix) = trimmed.split_at(digits_end);
832 number.parse().ok().map(|number| (number, suffix))
833}
834
835pub fn quantity_mib(value: &str) -> Option<i64> {
841 let (number, suffix) = split_quantity(value)?;
842 let bytes = match suffix {
843 "" => number,
844 "k" => number * 1e3,
845 "M" => number * 1e6,
846 "G" => number * 1e9,
847 "T" => number * 1e12,
848 "Ki" => number * 1024.0,
849 "Mi" => number * 1024.0 * 1024.0,
850 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
851 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
852 _ => return None,
854 };
855 Some((bytes / (1024.0 * 1024.0)) as i64)
856}
857
858pub fn millicores(value: &str) -> Option<i64> {
860 let (number, suffix) = split_quantity(value)?;
861 match suffix {
862 "" => Some((number * 1000.0) as i64),
863 "m" => Some(number as i64),
864 _ => None,
865 }
866}
867
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
871#[serde(rename_all = "camelCase")]
872pub struct SandboxOutputs {
873 pub parent_name: String,
875 #[serde(skip_serializing_if = "Option::is_none")]
877 pub identifier: Option<String>,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub endpoint: Option<String>,
881}
882
883impl ResourceOutputsDefinition for SandboxOutputs {
884 fn get_resource_type(&self) -> ResourceType {
885 Sandbox::RESOURCE_TYPE
886 }
887
888 fn as_any(&self) -> &dyn Any {
889 self
890 }
891
892 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
893 Box::new(self.clone())
894 }
895
896 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
897 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
898 }
899
900 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
901 serde_json::to_value(self)
902 }
903}
904
905impl ResourceDefinition for Sandbox {
906 fn get_resource_type(&self) -> ResourceType {
907 Self::RESOURCE_TYPE
908 }
909
910 fn id(&self) -> &str {
911 &self.id
912 }
913
914 fn get_dependencies(&self) -> Vec<ResourceRef> {
915 Vec::new()
916 }
917
918 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
919 let new_sandbox = new_config
920 .as_any()
921 .downcast_ref::<Sandbox>()
922 .ok_or_else(|| {
923 AlienError::new(ErrorData::UnexpectedResourceType {
924 resource_id: self.id.clone(),
925 expected: Self::RESOURCE_TYPE,
926 actual: new_config.get_resource_type(),
927 })
928 })?;
929
930 if self.id != new_sandbox.id {
931 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
932 resource_id: self.id.clone(),
933 reason: "the 'id' field is immutable".to_string(),
934 }));
935 }
936
937 Ok(())
938 }
939
940 fn as_any(&self) -> &dyn Any {
941 self
942 }
943
944 fn as_any_mut(&mut self) -> &mut dyn Any {
945 self
946 }
947
948 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
949 Box::new(self.clone())
950 }
951
952 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
953 other.as_any().downcast_ref::<Sandbox>() == Some(self)
954 }
955
956 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
957 serde_json::to_value(self)
958 }
959}
960
961pub const BUNDLE_REGION_TOKEN: &str = "{region}";
966
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969pub enum BundleUri<'a> {
970 Literal(&'a str),
972 Regional { before: &'a str, after: &'a str },
975}
976
977pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
983 let path = uri
984 .strip_prefix("s3://")
985 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
986 let (bucket, key) = path
987 .split_once('/')
988 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
989
990 if key.contains('{') || key.contains('}') {
991 return Err(format!(
992 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
993 bucket name alone"
994 ));
995 }
996
997 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
998 if bucket.contains('{') || bucket.contains('}') {
999 return Err(format!(
1000 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1001 only one"
1002 ));
1003 }
1004 return Ok(BundleUri::Literal(uri));
1005 };
1006
1007 if after.contains(BUNDLE_REGION_TOKEN) {
1008 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1009 }
1010 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1011 return Err(format!(
1012 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1013 ));
1014 }
1015
1016 Ok(BundleUri::Regional {
1017 before: &uri[.."s3://".len() + before.len()],
1018 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1019 })
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1027 Sandbox::new("agent-sbx".to_string())
1028 .code(SandboxCode::Image {
1029 image: "ubuntu".to_string(),
1030 })
1031 .limits(SandboxLimits {
1032 cpu: "1".to_string(),
1033 memory: "2Gi".to_string(),
1034 disk: "20Gi".to_string(),
1035 max_processes: None,
1036 })
1037 .egress(egress)
1038 .session(SandboxSessionPolicy {
1039 max_lifetime_seconds: None,
1040 idle_suspend_seconds: None,
1041 })
1042 .preview_ports(preview_ports)
1043 .build()
1044 }
1045
1046 #[test]
1049 fn a_uri_without_a_token_is_carried_whole() {
1050 assert_eq!(
1051 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1052 Ok(BundleUri::Literal(
1053 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1054 ))
1055 );
1056 }
1057
1058 #[test]
1061 fn a_regional_uri_splits_either_side_of_the_token() {
1062 let BundleUri::Regional { before, after } =
1063 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1064 .expect("the token is accepted in the bucket")
1065 else {
1066 panic!("a bucket-position token must split");
1067 };
1068
1069 assert_eq!(before, "s3://acme-artifacts-");
1070 assert_eq!(after, "/agents/bundle.zip");
1071 assert_eq!(
1072 format!("{before}us-east-2{after}"),
1073 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1074 "the halves must rejoin to the URI the vendor meant"
1075 );
1076 }
1077
1078 #[test]
1081 fn a_token_this_build_cannot_resolve_is_refused() {
1082 for uri in [
1083 "s3://acme-artifacts-{regio}/bundle.zip",
1084 "s3://acme-artifacts/{region}/bundle.zip",
1085 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1086 "s3://acme-artifacts/bundle-{version}.zip",
1087 "s3://acme}-artifacts-{region}/bundle.zip",
1088 "s3://acme{-artifacts-{region}/bundle.zip",
1089 ] {
1090 assert!(
1091 parse_bundle_uri(uri).is_err(),
1092 "'{uri}' must be refused before it can reach an image build"
1093 );
1094 }
1095 }
1096
1097 #[test]
1098 fn resource_type_is_stable() {
1099 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1100 }
1101
1102 #[test]
1103 fn capability_sets_are_per_platform() {
1104 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1105 assert!(
1106 gcp.reconnect,
1107 "generation from the container boot id makes a session reachable across processes"
1108 );
1109 assert!(!gcp.preview);
1110 assert!(gcp.enforced_limits);
1111
1112 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1113 assert!(azure.files, "every backend moves files");
1114 assert!(gcp.files);
1115 assert!(azure.domain_egress_rules);
1118 assert!(azure.egress_deny);
1119 assert!(!azure.enforced_limits);
1122 assert!(azure.suspend_resume);
1123 assert!(!azure.snapshot);
1127 assert!(!azure.preview);
1128
1129 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1130 assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
1131 assert!(aws.suspend_resume);
1132
1133 let k8s =
1134 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1135 assert!(
1136 !k8s.preview,
1137 "the session-scoped ingress gateway does not exist yet"
1138 );
1139 }
1140
1141 #[test]
1150 fn supervisor_isolation_is_per_platform() {
1151 let value = |platform| {
1152 SandboxCapabilities::for_platform(platform)
1153 .expect("supported")
1154 .supervisor_isolation
1155 };
1156
1157 assert!(
1158 value(Platform::Aws),
1159 "root agent setuids the command to 60000"
1160 );
1161 assert!(
1162 value(Platform::Local),
1163 "the supervisor is on the host, outside the container"
1164 );
1165 assert!(
1166 !value(Platform::Kubernetes),
1167 "a single pinned uid cannot be split"
1168 );
1169 assert!(!value(Platform::Azure), "no Alien process runs the command");
1170 assert!(
1171 !value(Platform::Gcp),
1172 "no separate supervisor identity runs the command"
1173 );
1174 }
1175
1176 #[test]
1180 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1181 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1182 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1183
1184 assert_eq!(
1185 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1186 "the older axis cannot tell them apart"
1187 );
1188 assert!(
1189 aws.supervisor_isolation,
1190 "AWS setuids the command off the supervisor"
1191 );
1192 assert!(
1193 !gcp.supervisor_isolation,
1194 "the command runs under no separate supervisor identity"
1195 );
1196 }
1197
1198 #[test]
1203 fn gcp_agent_platform_row_matches_measured_backend() {
1204 let row = SandboxCapabilities::gcp_agent_platform();
1205
1206 assert!(row.files, "agent file ops move over the session envelope");
1207 assert!(
1208 row.reconnect,
1209 "generation is derived from the container boot id, so a session is reachable across \
1210 processes"
1211 );
1212 assert!(
1213 !row.preview,
1214 "the only ingress is :execute; no port-scoped capability"
1215 );
1216 assert!(
1217 row.suspend_resume,
1218 ":pause and :resume preserve the container"
1219 );
1220 assert!(
1221 row.snapshot,
1222 "session state can be captured and restored into a new session"
1223 );
1224 assert!(
1225 !row.domain_egress_rules,
1226 "VPC and DNS peering is not a hostname allowlist"
1227 );
1228 assert!(
1229 row.egress_deny,
1230 "a declared deny blocks both egress and DNS"
1231 );
1232 assert!(
1233 row.enforced_limits,
1234 "ceilings are enforced, by terminating the session on breach"
1235 );
1236 assert!(!row.process_limit, "no process-count ceiling is observed");
1237 assert!(row.session_lifetime, "ttl maps to a session expireTime");
1238 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1239 assert!(
1240 !row.supervisor_isolation,
1241 "the command is not run under a separate supervisor identity"
1242 );
1243
1244 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1246 assert_eq!(
1247 live, row,
1248 "the Platform::Gcp arm is the Agent Platform capability row"
1249 );
1250 }
1251
1252 #[test]
1253 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1254 let error = SandboxCapabilities::for_platform(Platform::Machines)
1255 .expect_err("Machines has no sandbox backend");
1256 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1257 }
1258
1259 #[test]
1260 fn unsupported_capability_names_platform_and_capability() {
1261 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1262 let error = capabilities
1263 .require(SandboxCapability::Preview, Platform::Gcp)
1264 .expect_err("GCP has no preview");
1265
1266 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1267 let rendered = error.to_string();
1268 assert!(
1269 rendered.contains("preview"),
1270 "names the capability: {rendered}"
1271 );
1272 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1273 }
1274
1275 #[test]
1279 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1280 let sandbox = sandbox_with(
1281 SandboxEgress::AllowDomains {
1282 domains: vec!["example.com".to_string()],
1283 },
1284 vec![],
1285 );
1286
1287 for platform in [
1288 Platform::Aws,
1289 Platform::Gcp,
1290 Platform::Kubernetes,
1291 Platform::Local,
1292 ] {
1293 let error = sandbox
1294 .validate_for_platform(platform)
1295 .expect_err("only Azure expresses a hostname allowlist");
1296 assert_eq!(
1297 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1298 "on {platform:?}"
1299 );
1300 }
1301
1302 assert!(
1303 SandboxCapabilities::for_platform(Platform::Azure)
1304 .expect("supported")
1305 .domain_egress_rules,
1306 "Azure's egress policy matches on host pattern"
1307 );
1308 }
1309
1310 #[test]
1313 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1314 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1315
1316 assert!(
1319 SandboxCapabilities::for_platform(Platform::Gcp)
1320 .expect("supported")
1321 .egress_deny
1322 );
1323
1324 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1325 sandbox
1326 .validate_for_platform(platform)
1327 .expect("deny is enforced here");
1328 }
1329
1330 let egress_only = Sandbox::new("sbx".to_string())
1332 .code(SandboxCode::Image {
1333 image: "alpine".to_string(),
1334 })
1335 .egress(SandboxEgress::Deny)
1336 .session(SandboxSessionPolicy {
1337 max_lifetime_seconds: None,
1338 idle_suspend_seconds: None,
1339 })
1340 .build();
1341
1342 egress_only
1343 .validate_for_platform(Platform::Azure)
1344 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1345 }
1346
1347 #[test]
1351 fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() {
1352 let declared = sandbox_with(SandboxEgress::Deny, Vec::new());
1353 declared
1354 .validate_for_platform(Platform::Azure)
1355 .expect_err("declaring ceilings Azure cannot enforce is rejected");
1356
1357 let undeclared = Sandbox::new("sbx".to_string())
1358 .code(SandboxCode::Image {
1359 image: "alpine".to_string(),
1360 })
1361 .egress(SandboxEgress::Deny)
1362 .session(SandboxSessionPolicy {
1363 max_lifetime_seconds: None,
1364 idle_suspend_seconds: None,
1365 })
1366 .build();
1367
1368 undeclared
1369 .validate_for_platform(Platform::Azure)
1370 .expect("a sandbox naming no ceilings takes the platform's own");
1371
1372 assert_eq!(undeclared.resolved_limits().cpu, "1");
1374 }
1375
1376 #[test]
1377 fn preview_ports_require_the_preview_capability() {
1378 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1379
1380 sandbox
1381 .validate_for_platform(Platform::Aws)
1382 .expect("AWS mints a port-scoped JWE");
1383
1384 let error = sandbox
1385 .validate_for_platform(Platform::Kubernetes)
1386 .expect_err("Kubernetes preview is deferred");
1387 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1388 }
1389
1390 #[test]
1391 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1392 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1393 sandbox
1394 .validate_for_platform(Platform::Gcp)
1395 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1396 }
1397
1398 #[test]
1399 fn invalid_quantities_are_rejected_with_the_offending_field() {
1400 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1401 sandbox
1402 .limits
1403 .as_mut()
1404 .expect("the fixture declares limits")
1405 .memory = "2Gb".to_string();
1406
1407 let error = sandbox
1408 .validate_for_platform(Platform::Aws)
1409 .expect_err("Gb is not a valid suffix");
1410 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1411 assert!(error.to_string().contains("memory"));
1412
1413 sandbox
1414 .limits
1415 .as_mut()
1416 .expect("the fixture declares limits")
1417 .memory = "2Gi".to_string();
1418 sandbox
1419 .limits
1420 .as_mut()
1421 .expect("the fixture declares limits")
1422 .cpu = "0".to_string();
1423 let error = sandbox
1424 .validate_for_platform(Platform::Aws)
1425 .expect_err("zero cpu is not a ceiling");
1426 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1427 }
1428
1429 #[test]
1430 fn zero_max_processes_is_rejected() {
1431 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1432 sandbox
1433 .limits
1434 .as_mut()
1435 .expect("the fixture declares limits")
1436 .max_processes = Some(0);
1437
1438 let error = sandbox
1439 .validate_for_platform(Platform::Local)
1440 .expect_err("a sandbox must be able to run at least one process");
1441 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1442 assert!(error.to_string().contains("maxProcesses"));
1443 }
1444
1445 #[test]
1449 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1450 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1451 sandbox
1452 .limits
1453 .as_mut()
1454 .expect("the fixture declares limits")
1455 .max_processes = Some(256);
1456
1457 sandbox
1458 .validate_for_platform(Platform::Local)
1459 .expect("Docker takes a pids limit");
1460
1461 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1462 let error = sandbox
1463 .validate_for_platform(platform)
1464 .expect_err("a process ceiling nothing applies must be refused");
1465 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1466 }
1467 }
1468
1469 #[test]
1473 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1474 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1475
1476 for seconds in [0, 28_801, 100_000] {
1477 sandbox.session.max_lifetime_seconds = Some(seconds);
1478 let error = sandbox
1479 .validate_for_platform(Platform::Aws)
1480 .expect_err("a lifetime outside what AWS runs is refused");
1481 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1482
1483 sandbox
1485 .validate_for_platform(Platform::Kubernetes)
1486 .expect("the kubelet takes any activeDeadlineSeconds");
1487 }
1488
1489 sandbox.session.max_lifetime_seconds = Some(28_800);
1490 sandbox
1491 .validate_for_platform(Platform::Aws)
1492 .expect("the ceiling itself is allowed");
1493 }
1494
1495 #[test]
1500 fn an_image_azure_cannot_pull_is_refused_while_planning() {
1501 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1502 sandbox.limits = None;
1505
1506 for image in [
1507 "ubuntu:24.04",
1508 "ghcr.io/myorg/sandbox:latest",
1509 "ubuntu@sha256:abc",
1510 "",
1511 " ",
1512 "ubuntu latest",
1513 "ubuntu?x",
1514 ] {
1515 sandbox.code = SandboxCode::Image {
1516 image: image.to_string(),
1517 };
1518 let error = sandbox
1519 .validate_for_platform(Platform::Azure)
1520 .expect_err("an image Azure has nowhere to put is refused");
1521 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1522
1523 sandbox
1525 .validate_for_platform(Platform::Kubernetes)
1526 .expect("a registry reference is what every other backend takes");
1527 }
1528
1529 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1530 sandbox.code = SandboxCode::Image {
1531 image: image.to_string(),
1532 };
1533 sandbox
1534 .validate_for_platform(Platform::Azure)
1535 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1536 }
1537
1538 sandbox.code = SandboxCode::Image {
1540 image: " ubuntu ".to_string(),
1541 };
1542 assert_eq!(
1543 sandbox
1544 .azure_catalog_image()
1545 .expect("a padded name is still a name"),
1546 "ubuntu"
1547 );
1548 }
1549
1550 #[test]
1554 fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1555 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1556 sandbox.session.max_lifetime_seconds = Some(3600);
1557
1558 sandbox
1559 .validate_for_platform(Platform::Kubernetes)
1560 .expect("the kubelet enforces activeDeadlineSeconds");
1561 sandbox
1562 .validate_for_platform(Platform::Aws)
1563 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1564
1565 for platform in [Platform::Azure, Platform::Local] {
1566 let error = sandbox
1567 .validate_for_platform(platform)
1568 .expect_err("a deadline nothing applies must be refused");
1569 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1570 }
1571 }
1572
1573 #[test]
1577 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1578 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1579 let tier = sandbox
1580 .microvm_tier()
1581 .expect("2Gi/1cpu/20Gi is satisfiable");
1582
1583 assert_eq!(
1584 tier.peak_memory_mib, 2048,
1585 "the peak is the declared ceiling"
1586 );
1587 assert_eq!(
1588 tier.baseline_memory_mib, 512,
1589 "which is a quarter of it as the baseline"
1590 );
1591 assert!(tier.max_disk_mib <= 20 * 1024);
1592 }
1593
1594 #[test]
1598 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1599 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1600 {
1601 let limits = sandbox
1602 .limits
1603 .as_mut()
1604 .expect("the fixture declares limits");
1605 limits.cpu = "1".to_string();
1606 limits.memory = "8Gi".to_string();
1607 }
1608
1609 let error = sandbox
1610 .microvm_tier()
1611 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1612 assert!(
1613 error.to_string().contains("4 vCPU"),
1614 "the refusal must say what the memory ceiling implies: {error}"
1615 );
1616
1617 sandbox
1618 .limits
1619 .as_mut()
1620 .expect("the fixture declares limits")
1621 .cpu = "4".to_string();
1622 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1623 assert_eq!(tier.peak_memory_mib, 8192);
1624 }
1625
1626 #[test]
1629 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1630 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1631 sandbox
1632 .limits
1633 .as_mut()
1634 .expect("the fixture declares limits")
1635 .memory = "1Gi".to_string();
1636
1637 let error = sandbox
1638 .validate_for_platform(Platform::Aws)
1639 .expect_err("no MicroVM size peaks at or below 1Gi");
1640 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1641 assert!(
1642 error.to_string().contains("2Gi"),
1643 "the refusal must say what the smallest holdable ceiling is: {error}"
1644 );
1645 }
1646
1647 #[test]
1651 fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1652 let sandbox = Sandbox::new("agent".to_string())
1653 .code(SandboxCode::Source {
1654 src: "./sandbox".to_string(),
1655 toolchain: ToolchainConfig::Docker {
1656 dockerfile: None,
1657 build_args: None,
1658 target: None,
1659 },
1660 })
1661 .egress(SandboxEgress::Deny)
1662 .session(SandboxSessionPolicy {
1663 max_lifetime_seconds: None,
1664 idle_suspend_seconds: None,
1665 })
1666 .build();
1667
1668 for platform in [
1669 Platform::Aws,
1670 Platform::Azure,
1671 Platform::Gcp,
1672 Platform::Kubernetes,
1673 Platform::Local,
1674 ] {
1675 let error = sandbox
1676 .validate_for_platform(platform)
1677 .expect_err("no backend builds a sandbox image from source");
1678 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1679 assert!(
1680 error.to_string().contains("code.image"),
1681 "the refusal must say what to write instead: {error}"
1682 );
1683 }
1684 }
1685
1686 #[test]
1689 fn every_accepted_unit_converts_rather_than_falling_back() {
1690 assert_eq!(quantity_mib("2Gi"), Some(2048));
1691 assert_eq!(quantity_mib("512Mi"), Some(512));
1692 assert_eq!(quantity_mib("4G"), Some(3814));
1693 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1694 assert_eq!(millicores("1"), Some(1000));
1695 assert_eq!(millicores("500m"), Some(500));
1696 }
1697
1698 #[test]
1699 fn unknown_fields_are_rejected() {
1700 let json = r#"{
1701 "id": "sbx",
1702 "code": {"type": "image", "image": "ubuntu:24.04"},
1703 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1704 "egress": {"mode": "deny"},
1705 "session": {},
1706 "unexpected": true
1707 }"#;
1708
1709 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1710 }
1711
1712 #[test]
1713 fn serialization_roundtrips() {
1714 let sandbox = sandbox_with(
1715 SandboxEgress::AllowDomains {
1716 domains: vec!["example.com".to_string()],
1717 },
1718 vec![8080, 9090],
1719 );
1720
1721 let json = serde_json::to_string(&sandbox).expect("serializes");
1722 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1723 assert_eq!(sandbox, restored);
1724 }
1725
1726 #[test]
1727 fn id_is_immutable_across_updates() {
1728 let original = sandbox_with(SandboxEgress::Deny, vec![]);
1729 let renamed = Sandbox::new("other".to_string())
1730 .code(SandboxCode::Image {
1731 image: "ubuntu".to_string(),
1732 })
1733 .limits(
1734 original
1735 .limits
1736 .clone()
1737 .expect("the fixture declares limits"),
1738 )
1739 .egress(SandboxEgress::Deny)
1740 .session(SandboxSessionPolicy {
1741 max_lifetime_seconds: None,
1742 idle_suspend_seconds: None,
1743 })
1744 .build();
1745
1746 original
1747 .validate_update(&original.clone())
1748 .expect("an unchanged config is a valid update");
1749 original
1750 .validate_update(&renamed)
1751 .expect_err("renaming a sandbox is not an update");
1752 }
1753
1754 #[test]
1760 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
1761 let with_policy = |session: SandboxSessionPolicy| {
1762 Sandbox::new("sbx".to_string())
1763 .code(SandboxCode::Image {
1764 image: "ubuntu".to_string(),
1765 })
1766 .egress(SandboxEgress::Allow)
1767 .session(session)
1768 .build()
1769 .validate_for_platform(Platform::Azure)
1770 };
1771
1772 with_policy(SandboxSessionPolicy {
1773 max_lifetime_seconds: None,
1774 idle_suspend_seconds: Some(900),
1775 })
1776 .expect("Azure suspends a session on idle");
1777
1778 let error = with_policy(SandboxSessionPolicy {
1779 max_lifetime_seconds: Some(3600),
1780 idle_suspend_seconds: None,
1781 })
1782 .expect_err("Azure has no wall-clock ceiling to enforce one with");
1783 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1784 assert!(
1785 error.message.contains("sessionLifetime"),
1786 "names the capability: {}",
1787 error.message
1788 );
1789 }
1790
1791 #[test]
1797 fn an_allowlist_with_no_domains_is_refused() {
1798 let declared = |domains: Vec<String>| {
1799 Sandbox::new("sbx".to_string())
1800 .code(SandboxCode::Image {
1801 image: "ubuntu".to_string(),
1802 })
1803 .egress(SandboxEgress::AllowDomains { domains })
1804 .session(SandboxSessionPolicy {
1805 max_lifetime_seconds: None,
1806 idle_suspend_seconds: None,
1807 })
1808 .build()
1809 .validate_for_platform(Platform::Azure)
1810 };
1811
1812 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
1813 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1814
1815 declared(vec!["api.example.com".to_string()])
1816 .expect("a named domain is what an allowlist is for");
1817 }
1818
1819 #[test]
1822 fn internet_access_switch_maps_only_the_two_expressible_modes() {
1823 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
1824 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
1825 assert_eq!(
1826 SandboxEgress::AllowDomains {
1827 domains: vec!["api.example.com".to_string()]
1828 }
1829 .internet_access_switch(),
1830 None,
1831 "a host list has no boolean and must not be approximated"
1832 );
1833 }
1834}