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
134pub use super::sandbox_policy::{is_public_ipv4, EgressPolicy};
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum SandboxEgress {
139 None,
140 Internet,
141 Allow,
143 AllowDomains {
145 domains: Vec<String>,
146 },
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
152#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
153pub enum LegacySandboxEgress {
154 Deny,
156 Allow,
159 AllowDomains { domains: Vec<String> },
162}
163
164#[derive(Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166enum SandboxEgressMode {
167 None,
168 Internet,
169}
170
171#[derive(Serialize, Deserialize)]
172#[serde(untagged)]
173enum SandboxEgressInput {
174 Mode(SandboxEgressMode),
175 Legacy(LegacySandboxEgress),
176}
177
178impl Serialize for SandboxEgress {
179 fn serialize<S: serde::Serializer>(
180 &self,
181 serializer: S,
182 ) -> std::result::Result<S::Ok, S::Error> {
183 match self {
184 Self::None => serializer.serialize_str("none"),
185 Self::Internet => serializer.serialize_str("internet"),
186 Self::Allow => LegacySandboxEgress::Allow.serialize(serializer),
187 Self::AllowDomains { domains } => LegacySandboxEgress::AllowDomains {
188 domains: domains.clone(),
189 }
190 .serialize(serializer),
191 }
192 }
193}
194
195impl<'de> Deserialize<'de> for SandboxEgress {
196 fn deserialize<D: serde::Deserializer<'de>>(
197 deserializer: D,
198 ) -> std::result::Result<Self, D::Error> {
199 Ok(match SandboxEgressInput::deserialize(deserializer)? {
200 SandboxEgressInput::Mode(SandboxEgressMode::None)
201 | SandboxEgressInput::Legacy(LegacySandboxEgress::Deny) => Self::None,
202 SandboxEgressInput::Mode(SandboxEgressMode::Internet) => Self::Internet,
203 SandboxEgressInput::Legacy(LegacySandboxEgress::Allow) => Self::Allow,
204 SandboxEgressInput::Legacy(LegacySandboxEgress::AllowDomains { domains }) => {
205 Self::AllowDomains { domains }
206 }
207 })
208 }
209}
210
211#[cfg(feature = "openapi")]
212impl utoipa::PartialSchema for SandboxEgress {
213 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
214 use utoipa::openapi::schema::{ObjectBuilder, OneOfBuilder, Type};
215 OneOfBuilder::new()
216 .item(
217 ObjectBuilder::new()
218 .schema_type(Type::String)
219 .enum_values(Some(["none", "internet"])),
220 )
221 .item(LegacySandboxEgress::schema())
222 .into()
223 }
224}
225#[cfg(feature = "openapi")]
226impl utoipa::ToSchema for SandboxEgress {}
227
228impl SandboxEgress {
229 #[allow(non_upper_case_globals)]
231 #[deprecated(note = "use SandboxEgress::None")]
232 pub const Deny: Self = Self::None;
233
234 pub fn internet_access_switch(&self) -> Option<bool> {
236 match self {
237 Self::None => Some(false),
238 Self::Internet | Self::Allow => Some(true),
239 Self::AllowDomains { .. } => None,
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
250#[serde(rename_all = "camelCase", deny_unknown_fields)]
251pub struct SandboxLifecyclePolicy {
252 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub max_lifetime_seconds: Option<u32>,
260 #[serde(alias = "idleSuspendSeconds", skip_serializing_if = "Option::is_none")]
264 pub idle_pause_seconds: Option<u32>,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct SandboxCapabilities {
276 pub files: bool,
278 pub reconnect: bool,
280 pub jobs: bool,
283 pub preview: bool,
285 pub pause_resume: bool,
287 pub snapshot: bool,
289 pub domain_egress_rules: bool,
291 pub egress_deny: bool,
294 pub enforced_limits: bool,
296 pub process_limit: bool,
298 pub sandbox_lifetime: bool,
300 pub supervisor_pid_namespace: bool,
306 pub supervisor_isolation: bool,
312}
313
314impl SandboxCapabilities {
315 pub fn for_platform(platform: Platform) -> Result<Self> {
321 match platform {
322 Platform::Aws => Ok(Self {
323 files: true,
324 reconnect: true,
325 jobs: true,
326 preview: true,
327 pause_resume: true,
328 snapshot: false,
329 domain_egress_rules: false,
330 egress_deny: true,
333 enforced_limits: true,
334 process_limit: false,
336 sandbox_lifetime: true,
339 supervisor_pid_namespace: false,
344 supervisor_isolation: true,
347 }),
348 Platform::Azure => Ok(Self::azure()),
349 Platform::Gcp => Ok(Self::gcp_agent_platform()),
350 Platform::Kubernetes => Ok(Self {
353 files: true,
354 reconnect: true,
355 jobs: true,
356 preview: false,
357 pause_resume: false,
358 snapshot: false,
359 domain_egress_rules: false,
360 egress_deny: true,
361 enforced_limits: true,
362 process_limit: false,
364 sandbox_lifetime: true,
366 supervisor_pid_namespace: false,
370 supervisor_isolation: false,
375 }),
376 Platform::Local => Ok(Self {
377 files: true,
378 reconnect: true,
379 jobs: false,
381 preview: true,
382 pause_resume: false,
383 snapshot: false,
384 domain_egress_rules: false,
385 egress_deny: true,
386 enforced_limits: true,
387 process_limit: true,
389 sandbox_lifetime: false,
390 supervisor_pid_namespace: false,
393 supervisor_isolation: true,
397 }),
398 Platform::Machines | Platform::Test => {
399 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
400 platform: platform.to_string(),
401 }))
402 }
403 }
404 }
405
406 pub fn azure() -> Self {
408 Self {
409 files: true,
410 reconnect: true,
411 jobs: false,
413 preview: false,
418 pause_resume: true,
419 snapshot: false,
423 domain_egress_rules: true,
424 egress_deny: true,
425 enforced_limits: true,
429 process_limit: false,
430 sandbox_lifetime: false,
434 supervisor_pid_namespace: false,
436 supervisor_isolation: false,
439 }
440 }
441
442 pub fn gcp_agent_platform() -> Self {
444 Self {
445 files: true,
447 reconnect: true,
451 jobs: true,
452 preview: false,
454 pause_resume: false,
457 snapshot: false,
460 domain_egress_rules: false,
462 egress_deny: true,
464 enforced_limits: true,
468 process_limit: false,
470 sandbox_lifetime: true,
472 supervisor_pid_namespace: false,
474 supervisor_isolation: false,
477 }
478 }
479
480 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
482 let available = match capability {
483 SandboxCapability::Files => self.files,
484 SandboxCapability::Reconnect => self.reconnect,
485 SandboxCapability::Jobs => self.jobs,
486 SandboxCapability::Preview => self.preview,
487 SandboxCapability::PauseResume => self.pause_resume,
488 SandboxCapability::Snapshot => self.snapshot,
489 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
490 SandboxCapability::EgressDeny => self.egress_deny,
491 SandboxCapability::EnforcedLimits => self.enforced_limits,
492 SandboxCapability::ProcessLimit => self.process_limit,
493 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
494 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
495 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
496 };
497
498 if available {
499 return Ok(());
500 }
501
502 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
503 capability: capability.as_str().to_string(),
504 platform: platform.to_string(),
505 }))
506 }
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
511#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
512#[serde(rename_all = "camelCase")]
513pub enum SandboxCapability {
514 Files,
516 Reconnect,
518 Jobs,
520 Preview,
522 PauseResume,
524 Snapshot,
526 DomainEgressRules,
528 EgressDeny,
530 EnforcedLimits,
532 ProcessLimit,
534 SandboxLifetime,
536 SupervisorPidNamespace,
538 SupervisorIsolation,
540}
541
542impl SandboxCapability {
543 pub fn as_str(&self) -> &'static str {
545 match self {
546 Self::Files => "files",
547 Self::Reconnect => "reconnect",
548 Self::Jobs => "jobs",
549 Self::Preview => "preview",
550 Self::PauseResume => "pauseResume",
551 Self::Snapshot => "snapshot",
552 Self::DomainEgressRules => "domainEgressRules",
553 Self::EgressDeny => "egressDeny",
554 Self::EnforcedLimits => "enforcedLimits",
555 Self::ProcessLimit => "processLimit",
556 Self::SandboxLifetime => "sandboxLifetime",
557 Self::SupervisorPidNamespace => "supervisorPidNamespace",
558 Self::SupervisorIsolation => "supervisorIsolation",
559 }
560 }
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
566#[serde(rename_all = "camelCase", deny_unknown_fields)]
567pub struct SandboxPrivilegedSupervisor {
568 pub command_uid: u32,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
574#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
575#[serde(rename_all = "camelCase", deny_unknown_fields)]
576#[builder(start_fn = new)]
577pub struct Sandbox {
578 #[builder(start_fn)]
581 pub id: String,
582 #[builder(field)]
585 #[serde(default, skip_serializing_if = "Vec::is_empty")]
586 #[cfg_attr(feature = "openapi", schema(ignore))]
587 pub links: Vec<ResourceRef>,
588 pub code: SandboxCode,
590 #[serde(skip_serializing_if = "Option::is_none")]
594 pub private_base_image: Option<String>,
595 #[serde(skip_serializing_if = "Option::is_none")]
601 pub limits: Option<SandboxLimits>,
602 pub egress: SandboxEgress,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub default_policy: Option<EgressPolicy>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub privileged_supervisor: Option<SandboxPrivilegedSupervisor>,
611 #[serde(alias = "session")]
616 pub lifecycle: SandboxLifecyclePolicy,
617 #[builder(default)]
621 #[serde(default, skip_serializing_if = "Vec::is_empty")]
622 pub preview_ports: Vec<u16>,
623}
624
625pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
630 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
631}
632
633pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
639 stack.resources().any(|(_, entry)| {
640 entry
641 .config
642 .downcast_ref::<Sandbox>()
643 .is_some_and(|sandbox| crate::sandbox_egress::sandbox_needs_connector(stack, sandbox))
644 })
645}
646
647impl Sandbox {
648 pub(crate) fn setup_digest_value(&self) -> serde_json::Value {
649 match &self.egress {
650 SandboxEgress::None => serde_json::json!({"mode":"deny"}),
651 value => serde_json::to_value(value).expect("egress serializes"),
652 }
653 }
654
655 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
657
658 pub fn id(&self) -> &str {
660 &self.id
661 }
662
663 pub fn cloud_egress(&self) -> &SandboxEgress {
666 if self.privileged_supervisor.is_some() && self.egress != SandboxEgress::Internet {
667 &SandboxEgress::Allow
668 } else {
669 &self.egress
670 }
671 }
672
673 pub fn supervisor_environment(&self) -> std::collections::BTreeMap<String, String> {
675 let mut env = std::collections::BTreeMap::new();
676 if let Some(supervisor) = &self.privileged_supervisor {
677 env.insert(
678 "ALIEN_SANDBOX_EXPECTED_EXEC_UID".to_string(),
679 supervisor.command_uid.to_string(),
680 );
681 env.insert(
682 "ALIEN_SANDBOX_EGRESS".to_string(),
683 if self.egress == SandboxEgress::None {
685 r#"{"mode":"deny"}"#.to_string()
686 } else {
687 serde_json::to_string(&self.egress).expect("egress serializes")
688 },
689 );
690 }
691 env
692 }
693
694 pub fn resolved_limits(&self) -> SandboxLimits {
700 self.limits.clone().unwrap_or_else(default_limits)
701 }
702
703 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
708 if let Some(policy) = &self.default_policy {
709 if self.egress != SandboxEgress::Internet {
710 return Err(AlienError::new(ErrorData::SandboxEgressInvalid {
711 target: "defaultPolicy".into(),
712 reason: "defaultPolicy requires the new internet declaration".into(),
713 }));
714 }
715 policy.validate()?;
716 }
717 let mut capabilities = SandboxCapabilities::for_platform(platform)?;
718 if let Some(supervisor) = &self.privileged_supervisor {
719 if platform != Platform::Aws {
720 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
721 capability: "privilegedSupervisor".to_string(),
722 platform: platform.to_string(),
723 }));
724 }
725 if supervisor.command_uid == 0 || supervisor.command_uid == u32::MAX {
726 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
727 resource_id: self.id.clone(),
728 field: "privilegedSupervisor.commandUid".to_string(),
729 value: supervisor.command_uid.to_string(),
730 reason: "must be a non-root Linux uid other than the invalid uid sentinel"
731 .to_string(),
732 }));
733 }
734 if let SandboxEgress::AllowDomains { domains } = &self.egress {
735 for domain in domains {
736 let hostname = domain.strip_suffix('.').unwrap_or(domain);
737 if hostname.len() > 253
738 || !hostname.split('.').all(|label| {
739 !label.is_empty()
740 && label.len() <= 63
741 && label
742 .bytes()
743 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
744 && !label.starts_with('-')
745 && !label.ends_with('-')
746 })
747 {
748 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
749 resource_id: self.id.clone(), field: "egress.domains".to_string(), value: domain.clone(),
750 reason: "privileged supervisor allowlists require exact DNS hostnames; wildcards are unsupported".to_string(),
751 }));
752 }
753 }
754 }
755 capabilities.domain_egress_rules = true;
756 }
757
758 if matches!(&self.code, SandboxCode::Source { .. })
763 && !matches!(platform, Platform::Aws | Platform::Azure | Platform::Gcp)
764 {
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: format!(
770 "no sandbox backend builds an image from source on {platform}; give \
771 code.image a prebuilt reference"
772 ),
773 }));
774 }
775
776 if self.private_base_image.is_some() && platform != Platform::Aws {
779 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
780 capability: "privateBaseImage".to_string(),
781 platform: platform.to_string(),
782 }));
783 }
784
785 if platform == Platform::Azure && matches!(&self.code, SandboxCode::Image { .. }) {
788 self.azure_image()?;
789 }
790
791 let Some(limits) = self.limits.as_ref() else {
792 return self.validate_capabilities(&capabilities, platform);
794 };
795
796 validate_quantity(&self.id, "cpu", &limits.cpu)?;
797 validate_quantity(&self.id, "memory", &limits.memory)?;
798 validate_quantity(&self.id, "disk", &limits.disk)?;
799
800 if let Some(max_processes) = limits.max_processes {
801 if max_processes == 0 {
802 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
803 resource_id: self.id.clone(),
804 field: "maxProcesses".to_string(),
805 value: "0".to_string(),
806 reason: "a sandbox that may run no processes cannot run code".to_string(),
807 }));
808 }
809 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
810 }
811
812 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
815
816 if platform == Platform::Azure {
817 self.azure_sandbox_limits()?;
818 }
819
820 if platform == Platform::Aws {
821 self.microvm_tier()?;
824
825 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
830 if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
831 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
832 resource_id: self.id.clone(),
833 field: "maxLifetimeSeconds".to_string(),
834 value: seconds.to_string(),
835 reason: format!(
836 "AWS runs a MicroVM for between 1 and \
837 {AWS_MAX_LIFETIME_SECONDS} seconds"
838 ),
839 }));
840 }
841 }
842 }
843
844 self.validate_capabilities(&capabilities, platform)
845 }
846
847 pub fn azure_image(&self) -> Result<AzureSandboxImage<'_>> {
851 let SandboxCode::Image { image } = &self.code else {
852 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
853 resource_id: self.id.clone(),
854 field: "code".to_string(),
855 value: "source".to_string(),
856 reason: "the sandbox still has unbuilt source code. Run 'alien build' first"
857 .to_string(),
858 }));
859 };
860
861 classify_azure_sandbox_image(image).ok_or_else(|| {
862 let image = image.trim();
863 AlienError::new(ErrorData::SandboxLimitInvalid {
864 resource_id: self.id.clone(),
865 field: "code.image".to_string(),
866 value: image.to_string(),
867 reason: if image.is_empty() {
868 "a sandbox has to name an image".to_string()
869 } else {
870 "Azure creates a sandbox from a catalog name such as 'ubuntu' or from a \
871 registry image such as 'docker.io/library/python:3.14-slim'"
872 .to_string()
873 },
874 })
875 })
876 }
877
878 pub fn azure_sandbox_limits(&self) -> Result<()> {
882 let Some(limits) = self.limits.as_ref() else {
883 return Ok(());
886 };
887
888 let refused = |field: &str, value: &str, reason: &str| {
889 AlienError::new(ErrorData::SandboxLimitInvalid {
890 resource_id: self.id.clone(),
891 field: field.to_string(),
892 value: value.to_string(),
893 reason: reason.to_string(),
894 })
895 };
896
897 let cpu_millicores = millicores(&limits.cpu)
898 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
899
900 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
904 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
905 {
906 return Err(refused(
907 "cpu",
908 &limits.cpu,
909 "Azure allocates cpu in steps of 250m from 250m to 16000m",
910 ));
911 }
912
913 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
915 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
916
917 let memory_mib = quantity_mib(&limits.memory)
918 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
919 if memory_mib > memory_ceiling_mib {
920 return Err(refused(
921 "memory",
922 &limits.memory,
923 &format!(
924 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
925 at the declared cpu"
926 ),
927 ));
928 }
929
930 let disk_mib = quantity_mib(&limits.disk)
931 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
932 if disk_mib > disk_ceiling_mib {
933 return Err(refused(
934 "disk",
935 &limits.disk,
936 &format!(
937 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
938 the declared cpu"
939 ),
940 ));
941 }
942
943 Ok(())
944 }
945
946 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
953 let Some(limits) = self.limits.as_ref() else {
954 return Ok(MICROVM_TIERS[2]);
956 };
957
958 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
959 AlienError::new(ErrorData::SandboxLimitInvalid {
960 resource_id: self.id.clone(),
961 field: "memory".to_string(),
962 value: limits.memory.clone(),
963 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
964 })
965 })?;
966 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
967 AlienError::new(ErrorData::SandboxLimitInvalid {
968 resource_id: self.id.clone(),
969 field: "disk".to_string(),
970 value: limits.disk.clone(),
971 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
972 })
973 })?;
974 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
975 AlienError::new(ErrorData::SandboxLimitInvalid {
976 resource_id: self.id.clone(),
977 field: "cpu".to_string(),
978 value: limits.cpu.clone(),
979 reason: "expected cores or millicores".to_string(),
980 })
981 })?;
982
983 let sized = |tier: &&MicrovmTier| {
988 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
989 };
990
991 let tier = MICROVM_TIERS
992 .iter()
993 .rev()
994 .find(sized)
995 .copied()
996 .ok_or_else(|| {
997 AlienError::new(ErrorData::SandboxLimitInvalid {
998 resource_id: self.id.clone(),
999 field: "memory".to_string(),
1000 value: limits.memory.clone(),
1001 reason: format!(
1002 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
1003 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
1004 disk fit no size",
1005 limits.memory, limits.disk
1006 ),
1007 })
1008 })?;
1009
1010 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
1011 if cpu_millicores < required_millicores {
1012 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1013 resource_id: self.id.clone(),
1014 field: "cpu".to_string(),
1015 value: limits.cpu.clone(),
1016 reason: format!(
1017 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
1018 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
1019 limits.memory, tier.peak_vcpu, tier.peak_vcpu
1020 ),
1021 }));
1022 }
1023
1024 Ok(tier)
1025 }
1026
1027 fn validate_capabilities(
1029 &self,
1030 capabilities: &SandboxCapabilities,
1031 platform: Platform,
1032 ) -> Result<()> {
1033 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
1034 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
1035 }
1036
1037 if let SandboxEgress::AllowDomains { domains } = &self.egress {
1043 if domains.is_empty() {
1044 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1045 resource_id: self.id.clone(),
1046 field: "egress.domains".to_string(),
1047 value: "[]".to_string(),
1048 reason: "an allowlist naming no domain denies everything; declare \
1049 egress: deny if that is what was meant"
1050 .to_string(),
1051 }));
1052 }
1053 }
1054
1055 if matches!(self.egress, SandboxEgress::None) {
1056 capabilities.require(SandboxCapability::EgressDeny, platform)?;
1057 }
1058
1059 if !self.preview_ports.is_empty() {
1060 capabilities.require(SandboxCapability::Preview, platform)?;
1061 }
1062
1063 if self.lifecycle.idle_pause_seconds.is_some() {
1064 capabilities.require(SandboxCapability::PauseResume, platform)?;
1065 }
1066
1067 if self.lifecycle.max_lifetime_seconds.is_some() {
1068 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
1069 }
1070
1071 Ok(())
1072 }
1073}
1074
1075fn default_limits() -> SandboxLimits {
1080 SandboxLimits {
1081 cpu: "1".to_string(),
1082 memory: "2Gi".to_string(),
1083 disk: "8Gi".to_string(),
1084 max_processes: None,
1085 }
1086}
1087
1088fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
1090 let invalid = |reason: &str| {
1091 AlienError::new(ErrorData::SandboxLimitInvalid {
1092 resource_id: resource_id.to_string(),
1093 field: field.to_string(),
1094 value: value.to_string(),
1095 reason: reason.to_string(),
1096 })
1097 };
1098
1099 let digits_end = value
1100 .find(|c: char| !c.is_ascii_digit() && c != '.')
1101 .unwrap_or(value.len());
1102 let (number, suffix) = value.split_at(digits_end);
1103
1104 let parsed: f64 = number
1105 .parse()
1106 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
1107
1108 if parsed <= 0.0 {
1109 return Err(invalid("must be greater than zero"));
1110 }
1111
1112 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
1113 if !SUFFIXES.contains(&suffix) {
1114 return Err(invalid(
1115 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
1116 ));
1117 }
1118
1119 Ok(())
1120}
1121
1122fn split_quantity(value: &str) -> Option<(f64, &str)> {
1124 let trimmed = value.trim();
1125 let digits_end = trimmed
1126 .find(|c: char| !c.is_ascii_digit() && c != '.')
1127 .unwrap_or(trimmed.len());
1128 let (number, suffix) = trimmed.split_at(digits_end);
1129 number.parse().ok().map(|number| (number, suffix))
1130}
1131
1132pub fn quantity_mib(value: &str) -> Option<i64> {
1138 let (number, suffix) = split_quantity(value)?;
1139 let bytes = match suffix {
1140 "" => number,
1141 "k" => number * 1e3,
1142 "M" => number * 1e6,
1143 "G" => number * 1e9,
1144 "T" => number * 1e12,
1145 "Ki" => number * 1024.0,
1146 "Mi" => number * 1024.0 * 1024.0,
1147 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
1148 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
1149 _ => return None,
1151 };
1152 Some((bytes / (1024.0 * 1024.0)) as i64)
1153}
1154
1155pub fn millicores(value: &str) -> Option<i64> {
1157 let (number, suffix) = split_quantity(value)?;
1158 match suffix {
1159 "" => Some((number * 1000.0) as i64),
1160 "m" => Some(number as i64),
1161 _ => None,
1162 }
1163}
1164
1165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1168#[serde(rename_all = "camelCase")]
1169pub struct SandboxOutputs {
1170 pub parent_name: String,
1172 #[serde(skip_serializing_if = "Option::is_none")]
1174 pub identifier: Option<String>,
1175 #[serde(skip_serializing_if = "Option::is_none")]
1177 pub endpoint: Option<String>,
1178}
1179
1180impl ResourceOutputsDefinition for SandboxOutputs {
1181 fn get_resource_type(&self) -> ResourceType {
1182 Sandbox::RESOURCE_TYPE
1183 }
1184
1185 fn as_any(&self) -> &dyn Any {
1186 self
1187 }
1188
1189 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1190 Box::new(self.clone())
1191 }
1192
1193 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1194 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1195 }
1196
1197 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1198 serde_json::to_value(self)
1199 }
1200}
1201
1202impl ResourceDefinition for Sandbox {
1203 fn get_resource_type(&self) -> ResourceType {
1204 Self::RESOURCE_TYPE
1205 }
1206
1207 fn id(&self) -> &str {
1208 &self.id
1209 }
1210
1211 fn replace_after_failed_create_is_safe(&self) -> bool {
1212 false
1215 }
1216
1217 fn get_dependencies(&self) -> Vec<ResourceRef> {
1218 Vec::new()
1219 }
1220
1221 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1222 let new_sandbox = new_config
1223 .as_any()
1224 .downcast_ref::<Sandbox>()
1225 .ok_or_else(|| {
1226 AlienError::new(ErrorData::UnexpectedResourceType {
1227 resource_id: self.id.clone(),
1228 expected: Self::RESOURCE_TYPE,
1229 actual: new_config.get_resource_type(),
1230 })
1231 })?;
1232
1233 if self.id != new_sandbox.id {
1234 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1235 resource_id: self.id.clone(),
1236 reason: "the 'id' field is immutable".to_string(),
1237 }));
1238 }
1239
1240 Ok(())
1241 }
1242
1243 fn as_any(&self) -> &dyn Any {
1244 self
1245 }
1246
1247 fn as_any_mut(&mut self) -> &mut dyn Any {
1248 self
1249 }
1250
1251 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1252 Box::new(self.clone())
1253 }
1254
1255 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1256 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1257 }
1258
1259 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1260 serde_json::to_value(self)
1261 }
1262}
1263
1264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1266pub enum AzureSandboxImage<'a> {
1267 Catalog(&'a str),
1269 Registry(&'a str),
1272}
1273
1274impl<'a> AzureSandboxImage<'a> {
1275 pub fn as_str(&self) -> &'a str {
1277 match self {
1278 Self::Catalog(value) | Self::Registry(value) => value,
1279 }
1280 }
1281}
1282
1283pub const AZURE_DISK_IMAGE_LABEL: &str = "alienImage";
1285
1286pub fn classify_azure_sandbox_image(image: &str) -> Option<AzureSandboxImage<'_>> {
1290 let image = image.trim();
1291 if image.is_empty() {
1292 return None;
1293 }
1294 if image
1295 .chars()
1296 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1297 {
1298 return Some(AzureSandboxImage::Catalog(image));
1299 }
1300 (image.contains(|c| matches!(c, '/' | ':' | '@')) && OCI_REFERENCE.is_match(image))
1301 .then_some(AzureSandboxImage::Registry(image))
1302}
1303
1304pub fn azure_disk_image_label(reference: &str) -> String {
1307 let digest = format!("{:x}", Sha256::digest(reference.trim().as_bytes()));
1308 digest[..32].to_string()
1309}
1310
1311static OCI_REFERENCE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1313 let domain_component = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
1314 let domain = format!(r"{domain_component}(?:\.{domain_component})*(?::[0-9]+)?");
1315 let path_component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
1316 let name = format!(r"(?:{domain}/)?{path_component}(?:/{path_component})*");
1317 let tag = r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}";
1318 let digest = r"[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,}";
1319 regex::Regex::new(&format!(r"^{name}(?::{tag})?(?:@{digest})?$"))
1320 .expect("the OCI reference grammar compiles")
1321});
1322
1323pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1328
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1331pub enum BundleUri<'a> {
1332 Literal(&'a str),
1334 Regional { before: &'a str, after: &'a str },
1337}
1338
1339pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1343 let (above_file, _) = key.rsplit_once('/')?;
1344 let (above_version, _) = above_file.rsplit_once('/')?;
1345 Some(above_version)
1346}
1347
1348pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1354 let path = uri
1355 .strip_prefix("s3://")
1356 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1357 let (bucket, key) = path
1358 .split_once('/')
1359 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1360
1361 if path.contains('*') || path.contains('?') {
1365 return Err(format!(
1366 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1367 role's grant, so '*' and '?' would widen it past the bundle"
1368 ));
1369 }
1370
1371 if key.contains('{') || key.contains('}') {
1372 return Err(format!(
1373 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1374 bucket name alone"
1375 ));
1376 }
1377
1378 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1379 if bucket.contains('{') || bucket.contains('}') {
1380 return Err(format!(
1381 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1382 only one"
1383 ));
1384 }
1385 return Ok(BundleUri::Literal(uri));
1386 };
1387
1388 if after.contains(BUNDLE_REGION_TOKEN) {
1389 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1390 }
1391 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1392 return Err(format!(
1393 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1394 ));
1395 }
1396
1397 Ok(BundleUri::Regional {
1398 before: &uri[.."s3://".len() + before.len()],
1399 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1400 })
1401}
1402
1403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1405pub enum EcrImageRegion<'a> {
1406 Literal(&'a str),
1408 Deployment,
1410}
1411
1412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1414pub struct EcrImageRepository<'a> {
1415 pub account_id: &'a str,
1416 pub region: EcrImageRegion<'a>,
1417 pub repository: &'a str,
1419}
1420
1421impl EcrImageRepository<'_> {
1422 pub fn arn(&self, partition: &str, region: &str) -> String {
1425 let region = match self.region {
1426 EcrImageRegion::Literal(region) => region,
1427 EcrImageRegion::Deployment => region,
1428 };
1429 format!(
1430 "arn:{partition}:ecr:{region}:{}:repository/{}",
1431 self.account_id, self.repository
1432 )
1433 }
1434}
1435
1436pub fn parse_ecr_image_repository(
1442 image: &str,
1443) -> std::result::Result<EcrImageRepository<'_>, String> {
1444 let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1445 let (host, path) = image
1446 .split_once('/')
1447 .ok_or_else(|| refuse("names no repository"))?;
1448 let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1449 refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1450 })?;
1451 let region = rest
1453 .strip_suffix(".amazonaws.com.cn")
1454 .or_else(|| rest.strip_suffix(".amazonaws.com"))
1455 .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1456 if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1457 return Err(refuse("names no 12-digit account in its registry host"));
1458 }
1459 let region = if region == BUNDLE_REGION_TOKEN {
1460 EcrImageRegion::Deployment
1461 } else if !region.is_empty()
1462 && region
1463 .bytes()
1464 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1465 {
1466 EcrImageRegion::Literal(region)
1467 } else {
1468 return Err(refuse(&format!(
1469 "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1470 )));
1471 };
1472
1473 let repository = match path.split_once('@') {
1474 Some((repository, _digest)) => repository,
1475 None => match path.rsplit_once('/') {
1476 Some((parent, last)) => match last.split_once(':') {
1477 Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1478 None => path,
1479 },
1480 None => path.split_once(':').map_or(path, |(name, _tag)| name),
1481 },
1482 };
1483 let valid_segment = |segment: &str| {
1484 !segment.is_empty()
1485 && segment.bytes().all(|b| {
1486 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1487 })
1488 };
1489 if !repository.split('/').all(valid_segment) {
1490 return Err(refuse(
1491 "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1492 ));
1493 }
1494 Ok(EcrImageRepository {
1495 account_id,
1496 region,
1497 repository,
1498 })
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503 use super::*;
1504
1505 #[test]
1506 fn private_database_setup_accepts_the_default_network() {
1507 for lifecycle in [
1508 crate::ResourceLifecycle::Frozen,
1509 crate::ResourceLifecycle::Live,
1510 ] {
1511 let stack = crate::Stack::new("database".to_string())
1512 .add(
1513 crate::Postgres::new("metadata".to_string()).build(),
1514 lifecycle,
1515 )
1516 .build();
1517 assert!(!restricts_network_mode(&stack, false));
1518 assert!(!restricts_network_mode(&stack, true));
1519 }
1520 assert!(!restricts_network_mode(
1521 &crate::Stack::new("empty".to_string()).build(),
1522 false,
1523 ));
1524 }
1525
1526 fn repository_key(image: &str) -> String {
1529 match parse_ecr_image_repository(image) {
1530 Ok(parsed) => {
1531 let host = image.split_once('/').map_or(image, |(host, _)| host);
1532 format!("{host}/{}", parsed.repository)
1533 }
1534 Err(_) => image.to_string(),
1535 }
1536 }
1537
1538 #[test]
1539 fn a_private_base_image_names_one_repository() {
1540 let vectors: serde_json::Value = serde_json::from_str(include_str!(
1541 "../../tests/fixtures/ecr-image-repository-parity.json"
1542 ))
1543 .expect("the ECR repository vectors must be JSON");
1544 let field = |case: &serde_json::Value, name: &str| {
1545 case[name]
1546 .as_str()
1547 .unwrap_or_else(|| panic!("vector must carry {name}: {case}"))
1548 .to_string()
1549 };
1550
1551 for case in vectors["accepted"].as_array().expect("accepted vectors") {
1552 let image = field(case, "image");
1553 let region = field(case, "region");
1554 let region = if region == BUNDLE_REGION_TOKEN {
1555 EcrImageRegion::Deployment
1556 } else {
1557 EcrImageRegion::Literal(®ion)
1558 };
1559 assert_eq!(
1560 parse_ecr_image_repository(&image),
1561 Ok(EcrImageRepository {
1562 account_id: &field(case, "accountId"),
1563 region,
1564 repository: &field(case, "repository"),
1565 }),
1566 "{image}"
1567 );
1568 assert_eq!(
1569 repository_key(&image),
1570 field(case, "repositoryKey"),
1571 "{image}"
1572 );
1573 }
1574
1575 for case in vectors["refused"].as_array().expect("refused vectors") {
1576 let image = field(case, "image");
1577 assert!(
1578 parse_ecr_image_repository(&image).is_err(),
1579 "{image} must be refused"
1580 );
1581 assert_eq!(
1582 repository_key(&image),
1583 field(case, "repositoryKey"),
1584 "{image}"
1585 );
1586 }
1587 }
1588
1589 #[test]
1590 fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1591 let regional =
1592 parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1593 .expect("parses");
1594 let pinned =
1595 parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1596 .expect("parses");
1597 assert_eq!(
1598 regional.arn("aws-us-gov", "us-gov-west-1"),
1599 "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1600 );
1601 assert_eq!(
1602 pinned.arn("aws", "us-east-1"),
1603 "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1604 );
1605 }
1606
1607 #[test]
1610 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1611 for uri in [
1612 "s3://acme/team-*/v1/bundle.zip",
1613 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1614 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1615 ] {
1616 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1617 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1618 }
1619
1620 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1621 .expect("an ordinary key still parses");
1622 }
1623
1624 #[test]
1628 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1629 assert_eq!(
1630 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1631 Some("sandbox-bundle")
1632 );
1633 assert_eq!(
1634 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1635 Some("artifacts/team-a/sandbox"),
1636 "a deeper key narrows the prefix, it never widens to the first segment"
1637 );
1638
1639 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1642 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1643 }
1644
1645 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1646 Sandbox::new("agent-sbx".to_string())
1647 .code(SandboxCode::Image {
1648 image: "ubuntu".to_string(),
1649 })
1650 .limits(SandboxLimits {
1651 cpu: "1".to_string(),
1652 memory: "2Gi".to_string(),
1653 disk: "20Gi".to_string(),
1654 max_processes: None,
1655 })
1656 .egress(egress)
1657 .lifecycle(SandboxLifecyclePolicy {
1658 max_lifetime_seconds: None,
1659 idle_pause_seconds: None,
1660 })
1661 .preview_ports(preview_ports)
1662 .build()
1663 }
1664
1665 #[test]
1668 fn a_uri_without_a_token_is_carried_whole() {
1669 assert_eq!(
1670 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1671 Ok(BundleUri::Literal(
1672 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1673 ))
1674 );
1675 }
1676
1677 #[test]
1680 fn a_regional_uri_splits_either_side_of_the_token() {
1681 let BundleUri::Regional { before, after } =
1682 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1683 .expect("the token is accepted in the bucket")
1684 else {
1685 panic!("a bucket-position token must split");
1686 };
1687
1688 assert_eq!(before, "s3://acme-artifacts-");
1689 assert_eq!(after, "/agents/bundle.zip");
1690 assert_eq!(
1691 format!("{before}us-east-2{after}"),
1692 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1693 "the halves must rejoin to the URI the vendor meant"
1694 );
1695 }
1696
1697 #[test]
1700 fn a_token_this_build_cannot_resolve_is_refused() {
1701 for uri in [
1702 "s3://acme-artifacts-{regio}/bundle.zip",
1703 "s3://acme-artifacts/{region}/bundle.zip",
1704 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1705 "s3://acme-artifacts/bundle-{version}.zip",
1706 "s3://acme}-artifacts-{region}/bundle.zip",
1707 "s3://acme{-artifacts-{region}/bundle.zip",
1708 ] {
1709 assert!(
1710 parse_bundle_uri(uri).is_err(),
1711 "'{uri}' must be refused before it can reach an image build"
1712 );
1713 }
1714 }
1715
1716 #[test]
1717 fn resource_type_is_stable() {
1718 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1719 }
1720
1721 #[test]
1722 fn capability_sets_are_per_platform() {
1723 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1724 assert!(
1725 gcp.reconnect,
1726 "generation from the container boot id makes a sandbox reachable across processes"
1727 );
1728 assert!(!gcp.preview);
1729 assert!(gcp.enforced_limits);
1730
1731 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1732 assert!(azure.files, "every backend moves files");
1733 assert!(gcp.files);
1734 assert!(azure.domain_egress_rules);
1737 assert!(azure.egress_deny);
1738 assert!(azure.enforced_limits);
1741 assert!(azure.pause_resume);
1742 assert!(!azure.snapshot);
1746 assert!(!azure.preview);
1747
1748 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1749 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1750 assert!(aws.pause_resume);
1751
1752 let k8s =
1753 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1754 assert!(
1755 !k8s.preview,
1756 "the sandbox-scoped ingress gateway does not exist yet"
1757 );
1758 }
1759
1760 #[test]
1769 fn supervisor_isolation_is_per_platform() {
1770 let value = |platform| {
1771 SandboxCapabilities::for_platform(platform)
1772 .expect("supported")
1773 .supervisor_isolation
1774 };
1775
1776 assert!(
1777 value(Platform::Aws),
1778 "root agent setuids the command to 60000"
1779 );
1780 assert!(
1781 value(Platform::Local),
1782 "the supervisor is on the host, outside the container"
1783 );
1784 assert!(
1785 !value(Platform::Kubernetes),
1786 "a single pinned uid cannot be split"
1787 );
1788 assert!(!value(Platform::Azure), "no Alien process runs the command");
1789 assert!(
1790 !value(Platform::Gcp),
1791 "no separate supervisor identity runs the command"
1792 );
1793 }
1794
1795 #[test]
1799 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1800 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1801 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1802
1803 assert_eq!(
1804 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1805 "the older axis cannot tell them apart"
1806 );
1807 assert!(
1808 aws.supervisor_isolation,
1809 "AWS setuids the command off the supervisor"
1810 );
1811 assert!(
1812 !gcp.supervisor_isolation,
1813 "the command runs under no separate supervisor identity"
1814 );
1815 }
1816
1817 #[test]
1822 fn gcp_agent_platform_row_matches_measured_backend() {
1823 let row = SandboxCapabilities::gcp_agent_platform();
1824
1825 assert!(row.files, "agent file ops move over the sandbox envelope");
1826 assert!(
1827 row.reconnect,
1828 "generation is derived from the container boot id, so a sandbox is reachable across \
1829 processes"
1830 );
1831 assert!(
1832 !row.preview,
1833 "the only ingress is :execute; no port-scoped capability"
1834 );
1835 assert!(
1836 !row.pause_resume,
1837 ":resume can return a fresh container, so a pause keeps no state"
1838 );
1839 assert!(
1840 !row.snapshot,
1841 "the create path never sends a snapshot, so none is reachable through the trait"
1842 );
1843 assert!(
1844 !row.domain_egress_rules,
1845 "VPC and DNS peering is not a hostname allowlist"
1846 );
1847 assert!(
1848 row.egress_deny,
1849 "a declared deny blocks both egress and DNS"
1850 );
1851 assert!(
1852 row.enforced_limits,
1853 "ceilings are enforced, by terminating the sandbox on breach"
1854 );
1855 assert!(!row.process_limit, "no process-count ceiling is observed");
1856 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1857 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1858 assert!(
1859 !row.supervisor_isolation,
1860 "the command is not run under a separate supervisor identity"
1861 );
1862
1863 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1865 assert_eq!(
1866 live, row,
1867 "the Platform::Gcp arm is the Agent Platform capability row"
1868 );
1869 }
1870
1871 #[test]
1872 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1873 let error = SandboxCapabilities::for_platform(Platform::Machines)
1874 .expect_err("Machines has no sandbox backend");
1875 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1876 }
1877
1878 #[test]
1879 fn unsupported_capability_names_platform_and_capability() {
1880 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1881 let error = capabilities
1882 .require(SandboxCapability::Preview, Platform::Gcp)
1883 .expect_err("GCP has no preview");
1884
1885 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1886 let rendered = error.to_string();
1887 assert!(
1888 rendered.contains("preview"),
1889 "names the capability: {rendered}"
1890 );
1891 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1892 }
1893
1894 #[test]
1898 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1899 let sandbox = sandbox_with(
1900 SandboxEgress::AllowDomains {
1901 domains: vec!["example.com".to_string()],
1902 },
1903 vec![],
1904 );
1905
1906 for platform in [
1907 Platform::Aws,
1908 Platform::Gcp,
1909 Platform::Kubernetes,
1910 Platform::Local,
1911 ] {
1912 let error = sandbox
1913 .validate_for_platform(platform)
1914 .expect_err("only Azure expresses a hostname allowlist");
1915 assert_eq!(
1916 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1917 "on {platform:?}"
1918 );
1919 }
1920
1921 assert!(
1922 SandboxCapabilities::for_platform(Platform::Azure)
1923 .expect("supported")
1924 .domain_egress_rules,
1925 "Azure's egress policy matches on host pattern"
1926 );
1927 }
1928
1929 #[test]
1932 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1933 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
1934
1935 assert!(
1938 SandboxCapabilities::for_platform(Platform::Gcp)
1939 .expect("supported")
1940 .egress_deny
1941 );
1942
1943 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1944 sandbox
1945 .validate_for_platform(platform)
1946 .expect("deny is enforced here");
1947 }
1948
1949 let egress_only = Sandbox::new("sbx".to_string())
1951 .code(SandboxCode::Image {
1952 image: "alpine".to_string(),
1953 })
1954 .egress(SandboxEgress::None)
1955 .lifecycle(SandboxLifecyclePolicy {
1956 max_lifetime_seconds: None,
1957 idle_pause_seconds: None,
1958 })
1959 .build();
1960
1961 egress_only
1962 .validate_for_platform(Platform::Azure)
1963 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1964 }
1965
1966 #[test]
1970 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1971 let undeclared = Sandbox::new("sbx".to_string())
1972 .code(SandboxCode::Image {
1973 image: "alpine".to_string(),
1974 })
1975 .egress(SandboxEgress::None)
1976 .lifecycle(SandboxLifecyclePolicy {
1977 max_lifetime_seconds: None,
1978 idle_pause_seconds: None,
1979 })
1980 .build();
1981
1982 undeclared
1983 .validate_for_platform(Platform::Azure)
1984 .expect("a sandbox naming no ceilings takes the platform's own");
1985
1986 assert_eq!(undeclared.resolved_limits().cpu, "1");
1988 }
1989
1990 #[test]
1994 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1995 let sized = |cpu: &str, memory: &str, disk: &str| {
1996 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
1997 let limits = sandbox
1998 .limits
1999 .as_mut()
2000 .expect("the fixture declares limits");
2001 limits.cpu = cpu.to_string();
2002 limits.memory = memory.to_string();
2003 limits.disk = disk.to_string();
2004 sandbox.validate_for_platform(Platform::Azure)
2005 };
2006
2007 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
2008 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
2009 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
2010
2011 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
2013 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
2014 assert!(off_step.to_string().contains("cpu"), "{off_step}");
2015
2016 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
2017 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
2018
2019 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
2022 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
2023 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
2024 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
2025
2026 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
2027 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
2028 }
2029
2030 #[test]
2031 fn preview_ports_require_the_preview_capability() {
2032 let sandbox = sandbox_with(SandboxEgress::None, vec![8080]);
2033
2034 sandbox
2035 .validate_for_platform(Platform::Aws)
2036 .expect("AWS mints a port-scoped JWE");
2037
2038 let error = sandbox
2039 .validate_for_platform(Platform::Kubernetes)
2040 .expect_err("Kubernetes preview is deferred");
2041 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2042 }
2043
2044 #[test]
2048 fn a_private_base_image_is_refused_off_aws() {
2049 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2050 sandbox.code = SandboxCode::Image {
2051 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
2052 };
2053 sandbox.private_base_image =
2054 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
2055
2056 sandbox
2057 .validate_for_platform(Platform::Aws)
2058 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
2059
2060 for platform in [
2061 Platform::Gcp,
2062 Platform::Azure,
2063 Platform::Kubernetes,
2064 Platform::Local,
2065 ] {
2066 let error = sandbox
2067 .validate_for_platform(platform)
2068 .expect_err("a backend that builds no image must refuse a base image for one");
2069 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2070 assert!(
2071 error.to_string().contains("privateBaseImage"),
2072 "the refusal must name the field the user declared: {error}"
2073 );
2074 }
2075 }
2076
2077 #[test]
2078 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
2079 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2080 sandbox
2081 .validate_for_platform(Platform::Gcp)
2082 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
2083 }
2084
2085 #[test]
2086 fn invalid_quantities_are_rejected_with_the_offending_field() {
2087 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2088 sandbox
2089 .limits
2090 .as_mut()
2091 .expect("the fixture declares limits")
2092 .memory = "2Gb".to_string();
2093
2094 let error = sandbox
2095 .validate_for_platform(Platform::Aws)
2096 .expect_err("Gb is not a valid suffix");
2097 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2098 assert!(error.to_string().contains("memory"));
2099
2100 sandbox
2101 .limits
2102 .as_mut()
2103 .expect("the fixture declares limits")
2104 .memory = "2Gi".to_string();
2105 sandbox
2106 .limits
2107 .as_mut()
2108 .expect("the fixture declares limits")
2109 .cpu = "0".to_string();
2110 let error = sandbox
2111 .validate_for_platform(Platform::Aws)
2112 .expect_err("zero cpu is not a ceiling");
2113 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2114 }
2115
2116 #[test]
2117 fn zero_max_processes_is_rejected() {
2118 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2119 sandbox
2120 .limits
2121 .as_mut()
2122 .expect("the fixture declares limits")
2123 .max_processes = Some(0);
2124
2125 let error = sandbox
2126 .validate_for_platform(Platform::Local)
2127 .expect_err("a sandbox must be able to run at least one process");
2128 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2129 assert!(error.to_string().contains("maxProcesses"));
2130 }
2131
2132 #[test]
2136 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
2137 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2138 sandbox
2139 .limits
2140 .as_mut()
2141 .expect("the fixture declares limits")
2142 .max_processes = Some(256);
2143
2144 sandbox
2145 .validate_for_platform(Platform::Local)
2146 .expect("Docker takes a pids limit");
2147
2148 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
2149 let error = sandbox
2150 .validate_for_platform(platform)
2151 .expect_err("a process ceiling nothing applies must be refused");
2152 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2153 }
2154 }
2155
2156 #[test]
2160 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
2161 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2162
2163 for seconds in [0, 28_801, 100_000] {
2164 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2165 let error = sandbox
2166 .validate_for_platform(Platform::Aws)
2167 .expect_err("a lifetime outside what AWS runs is refused");
2168 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
2169
2170 sandbox
2172 .validate_for_platform(Platform::Kubernetes)
2173 .expect("the kubelet takes any activeDeadlineSeconds");
2174 }
2175
2176 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
2177 sandbox
2178 .validate_for_platform(Platform::Aws)
2179 .expect("the ceiling itself is allowed");
2180 }
2181
2182 #[test]
2185 fn an_image_azure_cannot_pull_is_refused_while_planning() {
2186 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2187 sandbox.limits = None;
2190
2191 for image in ["ubuntu@sha256:abc", "", " ", "ubuntu latest", "ubuntu?x"] {
2193 sandbox.code = SandboxCode::Image {
2194 image: image.to_string(),
2195 };
2196 let error = sandbox
2197 .validate_for_platform(Platform::Azure)
2198 .expect_err("an image Azure has nowhere to put is refused");
2199 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
2200 }
2201
2202 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
2203 sandbox.code = SandboxCode::Image {
2204 image: image.to_string(),
2205 };
2206 sandbox
2207 .validate_for_platform(Platform::Azure)
2208 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
2209 }
2210
2211 for image in [
2212 "ubuntu:24.04",
2213 "ghcr.io/myorg/sandbox:latest",
2214 "docker.io/library/python:3.14-slim",
2215 "localhost:5000/team/agent@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d",
2216 ] {
2217 sandbox.code = SandboxCode::Image {
2218 image: image.to_string(),
2219 };
2220 sandbox
2221 .validate_for_platform(Platform::Azure)
2222 .unwrap_or_else(|error| panic!("'{image}' is a registry image: {error}"));
2223 }
2224
2225 sandbox.code = SandboxCode::Image {
2227 image: " ubuntu ".to_string(),
2228 };
2229 assert_eq!(
2230 sandbox
2231 .azure_image()
2232 .expect("a padded name is still a name"),
2233 AzureSandboxImage::Catalog("ubuntu")
2234 );
2235 }
2236
2237 #[test]
2241 fn every_value_the_catalog_allowlist_accepted_is_still_a_catalog_name() {
2242 const ALPHABET: &[u8] =
2243 b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
2244
2245 let assert_catalog = |value: &str| {
2246 assert_eq!(
2247 classify_azure_sandbox_image(value),
2248 Some(AzureSandboxImage::Catalog(value.trim())),
2249 "'{value}'"
2250 );
2251 };
2252
2253 for a in ALPHABET {
2254 assert_catalog(&String::from_utf8(vec![*a]).unwrap());
2255 for b in ALPHABET {
2256 assert_catalog(&String::from_utf8(vec![*a, *b]).unwrap());
2257 for c in ALPHABET {
2258 assert_catalog(&String::from_utf8(vec![*a, *b, *c]).unwrap());
2259 }
2260 }
2261 }
2262
2263 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2264 let mut next = || {
2265 state ^= state << 13;
2266 state ^= state >> 7;
2267 state ^= state << 17;
2268 state
2269 };
2270 for _ in 0..20_000 {
2271 let len = 4 + (next() % 60) as usize;
2272 let mut value: String = (0..len)
2273 .map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize] as char)
2274 .collect();
2275 if next() % 4 == 0 {
2276 value = format!(" {value}\t");
2277 }
2278 assert_catalog(&value);
2279 }
2280 }
2281
2282 #[test]
2285 fn the_disk_image_label_is_stable_and_label_safe() {
2286 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
2287 assert_eq!(
2288 label,
2289 azure_disk_image_label(" docker.io/library/python:3.14-slim ")
2290 );
2291 assert_ne!(
2292 label,
2293 azure_disk_image_label("docker.io/library/python:3.13-slim")
2294 );
2295 assert_eq!(label.len(), 32);
2296 assert!(label.chars().all(|c| c.is_ascii_hexdigit()), "{label}");
2297 }
2298
2299 #[test]
2303 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2304 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2305 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2306
2307 sandbox
2308 .validate_for_platform(Platform::Kubernetes)
2309 .expect("the kubelet enforces activeDeadlineSeconds");
2310 sandbox
2311 .validate_for_platform(Platform::Aws)
2312 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2313
2314 for platform in [Platform::Azure, Platform::Local] {
2315 let error = sandbox
2316 .validate_for_platform(platform)
2317 .expect_err("a deadline nothing applies must be refused");
2318 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2319 }
2320 }
2321
2322 #[test]
2326 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2327 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
2328 let tier = sandbox
2329 .microvm_tier()
2330 .expect("2Gi/1cpu/20Gi is satisfiable");
2331
2332 assert_eq!(
2333 tier.peak_memory_mib, 2048,
2334 "the peak is the declared ceiling"
2335 );
2336 assert_eq!(
2337 tier.baseline_memory_mib, 512,
2338 "which is a quarter of it as the baseline"
2339 );
2340 assert!(tier.max_disk_mib <= 20 * 1024);
2341 }
2342
2343 #[test]
2347 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2348 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2349 {
2350 let limits = sandbox
2351 .limits
2352 .as_mut()
2353 .expect("the fixture declares limits");
2354 limits.cpu = "1".to_string();
2355 limits.memory = "8Gi".to_string();
2356 }
2357
2358 let error = sandbox
2359 .microvm_tier()
2360 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2361 assert!(
2362 error.to_string().contains("4 vCPU"),
2363 "the refusal must say what the memory ceiling implies: {error}"
2364 );
2365
2366 sandbox
2367 .limits
2368 .as_mut()
2369 .expect("the fixture declares limits")
2370 .cpu = "4".to_string();
2371 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2372 assert_eq!(tier.peak_memory_mib, 8192);
2373 }
2374
2375 #[test]
2378 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2379 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2380 sandbox
2381 .limits
2382 .as_mut()
2383 .expect("the fixture declares limits")
2384 .memory = "1Gi".to_string();
2385
2386 let error = sandbox
2387 .validate_for_platform(Platform::Aws)
2388 .expect_err("no MicroVM size peaks at or below 1Gi");
2389 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2390 assert!(
2391 error.to_string().contains("2Gi"),
2392 "the refusal must say what the smallest holdable ceiling is: {error}"
2393 );
2394 }
2395
2396 #[test]
2399 fn source_code_is_refused_where_no_backend_builds_it() {
2400 let sandbox = Sandbox::new("agent".to_string())
2401 .code(SandboxCode::Source {
2402 src: "./sandbox".to_string(),
2403 toolchain: ToolchainConfig::Docker {
2404 dockerfile: None,
2405 build_args: None,
2406 target: None,
2407 },
2408 })
2409 .egress(SandboxEgress::None)
2410 .lifecycle(SandboxLifecyclePolicy {
2411 max_lifetime_seconds: None,
2412 idle_pause_seconds: None,
2413 })
2414 .build();
2415
2416 sandbox
2417 .validate_for_platform(Platform::Aws)
2418 .expect("an AWS sandbox base image is built by `alien build`");
2419 sandbox
2420 .validate_for_platform(Platform::Azure)
2421 .expect("an Azure sandbox base image is built by `alien build`");
2422 sandbox
2423 .validate_for_platform(Platform::Gcp)
2424 .expect("a GCP sandbox base image is built by `alien build`");
2425
2426 for platform in [Platform::Kubernetes, Platform::Local] {
2427 let error = sandbox
2428 .validate_for_platform(platform)
2429 .expect_err("no backend builds a sandbox image from source here");
2430 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2431 assert!(
2432 error.to_string().contains("code.image"),
2433 "the refusal must say what to write instead: {error}"
2434 );
2435 assert!(
2436 error.to_string().contains(&platform.to_string()),
2437 "the refusal must name the platform that cannot build it: {error}"
2438 );
2439 }
2440 }
2441
2442 #[test]
2445 fn every_accepted_unit_converts_rather_than_falling_back() {
2446 assert_eq!(quantity_mib("2Gi"), Some(2048));
2447 assert_eq!(quantity_mib("512Mi"), Some(512));
2448 assert_eq!(quantity_mib("4G"), Some(3814));
2449 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2450 assert_eq!(millicores("1"), Some(1000));
2451 assert_eq!(millicores("500m"), Some(500));
2452 }
2453
2454 #[test]
2455 fn privileged_supervisor_fixes_identity_and_requires_an_enforceable_backend() {
2456 let mut sandbox = sandbox_with(
2457 SandboxEgress::AllowDomains {
2458 domains: vec!["example.com".to_string()],
2459 },
2460 vec![],
2461 );
2462 sandbox.privileged_supervisor = Some(SandboxPrivilegedSupervisor { command_uid: 60001 });
2463 sandbox
2464 .validate_for_platform(Platform::Aws)
2465 .expect("AWS can enforce the policy in the agent");
2466 assert_eq!(sandbox.cloud_egress(), &SandboxEgress::Allow);
2467 assert_eq!(
2468 sandbox.supervisor_environment()["ALIEN_SANDBOX_EXPECTED_EXEC_UID"],
2469 "60001"
2470 );
2471 for platform in [
2472 Platform::Local,
2473 Platform::Kubernetes,
2474 Platform::Azure,
2475 Platform::Gcp,
2476 ] {
2477 let error = sandbox
2478 .validate_for_platform(platform)
2479 .expect_err("cannot grant a privilege boundary the backend lacks");
2480 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2481 assert!(error.message.contains("privilegedSupervisor"));
2482 }
2483 for uid in [0, u32::MAX] {
2484 sandbox.privileged_supervisor.as_mut().unwrap().command_uid = uid;
2485 let error = sandbox
2486 .validate_for_platform(Platform::Aws)
2487 .expect_err("invalid uid must fail at plan time");
2488 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2489 }
2490 }
2491
2492 #[test]
2493 fn unknown_fields_are_rejected() {
2494 let json = r#"{
2495 "id": "sbx",
2496 "code": {"type": "image", "image": "ubuntu:24.04"},
2497 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2498 "egress": {"mode": "deny"},
2499 "lifecycle": {},
2500 "unexpected": true
2501 }"#;
2502
2503 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2504 }
2505
2506 #[test]
2507 fn serialization_roundtrips() {
2508 let sandbox = sandbox_with(
2509 SandboxEgress::AllowDomains {
2510 domains: vec!["example.com".to_string()],
2511 },
2512 vec![8080, 9090],
2513 );
2514
2515 let json = serde_json::to_string(&sandbox).expect("serializes");
2516 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2517 assert_eq!(sandbox, restored);
2518 }
2519
2520 #[test]
2521 fn id_is_immutable_across_updates() {
2522 let original = sandbox_with(SandboxEgress::None, vec![]);
2523 let renamed = Sandbox::new("other".to_string())
2524 .code(SandboxCode::Image {
2525 image: "ubuntu".to_string(),
2526 })
2527 .limits(
2528 original
2529 .limits
2530 .clone()
2531 .expect("the fixture declares limits"),
2532 )
2533 .egress(SandboxEgress::None)
2534 .lifecycle(SandboxLifecyclePolicy {
2535 max_lifetime_seconds: None,
2536 idle_pause_seconds: None,
2537 })
2538 .build();
2539
2540 original
2541 .validate_update(&original.clone())
2542 .expect("an unchanged config is a valid update");
2543 original
2544 .validate_update(&renamed)
2545 .expect_err("renaming a sandbox is not an update");
2546 }
2547
2548 #[test]
2554 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2555 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2556 Sandbox::new("sbx".to_string())
2557 .code(SandboxCode::Image {
2558 image: "ubuntu".to_string(),
2559 })
2560 .egress(SandboxEgress::Allow)
2561 .lifecycle(lifecycle)
2562 .build()
2563 .validate_for_platform(Platform::Azure)
2564 };
2565
2566 with_policy(SandboxLifecyclePolicy {
2567 max_lifetime_seconds: None,
2568 idle_pause_seconds: Some(900),
2569 })
2570 .expect("Azure pauses a sandbox on idle");
2571
2572 let error = with_policy(SandboxLifecyclePolicy {
2573 max_lifetime_seconds: Some(3600),
2574 idle_pause_seconds: None,
2575 })
2576 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2577 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2578 assert!(
2579 error.message.contains("sandboxLifetime"),
2580 "names the capability: {}",
2581 error.message
2582 );
2583 }
2584
2585 #[test]
2591 fn an_allowlist_with_no_domains_is_refused() {
2592 let declared = |domains: Vec<String>| {
2593 Sandbox::new("sbx".to_string())
2594 .code(SandboxCode::Image {
2595 image: "ubuntu".to_string(),
2596 })
2597 .egress(SandboxEgress::AllowDomains { domains })
2598 .lifecycle(SandboxLifecyclePolicy {
2599 max_lifetime_seconds: None,
2600 idle_pause_seconds: None,
2601 })
2602 .build()
2603 .validate_for_platform(Platform::Azure)
2604 };
2605
2606 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2607 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2608
2609 declared(vec!["api.example.com".to_string()])
2610 .expect("a named domain is what an allowlist is for");
2611 }
2612
2613 #[test]
2616 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2617 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2618 assert_eq!(SandboxEgress::None.internet_access_switch(), Some(false));
2619 assert_eq!(
2620 SandboxEgress::AllowDomains {
2621 domains: vec!["api.example.com".to_string()]
2622 }
2623 .internet_access_switch(),
2624 None,
2625 "a host list has no boolean and must not be approximated"
2626 );
2627 }
2628
2629 #[test]
2633 fn stack_state_written_before_the_lifecycle_rename_still_reads() {
2634 let stored: crate::StackResourceState = serde_json::from_value(serde_json::json!({
2635 "type": "sandbox",
2636 "config": {
2637 "id": "sandbox",
2638 "code": {
2639 "type": "image",
2640 "image": "s3://example-bundles/analysis/v1/bundle.zip"
2641 },
2642 "type": "sandbox",
2643 "egress": { "mode": "allow" },
2644 "session": { "maxLifetimeSeconds": 28800, "idleSuspendSeconds": 600 }
2645 },
2646 "status": "running",
2647 "outputs": {
2648 "type": "sandbox",
2649 "identifier": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox",
2650 "parentName": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox"
2651 },
2652 "lifecycle": "frozen",
2653 "dependencies": [
2654 { "id": "management", "type": "remote-stack-management" },
2655 { "id": "access", "type": "resource-access" }
2656 ],
2657 "controllerPlatform": "aws"
2658 }))
2659 .expect("a stack state written before the rename must still deserialize");
2660
2661 let expected = Sandbox::new("sandbox".to_string())
2662 .code(SandboxCode::Image {
2663 image: "s3://example-bundles/analysis/v1/bundle.zip".to_string(),
2664 })
2665 .egress(SandboxEgress::Allow)
2666 .lifecycle(SandboxLifecyclePolicy {
2667 max_lifetime_seconds: Some(28_800),
2668 idle_pause_seconds: Some(600),
2669 })
2670 .build();
2671 assert_eq!(stored.config.downcast_ref::<Sandbox>(), Some(&expected));
2672
2673 let rewritten = serde_json::to_value(&stored.config).expect("the sandbox serializes");
2674 assert_eq!(
2675 rewritten["lifecycle"],
2676 serde_json::json!({ "maxLifetimeSeconds": 28800, "idlePauseSeconds": 600 }),
2677 "the next write stores the current names"
2678 );
2679 assert!(rewritten.get("session").is_none());
2680 }
2681}