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 sha2::{Digest, Sha256};
18use std::any::Any;
19use std::fmt::Debug;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24#[serde(rename_all = "camelCase", tag = "type")]
25pub enum SandboxCode {
26 #[serde(rename_all = "camelCase")]
28 Image {
29 image: String,
33 },
34 #[serde(rename_all = "camelCase")]
39 Source {
40 src: String,
42 toolchain: ToolchainConfig,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct SandboxLimits {
56 pub cpu: String,
58 pub memory: String,
60 pub disk: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub max_processes: Option<u32>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct MicrovmTier {
78 pub baseline_memory_mib: i64,
80 pub peak_memory_mib: i64,
82 pub peak_vcpu: u32,
84 pub max_disk_mib: i64,
86}
87
88const AWS_MAX_LIFETIME_SECONDS: u32 = 28_800;
92
93const AZURE_CPU_STEP_MILLICORES: i64 = 250;
96const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
97const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
98const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
99
100const MICROVM_TIERS: &[MicrovmTier] = &[
101 MicrovmTier {
102 baseline_memory_mib: 512,
103 peak_memory_mib: 2048,
104 peak_vcpu: 1,
105 max_disk_mib: 8192,
106 },
107 MicrovmTier {
108 baseline_memory_mib: 1024,
109 peak_memory_mib: 4096,
110 peak_vcpu: 2,
111 max_disk_mib: 8192,
112 },
113 MicrovmTier {
114 baseline_memory_mib: 2048,
115 peak_memory_mib: 8192,
116 peak_vcpu: 4,
117 max_disk_mib: 8192,
118 },
119 MicrovmTier {
120 baseline_memory_mib: 4096,
121 peak_memory_mib: 16384,
122 peak_vcpu: 8,
123 max_disk_mib: 16384,
124 },
125 MicrovmTier {
126 baseline_memory_mib: 8192,
127 peak_memory_mib: 32768,
128 peak_vcpu: 16,
129 max_disk_mib: 32768,
130 },
131];
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
136#[serde(rename_all = "camelCase", tag = "mode")]
137pub enum SandboxEgress {
138 Deny,
147 Allow,
154 #[serde(rename_all = "camelCase")]
161 AllowDomains {
162 domains: Vec<String>,
164 },
165}
166
167impl SandboxEgress {
168 pub fn internet_access_switch(&self) -> Option<bool> {
175 match self {
176 SandboxEgress::Allow => Some(true),
177 SandboxEgress::Deny => Some(false),
178 SandboxEgress::AllowDomains { .. } => None,
179 }
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct SandboxLifecyclePolicy {
191 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub max_lifetime_seconds: Option<u32>,
199 #[serde(alias = "idleSuspendSeconds", skip_serializing_if = "Option::is_none")]
203 pub idle_pause_seconds: Option<u32>,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct SandboxCapabilities {
215 pub files: bool,
217 pub reconnect: bool,
219 pub jobs: bool,
222 pub preview: bool,
224 pub pause_resume: bool,
226 pub snapshot: bool,
228 pub domain_egress_rules: bool,
230 pub egress_deny: bool,
233 pub enforced_limits: bool,
235 pub process_limit: bool,
237 pub sandbox_lifetime: bool,
239 pub supervisor_pid_namespace: bool,
245 pub supervisor_isolation: bool,
251}
252
253impl SandboxCapabilities {
254 pub fn for_platform(platform: Platform) -> Result<Self> {
260 match platform {
261 Platform::Aws => Ok(Self {
262 files: true,
263 reconnect: true,
264 jobs: true,
265 preview: true,
266 pause_resume: true,
267 snapshot: false,
268 domain_egress_rules: false,
269 egress_deny: true,
272 enforced_limits: true,
273 process_limit: false,
275 sandbox_lifetime: true,
278 supervisor_pid_namespace: false,
283 supervisor_isolation: true,
286 }),
287 Platform::Azure => Ok(Self::azure()),
288 Platform::Gcp => Ok(Self::gcp_agent_platform()),
289 Platform::Kubernetes => Ok(Self {
292 files: true,
293 reconnect: true,
294 jobs: true,
295 preview: false,
296 pause_resume: false,
297 snapshot: false,
298 domain_egress_rules: false,
299 egress_deny: true,
300 enforced_limits: true,
301 process_limit: false,
303 sandbox_lifetime: true,
305 supervisor_pid_namespace: false,
309 supervisor_isolation: false,
314 }),
315 Platform::Local => Ok(Self {
316 files: true,
317 reconnect: true,
318 jobs: false,
320 preview: true,
321 pause_resume: false,
322 snapshot: false,
323 domain_egress_rules: false,
324 egress_deny: true,
325 enforced_limits: true,
326 process_limit: true,
328 sandbox_lifetime: false,
329 supervisor_pid_namespace: false,
332 supervisor_isolation: true,
336 }),
337 Platform::Machines | Platform::Test => {
338 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
339 platform: platform.to_string(),
340 }))
341 }
342 }
343 }
344
345 pub fn azure() -> Self {
347 Self {
348 files: true,
349 reconnect: true,
350 jobs: false,
352 preview: false,
357 pause_resume: true,
358 snapshot: false,
362 domain_egress_rules: true,
363 egress_deny: true,
364 enforced_limits: true,
368 process_limit: false,
369 sandbox_lifetime: false,
373 supervisor_pid_namespace: false,
375 supervisor_isolation: false,
378 }
379 }
380
381 pub fn gcp_agent_platform() -> Self {
383 Self {
384 files: true,
386 reconnect: true,
390 jobs: true,
391 preview: false,
393 pause_resume: false,
396 snapshot: false,
399 domain_egress_rules: false,
401 egress_deny: true,
403 enforced_limits: true,
407 process_limit: false,
409 sandbox_lifetime: true,
411 supervisor_pid_namespace: false,
413 supervisor_isolation: false,
416 }
417 }
418
419 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
421 let available = match capability {
422 SandboxCapability::Files => self.files,
423 SandboxCapability::Reconnect => self.reconnect,
424 SandboxCapability::Jobs => self.jobs,
425 SandboxCapability::Preview => self.preview,
426 SandboxCapability::PauseResume => self.pause_resume,
427 SandboxCapability::Snapshot => self.snapshot,
428 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
429 SandboxCapability::EgressDeny => self.egress_deny,
430 SandboxCapability::EnforcedLimits => self.enforced_limits,
431 SandboxCapability::ProcessLimit => self.process_limit,
432 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
433 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
434 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
435 };
436
437 if available {
438 return Ok(());
439 }
440
441 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
442 capability: capability.as_str().to_string(),
443 platform: platform.to_string(),
444 }))
445 }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
451#[serde(rename_all = "camelCase")]
452pub enum SandboxCapability {
453 Files,
455 Reconnect,
457 Jobs,
459 Preview,
461 PauseResume,
463 Snapshot,
465 DomainEgressRules,
467 EgressDeny,
469 EnforcedLimits,
471 ProcessLimit,
473 SandboxLifetime,
475 SupervisorPidNamespace,
477 SupervisorIsolation,
479}
480
481impl SandboxCapability {
482 pub fn as_str(&self) -> &'static str {
484 match self {
485 Self::Files => "files",
486 Self::Reconnect => "reconnect",
487 Self::Jobs => "jobs",
488 Self::Preview => "preview",
489 Self::PauseResume => "pauseResume",
490 Self::Snapshot => "snapshot",
491 Self::DomainEgressRules => "domainEgressRules",
492 Self::EgressDeny => "egressDeny",
493 Self::EnforcedLimits => "enforcedLimits",
494 Self::ProcessLimit => "processLimit",
495 Self::SandboxLifetime => "sandboxLifetime",
496 Self::SupervisorPidNamespace => "supervisorPidNamespace",
497 Self::SupervisorIsolation => "supervisorIsolation",
498 }
499 }
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
505#[serde(rename_all = "camelCase", deny_unknown_fields)]
506pub struct SandboxPrivilegedSupervisor {
507 pub command_uid: u32,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
513#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515#[builder(start_fn = new)]
516pub struct Sandbox {
517 #[builder(start_fn)]
520 pub id: String,
521 pub code: SandboxCode,
523 #[serde(skip_serializing_if = "Option::is_none")]
527 pub private_base_image: Option<String>,
528 #[serde(skip_serializing_if = "Option::is_none")]
534 pub limits: Option<SandboxLimits>,
535 pub egress: SandboxEgress,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub privileged_supervisor: Option<SandboxPrivilegedSupervisor>,
541 #[serde(alias = "session")]
546 pub lifecycle: SandboxLifecyclePolicy,
547 #[builder(default)]
551 #[serde(default, skip_serializing_if = "Vec::is_empty")]
552 pub preview_ports: Vec<u16>,
553}
554
555pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
560 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
561}
562
563pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
569 stack.resources().any(|(_resource_id, resource)| {
570 resource
571 .config
572 .downcast_ref::<Sandbox>()
573 .is_some_and(|sandbox| !matches!(sandbox.cloud_egress(), SandboxEgress::Allow))
574 })
575}
576
577impl Sandbox {
578 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
580
581 pub fn id(&self) -> &str {
583 &self.id
584 }
585
586 pub fn cloud_egress(&self) -> &SandboxEgress {
588 if self.privileged_supervisor.is_some() {
589 &SandboxEgress::Allow
590 } else {
591 &self.egress
592 }
593 }
594
595 pub fn supervisor_environment(&self) -> std::collections::BTreeMap<String, String> {
597 let mut env = std::collections::BTreeMap::new();
598 if let Some(supervisor) = &self.privileged_supervisor {
599 env.insert(
600 "ALIEN_SANDBOX_EXEC_UID".to_string(),
601 supervisor.command_uid.to_string(),
602 );
603 env.insert(
604 "ALIEN_SANDBOX_EXEC_GID".to_string(),
605 supervisor.command_uid.to_string(),
606 );
607 env.insert(
608 "ALIEN_SANDBOX_EGRESS".to_string(),
609 serde_json::to_string(&self.egress).expect("egress serializes"),
610 );
611 }
612 env
613 }
614
615 pub fn resolved_limits(&self) -> SandboxLimits {
621 self.limits.clone().unwrap_or_else(default_limits)
622 }
623
624 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
629 let mut capabilities = SandboxCapabilities::for_platform(platform)?;
630 if let Some(supervisor) = &self.privileged_supervisor {
631 if platform != Platform::Aws {
632 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
633 capability: "privilegedSupervisor".to_string(),
634 platform: platform.to_string(),
635 }));
636 }
637 if supervisor.command_uid == 0 || supervisor.command_uid == u32::MAX {
638 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
639 resource_id: self.id.clone(),
640 field: "privilegedSupervisor.commandUid".to_string(),
641 value: supervisor.command_uid.to_string(),
642 reason: "must be a non-root Linux uid other than the invalid uid sentinel"
643 .to_string(),
644 }));
645 }
646 if let SandboxEgress::AllowDomains { domains } = &self.egress {
647 for domain in domains {
648 let hostname = domain.strip_suffix('.').unwrap_or(domain);
649 if hostname.len() > 253
650 || !hostname.split('.').all(|label| {
651 !label.is_empty()
652 && label.len() <= 63
653 && label
654 .bytes()
655 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
656 && !label.starts_with('-')
657 && !label.ends_with('-')
658 })
659 {
660 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
661 resource_id: self.id.clone(), field: "egress.domains".to_string(), value: domain.clone(),
662 reason: "privileged supervisor allowlists require exact DNS hostnames; wildcards are unsupported".to_string(),
663 }));
664 }
665 }
666 }
667 capabilities.domain_egress_rules = true;
668 }
669
670 if matches!(&self.code, SandboxCode::Source { .. }) && platform != Platform::Aws {
674 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
675 resource_id: self.id.clone(),
676 field: "code".to_string(),
677 value: "source".to_string(),
678 reason: format!(
679 "no sandbox backend builds an image from source on {platform}; give \
680 code.image a prebuilt reference"
681 ),
682 }));
683 }
684
685 if self.private_base_image.is_some() && platform != Platform::Aws {
688 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
689 capability: "privateBaseImage".to_string(),
690 platform: platform.to_string(),
691 }));
692 }
693
694 if platform == Platform::Azure {
696 self.azure_image()?;
697 }
698
699 let Some(limits) = self.limits.as_ref() else {
700 return self.validate_capabilities(&capabilities, platform);
702 };
703
704 validate_quantity(&self.id, "cpu", &limits.cpu)?;
705 validate_quantity(&self.id, "memory", &limits.memory)?;
706 validate_quantity(&self.id, "disk", &limits.disk)?;
707
708 if let Some(max_processes) = limits.max_processes {
709 if max_processes == 0 {
710 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
711 resource_id: self.id.clone(),
712 field: "maxProcesses".to_string(),
713 value: "0".to_string(),
714 reason: "a sandbox that may run no processes cannot run code".to_string(),
715 }));
716 }
717 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
718 }
719
720 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
723
724 if platform == Platform::Azure {
725 self.azure_sandbox_limits()?;
726 }
727
728 if platform == Platform::Aws {
729 self.microvm_tier()?;
732
733 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
738 if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
739 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
740 resource_id: self.id.clone(),
741 field: "maxLifetimeSeconds".to_string(),
742 value: seconds.to_string(),
743 reason: format!(
744 "AWS runs a MicroVM for between 1 and \
745 {AWS_MAX_LIFETIME_SECONDS} seconds"
746 ),
747 }));
748 }
749 }
750 }
751
752 self.validate_capabilities(&capabilities, platform)
753 }
754
755 pub fn azure_image(&self) -> Result<AzureSandboxImage<'_>> {
759 let SandboxCode::Image { image } = &self.code else {
760 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
761 resource_id: self.id.clone(),
762 field: "code".to_string(),
763 value: "source".to_string(),
764 reason: "no sandbox backend builds an image from source yet".to_string(),
765 }));
766 };
767
768 classify_azure_sandbox_image(image).ok_or_else(|| {
769 let image = image.trim();
770 AlienError::new(ErrorData::SandboxLimitInvalid {
771 resource_id: self.id.clone(),
772 field: "code.image".to_string(),
773 value: image.to_string(),
774 reason: if image.is_empty() {
775 "a sandbox has to name an image".to_string()
776 } else {
777 "Azure creates a sandbox from a catalog name such as 'ubuntu' or from a \
778 registry image such as 'docker.io/library/python:3.14-slim'"
779 .to_string()
780 },
781 })
782 })
783 }
784
785 pub fn azure_sandbox_limits(&self) -> Result<()> {
789 let Some(limits) = self.limits.as_ref() else {
790 return Ok(());
793 };
794
795 let refused = |field: &str, value: &str, reason: &str| {
796 AlienError::new(ErrorData::SandboxLimitInvalid {
797 resource_id: self.id.clone(),
798 field: field.to_string(),
799 value: value.to_string(),
800 reason: reason.to_string(),
801 })
802 };
803
804 let cpu_millicores = millicores(&limits.cpu)
805 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
806
807 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
811 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
812 {
813 return Err(refused(
814 "cpu",
815 &limits.cpu,
816 "Azure allocates cpu in steps of 250m from 250m to 16000m",
817 ));
818 }
819
820 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
822 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
823
824 let memory_mib = quantity_mib(&limits.memory)
825 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
826 if memory_mib > memory_ceiling_mib {
827 return Err(refused(
828 "memory",
829 &limits.memory,
830 &format!(
831 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
832 at the declared cpu"
833 ),
834 ));
835 }
836
837 let disk_mib = quantity_mib(&limits.disk)
838 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
839 if disk_mib > disk_ceiling_mib {
840 return Err(refused(
841 "disk",
842 &limits.disk,
843 &format!(
844 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
845 the declared cpu"
846 ),
847 ));
848 }
849
850 Ok(())
851 }
852
853 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
860 let Some(limits) = self.limits.as_ref() else {
861 return Ok(MICROVM_TIERS[2]);
863 };
864
865 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
866 AlienError::new(ErrorData::SandboxLimitInvalid {
867 resource_id: self.id.clone(),
868 field: "memory".to_string(),
869 value: limits.memory.clone(),
870 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
871 })
872 })?;
873 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
874 AlienError::new(ErrorData::SandboxLimitInvalid {
875 resource_id: self.id.clone(),
876 field: "disk".to_string(),
877 value: limits.disk.clone(),
878 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
879 })
880 })?;
881 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
882 AlienError::new(ErrorData::SandboxLimitInvalid {
883 resource_id: self.id.clone(),
884 field: "cpu".to_string(),
885 value: limits.cpu.clone(),
886 reason: "expected cores or millicores".to_string(),
887 })
888 })?;
889
890 let sized = |tier: &&MicrovmTier| {
895 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
896 };
897
898 let tier = MICROVM_TIERS
899 .iter()
900 .rev()
901 .find(sized)
902 .copied()
903 .ok_or_else(|| {
904 AlienError::new(ErrorData::SandboxLimitInvalid {
905 resource_id: self.id.clone(),
906 field: "memory".to_string(),
907 value: limits.memory.clone(),
908 reason: format!(
909 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
910 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
911 disk fit no size",
912 limits.memory, limits.disk
913 ),
914 })
915 })?;
916
917 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
918 if cpu_millicores < required_millicores {
919 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
920 resource_id: self.id.clone(),
921 field: "cpu".to_string(),
922 value: limits.cpu.clone(),
923 reason: format!(
924 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
925 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
926 limits.memory, tier.peak_vcpu, tier.peak_vcpu
927 ),
928 }));
929 }
930
931 Ok(tier)
932 }
933
934 fn validate_capabilities(
936 &self,
937 capabilities: &SandboxCapabilities,
938 platform: Platform,
939 ) -> Result<()> {
940 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
941 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
942 }
943
944 if let SandboxEgress::AllowDomains { domains } = &self.egress {
950 if domains.is_empty() {
951 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
952 resource_id: self.id.clone(),
953 field: "egress.domains".to_string(),
954 value: "[]".to_string(),
955 reason: "an allowlist naming no domain denies everything; declare \
956 egress: deny if that is what was meant"
957 .to_string(),
958 }));
959 }
960 }
961
962 if matches!(self.egress, SandboxEgress::Deny) {
963 capabilities.require(SandboxCapability::EgressDeny, platform)?;
964 }
965
966 if !self.preview_ports.is_empty() {
967 capabilities.require(SandboxCapability::Preview, platform)?;
968 }
969
970 if self.lifecycle.idle_pause_seconds.is_some() {
971 capabilities.require(SandboxCapability::PauseResume, platform)?;
972 }
973
974 if self.lifecycle.max_lifetime_seconds.is_some() {
975 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
976 }
977
978 Ok(())
979 }
980}
981
982fn default_limits() -> SandboxLimits {
987 SandboxLimits {
988 cpu: "1".to_string(),
989 memory: "2Gi".to_string(),
990 disk: "8Gi".to_string(),
991 max_processes: None,
992 }
993}
994
995fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
997 let invalid = |reason: &str| {
998 AlienError::new(ErrorData::SandboxLimitInvalid {
999 resource_id: resource_id.to_string(),
1000 field: field.to_string(),
1001 value: value.to_string(),
1002 reason: reason.to_string(),
1003 })
1004 };
1005
1006 let digits_end = value
1007 .find(|c: char| !c.is_ascii_digit() && c != '.')
1008 .unwrap_or(value.len());
1009 let (number, suffix) = value.split_at(digits_end);
1010
1011 let parsed: f64 = number
1012 .parse()
1013 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
1014
1015 if parsed <= 0.0 {
1016 return Err(invalid("must be greater than zero"));
1017 }
1018
1019 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
1020 if !SUFFIXES.contains(&suffix) {
1021 return Err(invalid(
1022 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
1023 ));
1024 }
1025
1026 Ok(())
1027}
1028
1029fn split_quantity(value: &str) -> Option<(f64, &str)> {
1031 let trimmed = value.trim();
1032 let digits_end = trimmed
1033 .find(|c: char| !c.is_ascii_digit() && c != '.')
1034 .unwrap_or(trimmed.len());
1035 let (number, suffix) = trimmed.split_at(digits_end);
1036 number.parse().ok().map(|number| (number, suffix))
1037}
1038
1039pub fn quantity_mib(value: &str) -> Option<i64> {
1045 let (number, suffix) = split_quantity(value)?;
1046 let bytes = match suffix {
1047 "" => number,
1048 "k" => number * 1e3,
1049 "M" => number * 1e6,
1050 "G" => number * 1e9,
1051 "T" => number * 1e12,
1052 "Ki" => number * 1024.0,
1053 "Mi" => number * 1024.0 * 1024.0,
1054 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
1055 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
1056 _ => return None,
1058 };
1059 Some((bytes / (1024.0 * 1024.0)) as i64)
1060}
1061
1062pub fn millicores(value: &str) -> Option<i64> {
1064 let (number, suffix) = split_quantity(value)?;
1065 match suffix {
1066 "" => Some((number * 1000.0) as i64),
1067 "m" => Some(number as i64),
1068 _ => None,
1069 }
1070}
1071
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1074#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1075#[serde(rename_all = "camelCase")]
1076pub struct SandboxOutputs {
1077 pub parent_name: String,
1079 #[serde(skip_serializing_if = "Option::is_none")]
1081 pub identifier: Option<String>,
1082 #[serde(skip_serializing_if = "Option::is_none")]
1084 pub endpoint: Option<String>,
1085}
1086
1087impl ResourceOutputsDefinition for SandboxOutputs {
1088 fn get_resource_type(&self) -> ResourceType {
1089 Sandbox::RESOURCE_TYPE
1090 }
1091
1092 fn as_any(&self) -> &dyn Any {
1093 self
1094 }
1095
1096 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1097 Box::new(self.clone())
1098 }
1099
1100 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1101 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1102 }
1103
1104 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1105 serde_json::to_value(self)
1106 }
1107}
1108
1109impl ResourceDefinition for Sandbox {
1110 fn get_resource_type(&self) -> ResourceType {
1111 Self::RESOURCE_TYPE
1112 }
1113
1114 fn id(&self) -> &str {
1115 &self.id
1116 }
1117
1118 fn replace_after_failed_create_is_safe(&self) -> bool {
1119 false
1122 }
1123
1124 fn get_dependencies(&self) -> Vec<ResourceRef> {
1125 Vec::new()
1126 }
1127
1128 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1129 let new_sandbox = new_config
1130 .as_any()
1131 .downcast_ref::<Sandbox>()
1132 .ok_or_else(|| {
1133 AlienError::new(ErrorData::UnexpectedResourceType {
1134 resource_id: self.id.clone(),
1135 expected: Self::RESOURCE_TYPE,
1136 actual: new_config.get_resource_type(),
1137 })
1138 })?;
1139
1140 if self.id != new_sandbox.id {
1141 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1142 resource_id: self.id.clone(),
1143 reason: "the 'id' field is immutable".to_string(),
1144 }));
1145 }
1146
1147 Ok(())
1148 }
1149
1150 fn as_any(&self) -> &dyn Any {
1151 self
1152 }
1153
1154 fn as_any_mut(&mut self) -> &mut dyn Any {
1155 self
1156 }
1157
1158 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1159 Box::new(self.clone())
1160 }
1161
1162 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1163 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1164 }
1165
1166 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1167 serde_json::to_value(self)
1168 }
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1173pub enum AzureSandboxImage<'a> {
1174 Catalog(&'a str),
1176 Registry(&'a str),
1179}
1180
1181impl<'a> AzureSandboxImage<'a> {
1182 pub fn as_str(&self) -> &'a str {
1184 match self {
1185 Self::Catalog(value) | Self::Registry(value) => value,
1186 }
1187 }
1188}
1189
1190pub const AZURE_DISK_IMAGE_LABEL: &str = "alienImage";
1192
1193pub fn classify_azure_sandbox_image(image: &str) -> Option<AzureSandboxImage<'_>> {
1197 let image = image.trim();
1198 if image.is_empty() {
1199 return None;
1200 }
1201 if image
1202 .chars()
1203 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1204 {
1205 return Some(AzureSandboxImage::Catalog(image));
1206 }
1207 (image.contains(|c| matches!(c, '/' | ':' | '@')) && OCI_REFERENCE.is_match(image))
1208 .then_some(AzureSandboxImage::Registry(image))
1209}
1210
1211pub fn azure_disk_image_label(reference: &str) -> String {
1214 let digest = format!("{:x}", Sha256::digest(reference.trim().as_bytes()));
1215 digest[..32].to_string()
1216}
1217
1218static OCI_REFERENCE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1220 let domain_component = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
1221 let domain = format!(r"{domain_component}(?:\.{domain_component})*(?::[0-9]+)?");
1222 let path_component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
1223 let name = format!(r"(?:{domain}/)?{path_component}(?:/{path_component})*");
1224 let tag = r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}";
1225 let digest = r"[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,}";
1226 regex::Regex::new(&format!(r"^{name}(?::{tag})?(?:@{digest})?$"))
1227 .expect("the OCI reference grammar compiles")
1228});
1229
1230pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1238pub enum BundleUri<'a> {
1239 Literal(&'a str),
1241 Regional { before: &'a str, after: &'a str },
1244}
1245
1246pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1250 let (above_file, _) = key.rsplit_once('/')?;
1251 let (above_version, _) = above_file.rsplit_once('/')?;
1252 Some(above_version)
1253}
1254
1255pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1261 let path = uri
1262 .strip_prefix("s3://")
1263 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1264 let (bucket, key) = path
1265 .split_once('/')
1266 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1267
1268 if path.contains('*') || path.contains('?') {
1272 return Err(format!(
1273 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1274 role's grant, so '*' and '?' would widen it past the bundle"
1275 ));
1276 }
1277
1278 if key.contains('{') || key.contains('}') {
1279 return Err(format!(
1280 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1281 bucket name alone"
1282 ));
1283 }
1284
1285 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1286 if bucket.contains('{') || bucket.contains('}') {
1287 return Err(format!(
1288 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1289 only one"
1290 ));
1291 }
1292 return Ok(BundleUri::Literal(uri));
1293 };
1294
1295 if after.contains(BUNDLE_REGION_TOKEN) {
1296 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1297 }
1298 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1299 return Err(format!(
1300 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1301 ));
1302 }
1303
1304 Ok(BundleUri::Regional {
1305 before: &uri[.."s3://".len() + before.len()],
1306 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1307 })
1308}
1309
1310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312pub enum EcrImageRegion<'a> {
1313 Literal(&'a str),
1315 Deployment,
1317}
1318
1319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1321pub struct EcrImageRepository<'a> {
1322 pub account_id: &'a str,
1323 pub region: EcrImageRegion<'a>,
1324 pub repository: &'a str,
1326}
1327
1328impl EcrImageRepository<'_> {
1329 pub fn arn(&self, partition: &str, region: &str) -> String {
1332 let region = match self.region {
1333 EcrImageRegion::Literal(region) => region,
1334 EcrImageRegion::Deployment => region,
1335 };
1336 format!(
1337 "arn:{partition}:ecr:{region}:{}:repository/{}",
1338 self.account_id, self.repository
1339 )
1340 }
1341}
1342
1343pub fn parse_ecr_image_repository(
1349 image: &str,
1350) -> std::result::Result<EcrImageRepository<'_>, String> {
1351 let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1352 let (host, path) = image
1353 .split_once('/')
1354 .ok_or_else(|| refuse("names no repository"))?;
1355 let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1356 refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1357 })?;
1358 let region = rest
1360 .strip_suffix(".amazonaws.com.cn")
1361 .or_else(|| rest.strip_suffix(".amazonaws.com"))
1362 .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1363 if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1364 return Err(refuse("names no 12-digit account in its registry host"));
1365 }
1366 let region = if region == BUNDLE_REGION_TOKEN {
1367 EcrImageRegion::Deployment
1368 } else if !region.is_empty()
1369 && region
1370 .bytes()
1371 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1372 {
1373 EcrImageRegion::Literal(region)
1374 } else {
1375 return Err(refuse(&format!(
1376 "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1377 )));
1378 };
1379
1380 let repository = match path.split_once('@') {
1381 Some((repository, _digest)) => repository,
1382 None => match path.rsplit_once('/') {
1383 Some((parent, last)) => match last.split_once(':') {
1384 Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1385 None => path,
1386 },
1387 None => path.split_once(':').map_or(path, |(name, _tag)| name),
1388 },
1389 };
1390 let valid_segment = |segment: &str| {
1391 !segment.is_empty()
1392 && segment.bytes().all(|b| {
1393 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1394 })
1395 };
1396 if !repository.split('/').all(valid_segment) {
1397 return Err(refuse(
1398 "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1399 ));
1400 }
1401 Ok(EcrImageRepository {
1402 account_id,
1403 region,
1404 repository,
1405 })
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn private_database_setup_accepts_the_default_network() {
1414 for lifecycle in [
1415 crate::ResourceLifecycle::Frozen,
1416 crate::ResourceLifecycle::Live,
1417 ] {
1418 let stack = crate::Stack::new("database".to_string())
1419 .add(
1420 crate::Postgres::new("metadata".to_string()).build(),
1421 lifecycle,
1422 )
1423 .build();
1424 assert!(!restricts_network_mode(&stack, false));
1425 assert!(!restricts_network_mode(&stack, true));
1426 }
1427 assert!(!restricts_network_mode(
1428 &crate::Stack::new("empty".to_string()).build(),
1429 false,
1430 ));
1431 }
1432
1433 fn repository_key(image: &str) -> String {
1436 match parse_ecr_image_repository(image) {
1437 Ok(parsed) => {
1438 let host = image.split_once('/').map_or(image, |(host, _)| host);
1439 format!("{host}/{}", parsed.repository)
1440 }
1441 Err(_) => image.to_string(),
1442 }
1443 }
1444
1445 #[test]
1446 fn a_private_base_image_names_one_repository() {
1447 let vectors: serde_json::Value = serde_json::from_str(include_str!(
1448 "../../tests/fixtures/ecr-image-repository-parity.json"
1449 ))
1450 .expect("the ECR repository vectors must be JSON");
1451 let field = |case: &serde_json::Value, name: &str| {
1452 case[name]
1453 .as_str()
1454 .unwrap_or_else(|| panic!("vector must carry {name}: {case}"))
1455 .to_string()
1456 };
1457
1458 for case in vectors["accepted"].as_array().expect("accepted vectors") {
1459 let image = field(case, "image");
1460 let region = field(case, "region");
1461 let region = if region == BUNDLE_REGION_TOKEN {
1462 EcrImageRegion::Deployment
1463 } else {
1464 EcrImageRegion::Literal(®ion)
1465 };
1466 assert_eq!(
1467 parse_ecr_image_repository(&image),
1468 Ok(EcrImageRepository {
1469 account_id: &field(case, "accountId"),
1470 region,
1471 repository: &field(case, "repository"),
1472 }),
1473 "{image}"
1474 );
1475 assert_eq!(
1476 repository_key(&image),
1477 field(case, "repositoryKey"),
1478 "{image}"
1479 );
1480 }
1481
1482 for case in vectors["refused"].as_array().expect("refused vectors") {
1483 let image = field(case, "image");
1484 assert!(
1485 parse_ecr_image_repository(&image).is_err(),
1486 "{image} must be refused"
1487 );
1488 assert_eq!(
1489 repository_key(&image),
1490 field(case, "repositoryKey"),
1491 "{image}"
1492 );
1493 }
1494 }
1495
1496 #[test]
1497 fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1498 let regional =
1499 parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1500 .expect("parses");
1501 let pinned =
1502 parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1503 .expect("parses");
1504 assert_eq!(
1505 regional.arn("aws-us-gov", "us-gov-west-1"),
1506 "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1507 );
1508 assert_eq!(
1509 pinned.arn("aws", "us-east-1"),
1510 "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1511 );
1512 }
1513
1514 #[test]
1517 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1518 for uri in [
1519 "s3://acme/team-*/v1/bundle.zip",
1520 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1521 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1522 ] {
1523 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1524 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1525 }
1526
1527 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1528 .expect("an ordinary key still parses");
1529 }
1530
1531 #[test]
1535 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1536 assert_eq!(
1537 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1538 Some("sandbox-bundle")
1539 );
1540 assert_eq!(
1541 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1542 Some("artifacts/team-a/sandbox"),
1543 "a deeper key narrows the prefix, it never widens to the first segment"
1544 );
1545
1546 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1549 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1550 }
1551
1552 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1553 Sandbox::new("agent-sbx".to_string())
1554 .code(SandboxCode::Image {
1555 image: "ubuntu".to_string(),
1556 })
1557 .limits(SandboxLimits {
1558 cpu: "1".to_string(),
1559 memory: "2Gi".to_string(),
1560 disk: "20Gi".to_string(),
1561 max_processes: None,
1562 })
1563 .egress(egress)
1564 .lifecycle(SandboxLifecyclePolicy {
1565 max_lifetime_seconds: None,
1566 idle_pause_seconds: None,
1567 })
1568 .preview_ports(preview_ports)
1569 .build()
1570 }
1571
1572 #[test]
1575 fn a_uri_without_a_token_is_carried_whole() {
1576 assert_eq!(
1577 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1578 Ok(BundleUri::Literal(
1579 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1580 ))
1581 );
1582 }
1583
1584 #[test]
1587 fn a_regional_uri_splits_either_side_of_the_token() {
1588 let BundleUri::Regional { before, after } =
1589 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1590 .expect("the token is accepted in the bucket")
1591 else {
1592 panic!("a bucket-position token must split");
1593 };
1594
1595 assert_eq!(before, "s3://acme-artifacts-");
1596 assert_eq!(after, "/agents/bundle.zip");
1597 assert_eq!(
1598 format!("{before}us-east-2{after}"),
1599 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1600 "the halves must rejoin to the URI the vendor meant"
1601 );
1602 }
1603
1604 #[test]
1607 fn a_token_this_build_cannot_resolve_is_refused() {
1608 for uri in [
1609 "s3://acme-artifacts-{regio}/bundle.zip",
1610 "s3://acme-artifacts/{region}/bundle.zip",
1611 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1612 "s3://acme-artifacts/bundle-{version}.zip",
1613 "s3://acme}-artifacts-{region}/bundle.zip",
1614 "s3://acme{-artifacts-{region}/bundle.zip",
1615 ] {
1616 assert!(
1617 parse_bundle_uri(uri).is_err(),
1618 "'{uri}' must be refused before it can reach an image build"
1619 );
1620 }
1621 }
1622
1623 #[test]
1624 fn resource_type_is_stable() {
1625 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1626 }
1627
1628 #[test]
1629 fn capability_sets_are_per_platform() {
1630 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1631 assert!(
1632 gcp.reconnect,
1633 "generation from the container boot id makes a sandbox reachable across processes"
1634 );
1635 assert!(!gcp.preview);
1636 assert!(gcp.enforced_limits);
1637
1638 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1639 assert!(azure.files, "every backend moves files");
1640 assert!(gcp.files);
1641 assert!(azure.domain_egress_rules);
1644 assert!(azure.egress_deny);
1645 assert!(azure.enforced_limits);
1648 assert!(azure.pause_resume);
1649 assert!(!azure.snapshot);
1653 assert!(!azure.preview);
1654
1655 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1656 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1657 assert!(aws.pause_resume);
1658
1659 let k8s =
1660 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1661 assert!(
1662 !k8s.preview,
1663 "the sandbox-scoped ingress gateway does not exist yet"
1664 );
1665 }
1666
1667 #[test]
1676 fn supervisor_isolation_is_per_platform() {
1677 let value = |platform| {
1678 SandboxCapabilities::for_platform(platform)
1679 .expect("supported")
1680 .supervisor_isolation
1681 };
1682
1683 assert!(
1684 value(Platform::Aws),
1685 "root agent setuids the command to 60000"
1686 );
1687 assert!(
1688 value(Platform::Local),
1689 "the supervisor is on the host, outside the container"
1690 );
1691 assert!(
1692 !value(Platform::Kubernetes),
1693 "a single pinned uid cannot be split"
1694 );
1695 assert!(!value(Platform::Azure), "no Alien process runs the command");
1696 assert!(
1697 !value(Platform::Gcp),
1698 "no separate supervisor identity runs the command"
1699 );
1700 }
1701
1702 #[test]
1706 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1707 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1708 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1709
1710 assert_eq!(
1711 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1712 "the older axis cannot tell them apart"
1713 );
1714 assert!(
1715 aws.supervisor_isolation,
1716 "AWS setuids the command off the supervisor"
1717 );
1718 assert!(
1719 !gcp.supervisor_isolation,
1720 "the command runs under no separate supervisor identity"
1721 );
1722 }
1723
1724 #[test]
1729 fn gcp_agent_platform_row_matches_measured_backend() {
1730 let row = SandboxCapabilities::gcp_agent_platform();
1731
1732 assert!(row.files, "agent file ops move over the sandbox envelope");
1733 assert!(
1734 row.reconnect,
1735 "generation is derived from the container boot id, so a sandbox is reachable across \
1736 processes"
1737 );
1738 assert!(
1739 !row.preview,
1740 "the only ingress is :execute; no port-scoped capability"
1741 );
1742 assert!(
1743 !row.pause_resume,
1744 ":resume can return a fresh container, so a pause keeps no state"
1745 );
1746 assert!(
1747 !row.snapshot,
1748 "the create path never sends a snapshot, so none is reachable through the trait"
1749 );
1750 assert!(
1751 !row.domain_egress_rules,
1752 "VPC and DNS peering is not a hostname allowlist"
1753 );
1754 assert!(
1755 row.egress_deny,
1756 "a declared deny blocks both egress and DNS"
1757 );
1758 assert!(
1759 row.enforced_limits,
1760 "ceilings are enforced, by terminating the sandbox on breach"
1761 );
1762 assert!(!row.process_limit, "no process-count ceiling is observed");
1763 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1764 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1765 assert!(
1766 !row.supervisor_isolation,
1767 "the command is not run under a separate supervisor identity"
1768 );
1769
1770 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1772 assert_eq!(
1773 live, row,
1774 "the Platform::Gcp arm is the Agent Platform capability row"
1775 );
1776 }
1777
1778 #[test]
1779 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1780 let error = SandboxCapabilities::for_platform(Platform::Machines)
1781 .expect_err("Machines has no sandbox backend");
1782 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1783 }
1784
1785 #[test]
1786 fn unsupported_capability_names_platform_and_capability() {
1787 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1788 let error = capabilities
1789 .require(SandboxCapability::Preview, Platform::Gcp)
1790 .expect_err("GCP has no preview");
1791
1792 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1793 let rendered = error.to_string();
1794 assert!(
1795 rendered.contains("preview"),
1796 "names the capability: {rendered}"
1797 );
1798 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1799 }
1800
1801 #[test]
1805 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1806 let sandbox = sandbox_with(
1807 SandboxEgress::AllowDomains {
1808 domains: vec!["example.com".to_string()],
1809 },
1810 vec![],
1811 );
1812
1813 for platform in [
1814 Platform::Aws,
1815 Platform::Gcp,
1816 Platform::Kubernetes,
1817 Platform::Local,
1818 ] {
1819 let error = sandbox
1820 .validate_for_platform(platform)
1821 .expect_err("only Azure expresses a hostname allowlist");
1822 assert_eq!(
1823 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1824 "on {platform:?}"
1825 );
1826 }
1827
1828 assert!(
1829 SandboxCapabilities::for_platform(Platform::Azure)
1830 .expect("supported")
1831 .domain_egress_rules,
1832 "Azure's egress policy matches on host pattern"
1833 );
1834 }
1835
1836 #[test]
1839 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1840 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1841
1842 assert!(
1845 SandboxCapabilities::for_platform(Platform::Gcp)
1846 .expect("supported")
1847 .egress_deny
1848 );
1849
1850 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1851 sandbox
1852 .validate_for_platform(platform)
1853 .expect("deny is enforced here");
1854 }
1855
1856 let egress_only = Sandbox::new("sbx".to_string())
1858 .code(SandboxCode::Image {
1859 image: "alpine".to_string(),
1860 })
1861 .egress(SandboxEgress::Deny)
1862 .lifecycle(SandboxLifecyclePolicy {
1863 max_lifetime_seconds: None,
1864 idle_pause_seconds: None,
1865 })
1866 .build();
1867
1868 egress_only
1869 .validate_for_platform(Platform::Azure)
1870 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1871 }
1872
1873 #[test]
1877 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1878 let undeclared = Sandbox::new("sbx".to_string())
1879 .code(SandboxCode::Image {
1880 image: "alpine".to_string(),
1881 })
1882 .egress(SandboxEgress::Deny)
1883 .lifecycle(SandboxLifecyclePolicy {
1884 max_lifetime_seconds: None,
1885 idle_pause_seconds: None,
1886 })
1887 .build();
1888
1889 undeclared
1890 .validate_for_platform(Platform::Azure)
1891 .expect("a sandbox naming no ceilings takes the platform's own");
1892
1893 assert_eq!(undeclared.resolved_limits().cpu, "1");
1895 }
1896
1897 #[test]
1901 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1902 let sized = |cpu: &str, memory: &str, disk: &str| {
1903 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1904 let limits = sandbox
1905 .limits
1906 .as_mut()
1907 .expect("the fixture declares limits");
1908 limits.cpu = cpu.to_string();
1909 limits.memory = memory.to_string();
1910 limits.disk = disk.to_string();
1911 sandbox.validate_for_platform(Platform::Azure)
1912 };
1913
1914 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
1915 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
1916 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
1917
1918 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
1920 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
1921 assert!(off_step.to_string().contains("cpu"), "{off_step}");
1922
1923 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
1924 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
1925
1926 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
1929 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
1930 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
1931 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
1932
1933 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
1934 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
1935 }
1936
1937 #[test]
1938 fn preview_ports_require_the_preview_capability() {
1939 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1940
1941 sandbox
1942 .validate_for_platform(Platform::Aws)
1943 .expect("AWS mints a port-scoped JWE");
1944
1945 let error = sandbox
1946 .validate_for_platform(Platform::Kubernetes)
1947 .expect_err("Kubernetes preview is deferred");
1948 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1949 }
1950
1951 #[test]
1955 fn a_private_base_image_is_refused_off_aws() {
1956 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1957 sandbox.code = SandboxCode::Image {
1958 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
1959 };
1960 sandbox.private_base_image =
1961 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
1962
1963 sandbox
1964 .validate_for_platform(Platform::Aws)
1965 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
1966
1967 for platform in [
1968 Platform::Gcp,
1969 Platform::Azure,
1970 Platform::Kubernetes,
1971 Platform::Local,
1972 ] {
1973 let error = sandbox
1974 .validate_for_platform(platform)
1975 .expect_err("a backend that builds no image must refuse a base image for one");
1976 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1977 assert!(
1978 error.to_string().contains("privateBaseImage"),
1979 "the refusal must name the field the user declared: {error}"
1980 );
1981 }
1982 }
1983
1984 #[test]
1985 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1986 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1987 sandbox
1988 .validate_for_platform(Platform::Gcp)
1989 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1990 }
1991
1992 #[test]
1993 fn invalid_quantities_are_rejected_with_the_offending_field() {
1994 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1995 sandbox
1996 .limits
1997 .as_mut()
1998 .expect("the fixture declares limits")
1999 .memory = "2Gb".to_string();
2000
2001 let error = sandbox
2002 .validate_for_platform(Platform::Aws)
2003 .expect_err("Gb is not a valid suffix");
2004 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2005 assert!(error.to_string().contains("memory"));
2006
2007 sandbox
2008 .limits
2009 .as_mut()
2010 .expect("the fixture declares limits")
2011 .memory = "2Gi".to_string();
2012 sandbox
2013 .limits
2014 .as_mut()
2015 .expect("the fixture declares limits")
2016 .cpu = "0".to_string();
2017 let error = sandbox
2018 .validate_for_platform(Platform::Aws)
2019 .expect_err("zero cpu is not a ceiling");
2020 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2021 }
2022
2023 #[test]
2024 fn zero_max_processes_is_rejected() {
2025 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2026 sandbox
2027 .limits
2028 .as_mut()
2029 .expect("the fixture declares limits")
2030 .max_processes = Some(0);
2031
2032 let error = sandbox
2033 .validate_for_platform(Platform::Local)
2034 .expect_err("a sandbox must be able to run at least one process");
2035 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2036 assert!(error.to_string().contains("maxProcesses"));
2037 }
2038
2039 #[test]
2043 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
2044 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2045 sandbox
2046 .limits
2047 .as_mut()
2048 .expect("the fixture declares limits")
2049 .max_processes = Some(256);
2050
2051 sandbox
2052 .validate_for_platform(Platform::Local)
2053 .expect("Docker takes a pids limit");
2054
2055 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
2056 let error = sandbox
2057 .validate_for_platform(platform)
2058 .expect_err("a process ceiling nothing applies must be refused");
2059 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2060 }
2061 }
2062
2063 #[test]
2067 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
2068 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2069
2070 for seconds in [0, 28_801, 100_000] {
2071 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2072 let error = sandbox
2073 .validate_for_platform(Platform::Aws)
2074 .expect_err("a lifetime outside what AWS runs is refused");
2075 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
2076
2077 sandbox
2079 .validate_for_platform(Platform::Kubernetes)
2080 .expect("the kubelet takes any activeDeadlineSeconds");
2081 }
2082
2083 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
2084 sandbox
2085 .validate_for_platform(Platform::Aws)
2086 .expect("the ceiling itself is allowed");
2087 }
2088
2089 #[test]
2092 fn an_image_azure_cannot_pull_is_refused_while_planning() {
2093 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2094 sandbox.limits = None;
2097
2098 for image in ["ubuntu@sha256:abc", "", " ", "ubuntu latest", "ubuntu?x"] {
2100 sandbox.code = SandboxCode::Image {
2101 image: image.to_string(),
2102 };
2103 let error = sandbox
2104 .validate_for_platform(Platform::Azure)
2105 .expect_err("an image Azure has nowhere to put is refused");
2106 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
2107 }
2108
2109 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
2110 sandbox.code = SandboxCode::Image {
2111 image: image.to_string(),
2112 };
2113 sandbox
2114 .validate_for_platform(Platform::Azure)
2115 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
2116 }
2117
2118 for image in [
2119 "ubuntu:24.04",
2120 "ghcr.io/myorg/sandbox:latest",
2121 "docker.io/library/python:3.14-slim",
2122 "localhost:5000/team/agent@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d",
2123 ] {
2124 sandbox.code = SandboxCode::Image {
2125 image: image.to_string(),
2126 };
2127 sandbox
2128 .validate_for_platform(Platform::Azure)
2129 .unwrap_or_else(|error| panic!("'{image}' is a registry image: {error}"));
2130 }
2131
2132 sandbox.code = SandboxCode::Image {
2134 image: " ubuntu ".to_string(),
2135 };
2136 assert_eq!(
2137 sandbox
2138 .azure_image()
2139 .expect("a padded name is still a name"),
2140 AzureSandboxImage::Catalog("ubuntu")
2141 );
2142 }
2143
2144 #[test]
2148 fn every_value_the_catalog_allowlist_accepted_is_still_a_catalog_name() {
2149 const ALPHABET: &[u8] =
2150 b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
2151
2152 let assert_catalog = |value: &str| {
2153 assert_eq!(
2154 classify_azure_sandbox_image(value),
2155 Some(AzureSandboxImage::Catalog(value.trim())),
2156 "'{value}'"
2157 );
2158 };
2159
2160 for a in ALPHABET {
2161 assert_catalog(&String::from_utf8(vec![*a]).unwrap());
2162 for b in ALPHABET {
2163 assert_catalog(&String::from_utf8(vec![*a, *b]).unwrap());
2164 for c in ALPHABET {
2165 assert_catalog(&String::from_utf8(vec![*a, *b, *c]).unwrap());
2166 }
2167 }
2168 }
2169
2170 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2171 let mut next = || {
2172 state ^= state << 13;
2173 state ^= state >> 7;
2174 state ^= state << 17;
2175 state
2176 };
2177 for _ in 0..20_000 {
2178 let len = 4 + (next() % 60) as usize;
2179 let mut value: String = (0..len)
2180 .map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize] as char)
2181 .collect();
2182 if next() % 4 == 0 {
2183 value = format!(" {value}\t");
2184 }
2185 assert_catalog(&value);
2186 }
2187 }
2188
2189 #[test]
2192 fn the_disk_image_label_is_stable_and_label_safe() {
2193 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
2194 assert_eq!(
2195 label,
2196 azure_disk_image_label(" docker.io/library/python:3.14-slim ")
2197 );
2198 assert_ne!(
2199 label,
2200 azure_disk_image_label("docker.io/library/python:3.13-slim")
2201 );
2202 assert_eq!(label.len(), 32);
2203 assert!(label.chars().all(|c| c.is_ascii_hexdigit()), "{label}");
2204 }
2205
2206 #[test]
2210 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2211 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2212 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2213
2214 sandbox
2215 .validate_for_platform(Platform::Kubernetes)
2216 .expect("the kubelet enforces activeDeadlineSeconds");
2217 sandbox
2218 .validate_for_platform(Platform::Aws)
2219 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2220
2221 for platform in [Platform::Azure, Platform::Local] {
2222 let error = sandbox
2223 .validate_for_platform(platform)
2224 .expect_err("a deadline nothing applies must be refused");
2225 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2226 }
2227 }
2228
2229 #[test]
2233 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2234 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2235 let tier = sandbox
2236 .microvm_tier()
2237 .expect("2Gi/1cpu/20Gi is satisfiable");
2238
2239 assert_eq!(
2240 tier.peak_memory_mib, 2048,
2241 "the peak is the declared ceiling"
2242 );
2243 assert_eq!(
2244 tier.baseline_memory_mib, 512,
2245 "which is a quarter of it as the baseline"
2246 );
2247 assert!(tier.max_disk_mib <= 20 * 1024);
2248 }
2249
2250 #[test]
2254 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2255 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2256 {
2257 let limits = sandbox
2258 .limits
2259 .as_mut()
2260 .expect("the fixture declares limits");
2261 limits.cpu = "1".to_string();
2262 limits.memory = "8Gi".to_string();
2263 }
2264
2265 let error = sandbox
2266 .microvm_tier()
2267 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2268 assert!(
2269 error.to_string().contains("4 vCPU"),
2270 "the refusal must say what the memory ceiling implies: {error}"
2271 );
2272
2273 sandbox
2274 .limits
2275 .as_mut()
2276 .expect("the fixture declares limits")
2277 .cpu = "4".to_string();
2278 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2279 assert_eq!(tier.peak_memory_mib, 8192);
2280 }
2281
2282 #[test]
2285 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2286 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2287 sandbox
2288 .limits
2289 .as_mut()
2290 .expect("the fixture declares limits")
2291 .memory = "1Gi".to_string();
2292
2293 let error = sandbox
2294 .validate_for_platform(Platform::Aws)
2295 .expect_err("no MicroVM size peaks at or below 1Gi");
2296 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2297 assert!(
2298 error.to_string().contains("2Gi"),
2299 "the refusal must say what the smallest holdable ceiling is: {error}"
2300 );
2301 }
2302
2303 #[test]
2307 fn source_code_is_refused_off_aws_rather_than_producing_a_broken_manifest() {
2308 let sandbox = Sandbox::new("agent".to_string())
2309 .code(SandboxCode::Source {
2310 src: "./sandbox".to_string(),
2311 toolchain: ToolchainConfig::Docker {
2312 dockerfile: None,
2313 build_args: None,
2314 target: None,
2315 },
2316 })
2317 .egress(SandboxEgress::Deny)
2318 .lifecycle(SandboxLifecyclePolicy {
2319 max_lifetime_seconds: None,
2320 idle_pause_seconds: None,
2321 })
2322 .build();
2323
2324 sandbox
2325 .validate_for_platform(Platform::Aws)
2326 .expect("an AWS sandbox base image is built by `alien build`");
2327
2328 for platform in [
2329 Platform::Azure,
2330 Platform::Gcp,
2331 Platform::Kubernetes,
2332 Platform::Local,
2333 ] {
2334 let error = sandbox
2335 .validate_for_platform(platform)
2336 .expect_err("no backend builds a sandbox image from source here");
2337 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2338 assert!(
2339 error.to_string().contains("code.image"),
2340 "the refusal must say what to write instead: {error}"
2341 );
2342 assert!(
2343 error.to_string().contains(&platform.to_string()),
2344 "the refusal must name the platform that cannot build it: {error}"
2345 );
2346 }
2347 }
2348
2349 #[test]
2352 fn every_accepted_unit_converts_rather_than_falling_back() {
2353 assert_eq!(quantity_mib("2Gi"), Some(2048));
2354 assert_eq!(quantity_mib("512Mi"), Some(512));
2355 assert_eq!(quantity_mib("4G"), Some(3814));
2356 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2357 assert_eq!(millicores("1"), Some(1000));
2358 assert_eq!(millicores("500m"), Some(500));
2359 }
2360
2361 #[test]
2362 fn privileged_supervisor_fixes_identity_and_requires_an_enforceable_backend() {
2363 let mut sandbox = sandbox_with(
2364 SandboxEgress::AllowDomains {
2365 domains: vec!["example.com".to_string()],
2366 },
2367 vec![],
2368 );
2369 sandbox.privileged_supervisor = Some(SandboxPrivilegedSupervisor { command_uid: 60001 });
2370 sandbox
2371 .validate_for_platform(Platform::Aws)
2372 .expect("AWS can enforce the policy in the agent");
2373 assert_eq!(sandbox.cloud_egress(), &SandboxEgress::Allow);
2374 assert_eq!(
2375 sandbox.supervisor_environment()["ALIEN_SANDBOX_EXEC_UID"],
2376 "60001"
2377 );
2378 for platform in [
2379 Platform::Local,
2380 Platform::Kubernetes,
2381 Platform::Azure,
2382 Platform::Gcp,
2383 ] {
2384 let error = sandbox
2385 .validate_for_platform(platform)
2386 .expect_err("cannot grant a privilege boundary the backend lacks");
2387 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2388 assert!(error.message.contains("privilegedSupervisor"));
2389 }
2390 for uid in [0, u32::MAX] {
2391 sandbox.privileged_supervisor.as_mut().unwrap().command_uid = uid;
2392 let error = sandbox
2393 .validate_for_platform(Platform::Aws)
2394 .expect_err("invalid uid must fail at plan time");
2395 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2396 }
2397 }
2398
2399 #[test]
2400 fn unknown_fields_are_rejected() {
2401 let json = r#"{
2402 "id": "sbx",
2403 "code": {"type": "image", "image": "ubuntu:24.04"},
2404 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2405 "egress": {"mode": "deny"},
2406 "lifecycle": {},
2407 "unexpected": true
2408 }"#;
2409
2410 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2411 }
2412
2413 #[test]
2414 fn serialization_roundtrips() {
2415 let sandbox = sandbox_with(
2416 SandboxEgress::AllowDomains {
2417 domains: vec!["example.com".to_string()],
2418 },
2419 vec![8080, 9090],
2420 );
2421
2422 let json = serde_json::to_string(&sandbox).expect("serializes");
2423 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2424 assert_eq!(sandbox, restored);
2425 }
2426
2427 #[test]
2428 fn id_is_immutable_across_updates() {
2429 let original = sandbox_with(SandboxEgress::Deny, vec![]);
2430 let renamed = Sandbox::new("other".to_string())
2431 .code(SandboxCode::Image {
2432 image: "ubuntu".to_string(),
2433 })
2434 .limits(
2435 original
2436 .limits
2437 .clone()
2438 .expect("the fixture declares limits"),
2439 )
2440 .egress(SandboxEgress::Deny)
2441 .lifecycle(SandboxLifecyclePolicy {
2442 max_lifetime_seconds: None,
2443 idle_pause_seconds: None,
2444 })
2445 .build();
2446
2447 original
2448 .validate_update(&original.clone())
2449 .expect("an unchanged config is a valid update");
2450 original
2451 .validate_update(&renamed)
2452 .expect_err("renaming a sandbox is not an update");
2453 }
2454
2455 #[test]
2461 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2462 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2463 Sandbox::new("sbx".to_string())
2464 .code(SandboxCode::Image {
2465 image: "ubuntu".to_string(),
2466 })
2467 .egress(SandboxEgress::Allow)
2468 .lifecycle(lifecycle)
2469 .build()
2470 .validate_for_platform(Platform::Azure)
2471 };
2472
2473 with_policy(SandboxLifecyclePolicy {
2474 max_lifetime_seconds: None,
2475 idle_pause_seconds: Some(900),
2476 })
2477 .expect("Azure pauses a sandbox on idle");
2478
2479 let error = with_policy(SandboxLifecyclePolicy {
2480 max_lifetime_seconds: Some(3600),
2481 idle_pause_seconds: None,
2482 })
2483 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2484 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2485 assert!(
2486 error.message.contains("sandboxLifetime"),
2487 "names the capability: {}",
2488 error.message
2489 );
2490 }
2491
2492 #[test]
2498 fn an_allowlist_with_no_domains_is_refused() {
2499 let declared = |domains: Vec<String>| {
2500 Sandbox::new("sbx".to_string())
2501 .code(SandboxCode::Image {
2502 image: "ubuntu".to_string(),
2503 })
2504 .egress(SandboxEgress::AllowDomains { domains })
2505 .lifecycle(SandboxLifecyclePolicy {
2506 max_lifetime_seconds: None,
2507 idle_pause_seconds: None,
2508 })
2509 .build()
2510 .validate_for_platform(Platform::Azure)
2511 };
2512
2513 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2514 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2515
2516 declared(vec!["api.example.com".to_string()])
2517 .expect("a named domain is what an allowlist is for");
2518 }
2519
2520 #[test]
2523 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2524 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2525 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
2526 assert_eq!(
2527 SandboxEgress::AllowDomains {
2528 domains: vec!["api.example.com".to_string()]
2529 }
2530 .internet_access_switch(),
2531 None,
2532 "a host list has no boolean and must not be approximated"
2533 );
2534 }
2535
2536 #[test]
2540 fn stack_state_written_before_the_lifecycle_rename_still_reads() {
2541 let stored: crate::StackResourceState = serde_json::from_value(serde_json::json!({
2542 "type": "sandbox",
2543 "config": {
2544 "id": "sandbox",
2545 "code": {
2546 "type": "image",
2547 "image": "s3://example-bundles/analysis/v1/bundle.zip"
2548 },
2549 "type": "sandbox",
2550 "egress": { "mode": "allow" },
2551 "session": { "maxLifetimeSeconds": 28800, "idleSuspendSeconds": 600 }
2552 },
2553 "status": "running",
2554 "outputs": {
2555 "type": "sandbox",
2556 "identifier": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox",
2557 "parentName": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox"
2558 },
2559 "lifecycle": "frozen",
2560 "dependencies": [
2561 { "id": "management", "type": "remote-stack-management" },
2562 { "id": "access", "type": "resource-access" }
2563 ],
2564 "controllerPlatform": "aws"
2565 }))
2566 .expect("a stack state written before the rename must still deserialize");
2567
2568 let expected = Sandbox::new("sandbox".to_string())
2569 .code(SandboxCode::Image {
2570 image: "s3://example-bundles/analysis/v1/bundle.zip".to_string(),
2571 })
2572 .egress(SandboxEgress::Allow)
2573 .lifecycle(SandboxLifecyclePolicy {
2574 max_lifetime_seconds: Some(28_800),
2575 idle_pause_seconds: Some(600),
2576 })
2577 .build();
2578 assert_eq!(stored.config.downcast_ref::<Sandbox>(), Some(&expected));
2579
2580 let rewritten = serde_json::to_value(&stored.config).expect("the sandbox serializes");
2581 assert_eq!(
2582 rewritten["lifecycle"],
2583 serde_json::json!({ "maxLifetimeSeconds": 28800, "idlePauseSeconds": 600 }),
2584 "the next write stores the current names"
2585 );
2586 assert!(rewritten.get("session").is_none());
2587 }
2588}