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