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 if platform == Platform::Aws {
797 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
798 if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
799 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
800 resource_id: self.id.clone(),
801 field: "maxLifetimeSeconds".to_string(),
802 value: seconds.to_string(),
803 reason: format!(
804 "AWS runs a MicroVM for between 1 and \
805 {AWS_MAX_LIFETIME_SECONDS} seconds"
806 ),
807 }));
808 }
809 }
810 }
811
812 let Some(limits) = self.limits.as_ref() else {
813 return self.validate_capabilities(&capabilities, platform);
815 };
816
817 validate_quantity(&self.id, "cpu", &limits.cpu)?;
818 validate_quantity(&self.id, "memory", &limits.memory)?;
819 validate_quantity(&self.id, "disk", &limits.disk)?;
820
821 if let Some(max_processes) = limits.max_processes {
822 if max_processes == 0 {
823 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
824 resource_id: self.id.clone(),
825 field: "maxProcesses".to_string(),
826 value: "0".to_string(),
827 reason: "a sandbox that may run no processes cannot run code".to_string(),
828 }));
829 }
830 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
831 }
832
833 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
836
837 if platform == Platform::Azure {
838 self.azure_sandbox_limits()?;
839 }
840
841 if platform == Platform::Aws {
842 self.microvm_tier()?;
845 }
846
847 self.validate_capabilities(&capabilities, platform)
848 }
849
850 pub fn azure_image(&self) -> Result<AzureSandboxImage<'_>> {
854 let SandboxCode::Image { image } = &self.code else {
855 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
856 resource_id: self.id.clone(),
857 field: "code".to_string(),
858 value: "source".to_string(),
859 reason: "the sandbox still has unbuilt source code. Run 'alien build' first"
860 .to_string(),
861 }));
862 };
863
864 classify_azure_sandbox_image(image).ok_or_else(|| {
865 let image = image.trim();
866 AlienError::new(ErrorData::SandboxLimitInvalid {
867 resource_id: self.id.clone(),
868 field: "code.image".to_string(),
869 value: image.to_string(),
870 reason: if image.is_empty() {
871 "a sandbox has to name an image".to_string()
872 } else {
873 "Azure creates a sandbox from a catalog name such as 'ubuntu' or from a \
874 registry image such as 'docker.io/library/python:3.14-slim'"
875 .to_string()
876 },
877 })
878 })
879 }
880
881 pub fn azure_sandbox_limits(&self) -> Result<()> {
885 let Some(limits) = self.limits.as_ref() else {
886 return Ok(());
889 };
890
891 let refused = |field: &str, value: &str, reason: &str| {
892 AlienError::new(ErrorData::SandboxLimitInvalid {
893 resource_id: self.id.clone(),
894 field: field.to_string(),
895 value: value.to_string(),
896 reason: reason.to_string(),
897 })
898 };
899
900 let cpu_millicores = millicores(&limits.cpu)
901 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
902
903 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
907 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
908 {
909 return Err(refused(
910 "cpu",
911 &limits.cpu,
912 "Azure allocates cpu in steps of 250m from 250m to 16000m",
913 ));
914 }
915
916 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
918 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
919
920 let memory_mib = quantity_mib(&limits.memory)
921 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
922 if memory_mib > memory_ceiling_mib {
923 return Err(refused(
924 "memory",
925 &limits.memory,
926 &format!(
927 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
928 at the declared cpu"
929 ),
930 ));
931 }
932
933 let disk_mib = quantity_mib(&limits.disk)
934 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
935 if disk_mib > disk_ceiling_mib {
936 return Err(refused(
937 "disk",
938 &limits.disk,
939 &format!(
940 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
941 the declared cpu"
942 ),
943 ));
944 }
945
946 Ok(())
947 }
948
949 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
956 let Some(limits) = self.limits.as_ref() else {
957 return Ok(MICROVM_TIERS[2]);
959 };
960
961 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
962 AlienError::new(ErrorData::SandboxLimitInvalid {
963 resource_id: self.id.clone(),
964 field: "memory".to_string(),
965 value: limits.memory.clone(),
966 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
967 })
968 })?;
969 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
970 AlienError::new(ErrorData::SandboxLimitInvalid {
971 resource_id: self.id.clone(),
972 field: "disk".to_string(),
973 value: limits.disk.clone(),
974 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
975 })
976 })?;
977 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
978 AlienError::new(ErrorData::SandboxLimitInvalid {
979 resource_id: self.id.clone(),
980 field: "cpu".to_string(),
981 value: limits.cpu.clone(),
982 reason: "expected cores or millicores".to_string(),
983 })
984 })?;
985
986 let sized = |tier: &&MicrovmTier| {
991 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
992 };
993
994 let tier = MICROVM_TIERS
995 .iter()
996 .rev()
997 .find(sized)
998 .copied()
999 .ok_or_else(|| {
1000 AlienError::new(ErrorData::SandboxLimitInvalid {
1001 resource_id: self.id.clone(),
1002 field: "memory".to_string(),
1003 value: limits.memory.clone(),
1004 reason: format!(
1005 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
1006 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
1007 disk fit no size",
1008 limits.memory, limits.disk
1009 ),
1010 })
1011 })?;
1012
1013 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
1014 if cpu_millicores < required_millicores {
1015 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1016 resource_id: self.id.clone(),
1017 field: "cpu".to_string(),
1018 value: limits.cpu.clone(),
1019 reason: format!(
1020 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
1021 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
1022 limits.memory, tier.peak_vcpu, tier.peak_vcpu
1023 ),
1024 }));
1025 }
1026
1027 Ok(tier)
1028 }
1029
1030 fn validate_capabilities(
1032 &self,
1033 capabilities: &SandboxCapabilities,
1034 platform: Platform,
1035 ) -> Result<()> {
1036 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
1037 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
1038 }
1039
1040 if let SandboxEgress::AllowDomains { domains } = &self.egress {
1046 if domains.is_empty() {
1047 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1048 resource_id: self.id.clone(),
1049 field: "egress.domains".to_string(),
1050 value: "[]".to_string(),
1051 reason: "an allowlist naming no domain denies everything; declare \
1052 egress: deny if that is what was meant"
1053 .to_string(),
1054 }));
1055 }
1056 }
1057
1058 if matches!(self.egress, SandboxEgress::None) {
1059 capabilities.require(SandboxCapability::EgressDeny, platform)?;
1060 }
1061
1062 if !self.preview_ports.is_empty() {
1063 capabilities.require(SandboxCapability::Preview, platform)?;
1064 }
1065
1066 if self.lifecycle.idle_pause_seconds.is_some() {
1067 capabilities.require(SandboxCapability::PauseResume, platform)?;
1068 }
1069
1070 if self.lifecycle.max_lifetime_seconds.is_some() {
1071 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
1072 }
1073
1074 Ok(())
1075 }
1076}
1077
1078fn default_limits() -> SandboxLimits {
1083 SandboxLimits {
1084 cpu: "1".to_string(),
1085 memory: "2Gi".to_string(),
1086 disk: "8Gi".to_string(),
1087 max_processes: None,
1088 }
1089}
1090
1091fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
1093 let invalid = |reason: &str| {
1094 AlienError::new(ErrorData::SandboxLimitInvalid {
1095 resource_id: resource_id.to_string(),
1096 field: field.to_string(),
1097 value: value.to_string(),
1098 reason: reason.to_string(),
1099 })
1100 };
1101
1102 let digits_end = value
1103 .find(|c: char| !c.is_ascii_digit() && c != '.')
1104 .unwrap_or(value.len());
1105 let (number, suffix) = value.split_at(digits_end);
1106
1107 let parsed: f64 = number
1108 .parse()
1109 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
1110
1111 if parsed <= 0.0 {
1112 return Err(invalid("must be greater than zero"));
1113 }
1114
1115 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
1116 if !SUFFIXES.contains(&suffix) {
1117 return Err(invalid(
1118 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
1119 ));
1120 }
1121
1122 Ok(())
1123}
1124
1125fn split_quantity(value: &str) -> Option<(f64, &str)> {
1127 let trimmed = value.trim();
1128 let digits_end = trimmed
1129 .find(|c: char| !c.is_ascii_digit() && c != '.')
1130 .unwrap_or(trimmed.len());
1131 let (number, suffix) = trimmed.split_at(digits_end);
1132 number.parse().ok().map(|number| (number, suffix))
1133}
1134
1135pub fn quantity_mib(value: &str) -> Option<i64> {
1141 let (number, suffix) = split_quantity(value)?;
1142 let bytes = match suffix {
1143 "" => number,
1144 "k" => number * 1e3,
1145 "M" => number * 1e6,
1146 "G" => number * 1e9,
1147 "T" => number * 1e12,
1148 "Ki" => number * 1024.0,
1149 "Mi" => number * 1024.0 * 1024.0,
1150 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
1151 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
1152 _ => return None,
1154 };
1155 Some((bytes / (1024.0 * 1024.0)) as i64)
1156}
1157
1158pub fn millicores(value: &str) -> Option<i64> {
1160 let (number, suffix) = split_quantity(value)?;
1161 match suffix {
1162 "" => Some((number * 1000.0) as i64),
1163 "m" => Some(number as i64),
1164 _ => None,
1165 }
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1171#[serde(rename_all = "camelCase")]
1172pub struct SandboxOutputs {
1173 pub parent_name: String,
1175 #[serde(skip_serializing_if = "Option::is_none")]
1177 pub identifier: Option<String>,
1178 #[serde(skip_serializing_if = "Option::is_none")]
1180 pub endpoint: Option<String>,
1181}
1182
1183impl ResourceOutputsDefinition for SandboxOutputs {
1184 fn get_resource_type(&self) -> ResourceType {
1185 Sandbox::RESOURCE_TYPE
1186 }
1187
1188 fn as_any(&self) -> &dyn Any {
1189 self
1190 }
1191
1192 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1193 Box::new(self.clone())
1194 }
1195
1196 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1197 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1198 }
1199
1200 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1201 serde_json::to_value(self)
1202 }
1203}
1204
1205impl ResourceDefinition for Sandbox {
1206 fn get_resource_type(&self) -> ResourceType {
1207 Self::RESOURCE_TYPE
1208 }
1209
1210 fn id(&self) -> &str {
1211 &self.id
1212 }
1213
1214 fn replace_after_failed_create_is_safe(&self) -> bool {
1215 false
1218 }
1219
1220 fn get_dependencies(&self) -> Vec<ResourceRef> {
1221 Vec::new()
1222 }
1223
1224 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1225 let new_sandbox = new_config
1226 .as_any()
1227 .downcast_ref::<Sandbox>()
1228 .ok_or_else(|| {
1229 AlienError::new(ErrorData::UnexpectedResourceType {
1230 resource_id: self.id.clone(),
1231 expected: Self::RESOURCE_TYPE,
1232 actual: new_config.get_resource_type(),
1233 })
1234 })?;
1235
1236 if self.id != new_sandbox.id {
1237 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1238 resource_id: self.id.clone(),
1239 reason: "the 'id' field is immutable".to_string(),
1240 }));
1241 }
1242
1243 Ok(())
1244 }
1245
1246 fn as_any(&self) -> &dyn Any {
1247 self
1248 }
1249
1250 fn as_any_mut(&mut self) -> &mut dyn Any {
1251 self
1252 }
1253
1254 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1255 Box::new(self.clone())
1256 }
1257
1258 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1259 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1260 }
1261
1262 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1263 serde_json::to_value(self)
1264 }
1265}
1266
1267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1269pub enum AzureSandboxImage<'a> {
1270 Catalog(&'a str),
1272 Registry(&'a str),
1275}
1276
1277impl<'a> AzureSandboxImage<'a> {
1278 pub fn as_str(&self) -> &'a str {
1280 match self {
1281 Self::Catalog(value) | Self::Registry(value) => value,
1282 }
1283 }
1284}
1285
1286pub const AZURE_DISK_IMAGE_LABEL: &str = "alienImage";
1288
1289pub fn classify_azure_sandbox_image(image: &str) -> Option<AzureSandboxImage<'_>> {
1293 let image = image.trim();
1294 if image.is_empty() {
1295 return None;
1296 }
1297 if image
1298 .chars()
1299 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1300 {
1301 return Some(AzureSandboxImage::Catalog(image));
1302 }
1303 (image.contains(|c| matches!(c, '/' | ':' | '@')) && OCI_REFERENCE.is_match(image))
1304 .then_some(AzureSandboxImage::Registry(image))
1305}
1306
1307pub fn azure_disk_image_label(reference: &str) -> String {
1310 let digest = format!("{:x}", Sha256::digest(reference.trim().as_bytes()));
1311 digest[..32].to_string()
1312}
1313
1314static OCI_REFERENCE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1316 let domain_component = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
1317 let domain = format!(r"{domain_component}(?:\.{domain_component})*(?::[0-9]+)?");
1318 let path_component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
1319 let name = format!(r"(?:{domain}/)?{path_component}(?:/{path_component})*");
1320 let tag = r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}";
1321 let digest = r"[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,}";
1322 regex::Regex::new(&format!(r"^{name}(?::{tag})?(?:@{digest})?$"))
1323 .expect("the OCI reference grammar compiles")
1324});
1325
1326pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1331
1332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1334pub enum BundleUri<'a> {
1335 Literal(&'a str),
1337 Regional { before: &'a str, after: &'a str },
1340}
1341
1342pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1346 let (above_file, _) = key.rsplit_once('/')?;
1347 let (above_version, _) = above_file.rsplit_once('/')?;
1348 Some(above_version)
1349}
1350
1351pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1357 let path = uri
1358 .strip_prefix("s3://")
1359 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1360 let (bucket, key) = path
1361 .split_once('/')
1362 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1363
1364 if path.contains('*') || path.contains('?') {
1368 return Err(format!(
1369 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1370 role's grant, so '*' and '?' would widen it past the bundle"
1371 ));
1372 }
1373
1374 if key.contains('{') || key.contains('}') {
1375 return Err(format!(
1376 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1377 bucket name alone"
1378 ));
1379 }
1380
1381 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1382 if bucket.contains('{') || bucket.contains('}') {
1383 return Err(format!(
1384 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1385 only one"
1386 ));
1387 }
1388 return Ok(BundleUri::Literal(uri));
1389 };
1390
1391 if after.contains(BUNDLE_REGION_TOKEN) {
1392 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1393 }
1394 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1395 return Err(format!(
1396 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1397 ));
1398 }
1399
1400 Ok(BundleUri::Regional {
1401 before: &uri[.."s3://".len() + before.len()],
1402 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1403 })
1404}
1405
1406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1408pub enum EcrImageRegion<'a> {
1409 Literal(&'a str),
1411 Deployment,
1413}
1414
1415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1417pub struct EcrImageRepository<'a> {
1418 pub account_id: &'a str,
1419 pub region: EcrImageRegion<'a>,
1420 pub repository: &'a str,
1422}
1423
1424impl EcrImageRepository<'_> {
1425 pub fn arn(&self, partition: &str, region: &str) -> String {
1428 let region = match self.region {
1429 EcrImageRegion::Literal(region) => region,
1430 EcrImageRegion::Deployment => region,
1431 };
1432 format!(
1433 "arn:{partition}:ecr:{region}:{}:repository/{}",
1434 self.account_id, self.repository
1435 )
1436 }
1437}
1438
1439pub fn parse_ecr_image_repository(
1445 image: &str,
1446) -> std::result::Result<EcrImageRepository<'_>, String> {
1447 let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1448 let (host, path) = image
1449 .split_once('/')
1450 .ok_or_else(|| refuse("names no repository"))?;
1451 let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1452 refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1453 })?;
1454 let region = rest
1456 .strip_suffix(".amazonaws.com.cn")
1457 .or_else(|| rest.strip_suffix(".amazonaws.com"))
1458 .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1459 if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1460 return Err(refuse("names no 12-digit account in its registry host"));
1461 }
1462 let region = if region == BUNDLE_REGION_TOKEN {
1463 EcrImageRegion::Deployment
1464 } else if !region.is_empty()
1465 && region
1466 .bytes()
1467 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1468 {
1469 EcrImageRegion::Literal(region)
1470 } else {
1471 return Err(refuse(&format!(
1472 "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1473 )));
1474 };
1475
1476 let repository = match path.split_once('@') {
1477 Some((repository, _digest)) => repository,
1478 None => match path.rsplit_once('/') {
1479 Some((parent, last)) => match last.split_once(':') {
1480 Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1481 None => path,
1482 },
1483 None => path.split_once(':').map_or(path, |(name, _tag)| name),
1484 },
1485 };
1486 let valid_segment = |segment: &str| {
1487 !segment.is_empty()
1488 && segment.bytes().all(|b| {
1489 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1490 })
1491 };
1492 if !repository.split('/').all(valid_segment) {
1493 return Err(refuse(
1494 "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1495 ));
1496 }
1497 Ok(EcrImageRepository {
1498 account_id,
1499 region,
1500 repository,
1501 })
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506 use super::*;
1507
1508 #[test]
1509 fn private_database_setup_accepts_the_default_network() {
1510 for lifecycle in [
1511 crate::ResourceLifecycle::Frozen,
1512 crate::ResourceLifecycle::Live,
1513 ] {
1514 let stack = crate::Stack::new("database".to_string())
1515 .add(
1516 crate::Postgres::new("metadata".to_string()).build(),
1517 lifecycle,
1518 )
1519 .build();
1520 assert!(!restricts_network_mode(&stack, false));
1521 assert!(!restricts_network_mode(&stack, true));
1522 }
1523 assert!(!restricts_network_mode(
1524 &crate::Stack::new("empty".to_string()).build(),
1525 false,
1526 ));
1527 }
1528
1529 fn repository_key(image: &str) -> String {
1532 match parse_ecr_image_repository(image) {
1533 Ok(parsed) => {
1534 let host = image.split_once('/').map_or(image, |(host, _)| host);
1535 format!("{host}/{}", parsed.repository)
1536 }
1537 Err(_) => image.to_string(),
1538 }
1539 }
1540
1541 #[test]
1542 fn a_private_base_image_names_one_repository() {
1543 let vectors: serde_json::Value = serde_json::from_str(include_str!(
1544 "../../tests/fixtures/ecr-image-repository-parity.json"
1545 ))
1546 .expect("the ECR repository vectors must be JSON");
1547 let field = |case: &serde_json::Value, name: &str| {
1548 case[name]
1549 .as_str()
1550 .unwrap_or_else(|| panic!("vector must carry {name}: {case}"))
1551 .to_string()
1552 };
1553
1554 for case in vectors["accepted"].as_array().expect("accepted vectors") {
1555 let image = field(case, "image");
1556 let region = field(case, "region");
1557 let region = if region == BUNDLE_REGION_TOKEN {
1558 EcrImageRegion::Deployment
1559 } else {
1560 EcrImageRegion::Literal(®ion)
1561 };
1562 assert_eq!(
1563 parse_ecr_image_repository(&image),
1564 Ok(EcrImageRepository {
1565 account_id: &field(case, "accountId"),
1566 region,
1567 repository: &field(case, "repository"),
1568 }),
1569 "{image}"
1570 );
1571 assert_eq!(
1572 repository_key(&image),
1573 field(case, "repositoryKey"),
1574 "{image}"
1575 );
1576 }
1577
1578 for case in vectors["refused"].as_array().expect("refused vectors") {
1579 let image = field(case, "image");
1580 assert!(
1581 parse_ecr_image_repository(&image).is_err(),
1582 "{image} must be refused"
1583 );
1584 assert_eq!(
1585 repository_key(&image),
1586 field(case, "repositoryKey"),
1587 "{image}"
1588 );
1589 }
1590 }
1591
1592 #[test]
1593 fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1594 let regional =
1595 parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1596 .expect("parses");
1597 let pinned =
1598 parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1599 .expect("parses");
1600 assert_eq!(
1601 regional.arn("aws-us-gov", "us-gov-west-1"),
1602 "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1603 );
1604 assert_eq!(
1605 pinned.arn("aws", "us-east-1"),
1606 "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1607 );
1608 }
1609
1610 #[test]
1613 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1614 for uri in [
1615 "s3://acme/team-*/v1/bundle.zip",
1616 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1617 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1618 ] {
1619 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1620 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1621 }
1622
1623 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1624 .expect("an ordinary key still parses");
1625 }
1626
1627 #[test]
1631 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1632 assert_eq!(
1633 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1634 Some("sandbox-bundle")
1635 );
1636 assert_eq!(
1637 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1638 Some("artifacts/team-a/sandbox"),
1639 "a deeper key narrows the prefix, it never widens to the first segment"
1640 );
1641
1642 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1645 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1646 }
1647
1648 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1649 Sandbox::new("agent-sbx".to_string())
1650 .code(SandboxCode::Image {
1651 image: "ubuntu".to_string(),
1652 })
1653 .limits(SandboxLimits {
1654 cpu: "1".to_string(),
1655 memory: "2Gi".to_string(),
1656 disk: "20Gi".to_string(),
1657 max_processes: None,
1658 })
1659 .egress(egress)
1660 .lifecycle(SandboxLifecyclePolicy {
1661 max_lifetime_seconds: None,
1662 idle_pause_seconds: None,
1663 })
1664 .preview_ports(preview_ports)
1665 .build()
1666 }
1667
1668 #[test]
1671 fn a_uri_without_a_token_is_carried_whole() {
1672 assert_eq!(
1673 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1674 Ok(BundleUri::Literal(
1675 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1676 ))
1677 );
1678 }
1679
1680 #[test]
1683 fn a_regional_uri_splits_either_side_of_the_token() {
1684 let BundleUri::Regional { before, after } =
1685 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1686 .expect("the token is accepted in the bucket")
1687 else {
1688 panic!("a bucket-position token must split");
1689 };
1690
1691 assert_eq!(before, "s3://acme-artifacts-");
1692 assert_eq!(after, "/agents/bundle.zip");
1693 assert_eq!(
1694 format!("{before}us-east-2{after}"),
1695 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1696 "the halves must rejoin to the URI the vendor meant"
1697 );
1698 }
1699
1700 #[test]
1703 fn a_token_this_build_cannot_resolve_is_refused() {
1704 for uri in [
1705 "s3://acme-artifacts-{regio}/bundle.zip",
1706 "s3://acme-artifacts/{region}/bundle.zip",
1707 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1708 "s3://acme-artifacts/bundle-{version}.zip",
1709 "s3://acme}-artifacts-{region}/bundle.zip",
1710 "s3://acme{-artifacts-{region}/bundle.zip",
1711 ] {
1712 assert!(
1713 parse_bundle_uri(uri).is_err(),
1714 "'{uri}' must be refused before it can reach an image build"
1715 );
1716 }
1717 }
1718
1719 #[test]
1720 fn resource_type_is_stable() {
1721 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1722 }
1723
1724 #[test]
1725 fn capability_sets_are_per_platform() {
1726 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1727 assert!(
1728 gcp.reconnect,
1729 "generation from the container boot id makes a sandbox reachable across processes"
1730 );
1731 assert!(!gcp.preview);
1732 assert!(gcp.enforced_limits);
1733
1734 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1735 assert!(azure.files, "every backend moves files");
1736 assert!(gcp.files);
1737 assert!(azure.domain_egress_rules);
1740 assert!(azure.egress_deny);
1741 assert!(azure.enforced_limits);
1744 assert!(azure.pause_resume);
1745 assert!(!azure.snapshot);
1749 assert!(!azure.preview);
1750
1751 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1752 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1753 assert!(aws.pause_resume);
1754
1755 let k8s =
1756 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1757 assert!(
1758 !k8s.preview,
1759 "the sandbox-scoped ingress gateway does not exist yet"
1760 );
1761 }
1762
1763 #[test]
1772 fn supervisor_isolation_is_per_platform() {
1773 let value = |platform| {
1774 SandboxCapabilities::for_platform(platform)
1775 .expect("supported")
1776 .supervisor_isolation
1777 };
1778
1779 assert!(
1780 value(Platform::Aws),
1781 "root agent setuids the command to 60000"
1782 );
1783 assert!(
1784 value(Platform::Local),
1785 "the supervisor is on the host, outside the container"
1786 );
1787 assert!(
1788 !value(Platform::Kubernetes),
1789 "a single pinned uid cannot be split"
1790 );
1791 assert!(!value(Platform::Azure), "no Alien process runs the command");
1792 assert!(
1793 !value(Platform::Gcp),
1794 "no separate supervisor identity runs the command"
1795 );
1796 }
1797
1798 #[test]
1802 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1803 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1804 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1805
1806 assert_eq!(
1807 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1808 "the older axis cannot tell them apart"
1809 );
1810 assert!(
1811 aws.supervisor_isolation,
1812 "AWS setuids the command off the supervisor"
1813 );
1814 assert!(
1815 !gcp.supervisor_isolation,
1816 "the command runs under no separate supervisor identity"
1817 );
1818 }
1819
1820 #[test]
1825 fn gcp_agent_platform_row_matches_measured_backend() {
1826 let row = SandboxCapabilities::gcp_agent_platform();
1827
1828 assert!(row.files, "agent file ops move over the sandbox envelope");
1829 assert!(
1830 row.reconnect,
1831 "generation is derived from the container boot id, so a sandbox is reachable across \
1832 processes"
1833 );
1834 assert!(
1835 !row.preview,
1836 "the only ingress is :execute; no port-scoped capability"
1837 );
1838 assert!(
1839 !row.pause_resume,
1840 ":resume can return a fresh container, so a pause keeps no state"
1841 );
1842 assert!(
1843 !row.snapshot,
1844 "the create path never sends a snapshot, so none is reachable through the trait"
1845 );
1846 assert!(
1847 !row.domain_egress_rules,
1848 "VPC and DNS peering is not a hostname allowlist"
1849 );
1850 assert!(
1851 row.egress_deny,
1852 "a declared deny blocks both egress and DNS"
1853 );
1854 assert!(
1855 row.enforced_limits,
1856 "ceilings are enforced, by terminating the sandbox on breach"
1857 );
1858 assert!(!row.process_limit, "no process-count ceiling is observed");
1859 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1860 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1861 assert!(
1862 !row.supervisor_isolation,
1863 "the command is not run under a separate supervisor identity"
1864 );
1865
1866 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1868 assert_eq!(
1869 live, row,
1870 "the Platform::Gcp arm is the Agent Platform capability row"
1871 );
1872 }
1873
1874 #[test]
1875 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1876 let error = SandboxCapabilities::for_platform(Platform::Machines)
1877 .expect_err("Machines has no sandbox backend");
1878 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1879 }
1880
1881 #[test]
1882 fn unsupported_capability_names_platform_and_capability() {
1883 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1884 let error = capabilities
1885 .require(SandboxCapability::Preview, Platform::Gcp)
1886 .expect_err("GCP has no preview");
1887
1888 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1889 let rendered = error.to_string();
1890 assert!(
1891 rendered.contains("preview"),
1892 "names the capability: {rendered}"
1893 );
1894 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1895 }
1896
1897 #[test]
1901 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1902 let sandbox = sandbox_with(
1903 SandboxEgress::AllowDomains {
1904 domains: vec!["example.com".to_string()],
1905 },
1906 vec![],
1907 );
1908
1909 for platform in [
1910 Platform::Aws,
1911 Platform::Gcp,
1912 Platform::Kubernetes,
1913 Platform::Local,
1914 ] {
1915 let error = sandbox
1916 .validate_for_platform(platform)
1917 .expect_err("only Azure expresses a hostname allowlist");
1918 assert_eq!(
1919 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1920 "on {platform:?}"
1921 );
1922 }
1923
1924 assert!(
1925 SandboxCapabilities::for_platform(Platform::Azure)
1926 .expect("supported")
1927 .domain_egress_rules,
1928 "Azure's egress policy matches on host pattern"
1929 );
1930 }
1931
1932 #[test]
1935 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1936 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
1937
1938 assert!(
1941 SandboxCapabilities::for_platform(Platform::Gcp)
1942 .expect("supported")
1943 .egress_deny
1944 );
1945
1946 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1947 sandbox
1948 .validate_for_platform(platform)
1949 .expect("deny is enforced here");
1950 }
1951
1952 let egress_only = Sandbox::new("sbx".to_string())
1954 .code(SandboxCode::Image {
1955 image: "alpine".to_string(),
1956 })
1957 .egress(SandboxEgress::None)
1958 .lifecycle(SandboxLifecyclePolicy {
1959 max_lifetime_seconds: None,
1960 idle_pause_seconds: None,
1961 })
1962 .build();
1963
1964 egress_only
1965 .validate_for_platform(Platform::Azure)
1966 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1967 }
1968
1969 #[test]
1973 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1974 let undeclared = Sandbox::new("sbx".to_string())
1975 .code(SandboxCode::Image {
1976 image: "alpine".to_string(),
1977 })
1978 .egress(SandboxEgress::None)
1979 .lifecycle(SandboxLifecyclePolicy {
1980 max_lifetime_seconds: None,
1981 idle_pause_seconds: None,
1982 })
1983 .build();
1984
1985 undeclared
1986 .validate_for_platform(Platform::Azure)
1987 .expect("a sandbox naming no ceilings takes the platform's own");
1988
1989 assert_eq!(undeclared.resolved_limits().cpu, "1");
1991 }
1992
1993 #[test]
1997 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1998 let sized = |cpu: &str, memory: &str, disk: &str| {
1999 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2000 let limits = sandbox
2001 .limits
2002 .as_mut()
2003 .expect("the fixture declares limits");
2004 limits.cpu = cpu.to_string();
2005 limits.memory = memory.to_string();
2006 limits.disk = disk.to_string();
2007 sandbox.validate_for_platform(Platform::Azure)
2008 };
2009
2010 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
2011 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
2012 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
2013
2014 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
2016 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
2017 assert!(off_step.to_string().contains("cpu"), "{off_step}");
2018
2019 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
2020 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
2021
2022 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
2025 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
2026 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
2027 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
2028
2029 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
2030 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
2031 }
2032
2033 #[test]
2034 fn preview_ports_require_the_preview_capability() {
2035 let sandbox = sandbox_with(SandboxEgress::None, vec![8080]);
2036
2037 sandbox
2038 .validate_for_platform(Platform::Aws)
2039 .expect("AWS mints a port-scoped JWE");
2040
2041 let error = sandbox
2042 .validate_for_platform(Platform::Kubernetes)
2043 .expect_err("Kubernetes preview is deferred");
2044 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2045 }
2046
2047 #[test]
2051 fn a_private_base_image_is_refused_off_aws() {
2052 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2053 sandbox.code = SandboxCode::Image {
2054 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
2055 };
2056 sandbox.private_base_image =
2057 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
2058
2059 sandbox
2060 .validate_for_platform(Platform::Aws)
2061 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
2062
2063 for platform in [
2064 Platform::Gcp,
2065 Platform::Azure,
2066 Platform::Kubernetes,
2067 Platform::Local,
2068 ] {
2069 let error = sandbox
2070 .validate_for_platform(platform)
2071 .expect_err("a backend that builds no image must refuse a base image for one");
2072 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2073 assert!(
2074 error.to_string().contains("privateBaseImage"),
2075 "the refusal must name the field the user declared: {error}"
2076 );
2077 }
2078 }
2079
2080 #[test]
2081 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
2082 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2083 sandbox
2084 .validate_for_platform(Platform::Gcp)
2085 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
2086 }
2087
2088 #[test]
2089 fn invalid_quantities_are_rejected_with_the_offending_field() {
2090 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2091 sandbox
2092 .limits
2093 .as_mut()
2094 .expect("the fixture declares limits")
2095 .memory = "2Gb".to_string();
2096
2097 let error = sandbox
2098 .validate_for_platform(Platform::Aws)
2099 .expect_err("Gb is not a valid suffix");
2100 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2101 assert!(error.to_string().contains("memory"));
2102
2103 sandbox
2104 .limits
2105 .as_mut()
2106 .expect("the fixture declares limits")
2107 .memory = "2Gi".to_string();
2108 sandbox
2109 .limits
2110 .as_mut()
2111 .expect("the fixture declares limits")
2112 .cpu = "0".to_string();
2113 let error = sandbox
2114 .validate_for_platform(Platform::Aws)
2115 .expect_err("zero cpu is not a ceiling");
2116 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2117 }
2118
2119 #[test]
2120 fn zero_max_processes_is_rejected() {
2121 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2122 sandbox
2123 .limits
2124 .as_mut()
2125 .expect("the fixture declares limits")
2126 .max_processes = Some(0);
2127
2128 let error = sandbox
2129 .validate_for_platform(Platform::Local)
2130 .expect_err("a sandbox must be able to run at least one process");
2131 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2132 assert!(error.to_string().contains("maxProcesses"));
2133 }
2134
2135 #[test]
2139 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
2140 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2141 sandbox
2142 .limits
2143 .as_mut()
2144 .expect("the fixture declares limits")
2145 .max_processes = Some(256);
2146
2147 sandbox
2148 .validate_for_platform(Platform::Local)
2149 .expect("Docker takes a pids limit");
2150
2151 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
2152 let error = sandbox
2153 .validate_for_platform(platform)
2154 .expect_err("a process ceiling nothing applies must be refused");
2155 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2156 }
2157 }
2158
2159 #[test]
2164 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
2165 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2166 let mut without_limits = with_limits.clone();
2167 without_limits.limits = None;
2168
2169 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2170 for seconds in [0, 28_801, 30_000, 100_000] {
2171 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2172 let error = sandbox
2173 .validate_for_platform(Platform::Aws)
2174 .expect_err("a lifetime outside what AWS runs is refused");
2175 assert_eq!(
2176 error.code, "SANDBOX_LIMIT_INVALID",
2177 "{seconds}s {declared} limits"
2178 );
2179
2180 sandbox
2182 .validate_for_platform(Platform::Kubernetes)
2183 .expect("the kubelet takes any activeDeadlineSeconds");
2184 }
2185
2186 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
2187 sandbox
2188 .validate_for_platform(Platform::Aws)
2189 .expect("the ceiling itself is allowed");
2190 }
2191 }
2192
2193 #[test]
2196 fn an_image_azure_cannot_pull_is_refused_while_planning() {
2197 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2198 sandbox.limits = None;
2201
2202 for image in ["ubuntu@sha256:abc", "", " ", "ubuntu latest", "ubuntu?x"] {
2204 sandbox.code = SandboxCode::Image {
2205 image: image.to_string(),
2206 };
2207 let error = sandbox
2208 .validate_for_platform(Platform::Azure)
2209 .expect_err("an image Azure has nowhere to put is refused");
2210 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
2211 }
2212
2213 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
2214 sandbox.code = SandboxCode::Image {
2215 image: image.to_string(),
2216 };
2217 sandbox
2218 .validate_for_platform(Platform::Azure)
2219 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
2220 }
2221
2222 for image in [
2223 "ubuntu:24.04",
2224 "ghcr.io/myorg/sandbox:latest",
2225 "docker.io/library/python:3.14-slim",
2226 "localhost:5000/team/agent@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d",
2227 ] {
2228 sandbox.code = SandboxCode::Image {
2229 image: image.to_string(),
2230 };
2231 sandbox
2232 .validate_for_platform(Platform::Azure)
2233 .unwrap_or_else(|error| panic!("'{image}' is a registry image: {error}"));
2234 }
2235
2236 sandbox.code = SandboxCode::Image {
2238 image: " ubuntu ".to_string(),
2239 };
2240 assert_eq!(
2241 sandbox
2242 .azure_image()
2243 .expect("a padded name is still a name"),
2244 AzureSandboxImage::Catalog("ubuntu")
2245 );
2246 }
2247
2248 #[test]
2252 fn every_value_the_catalog_allowlist_accepted_is_still_a_catalog_name() {
2253 const ALPHABET: &[u8] =
2254 b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
2255
2256 let assert_catalog = |value: &str| {
2257 assert_eq!(
2258 classify_azure_sandbox_image(value),
2259 Some(AzureSandboxImage::Catalog(value.trim())),
2260 "'{value}'"
2261 );
2262 };
2263
2264 for a in ALPHABET {
2265 assert_catalog(&String::from_utf8(vec![*a]).unwrap());
2266 for b in ALPHABET {
2267 assert_catalog(&String::from_utf8(vec![*a, *b]).unwrap());
2268 for c in ALPHABET {
2269 assert_catalog(&String::from_utf8(vec![*a, *b, *c]).unwrap());
2270 }
2271 }
2272 }
2273
2274 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2275 let mut next = || {
2276 state ^= state << 13;
2277 state ^= state >> 7;
2278 state ^= state << 17;
2279 state
2280 };
2281 for _ in 0..20_000 {
2282 let len = 4 + (next() % 60) as usize;
2283 let mut value: String = (0..len)
2284 .map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize] as char)
2285 .collect();
2286 if next() % 4 == 0 {
2287 value = format!(" {value}\t");
2288 }
2289 assert_catalog(&value);
2290 }
2291 }
2292
2293 #[test]
2296 fn the_disk_image_label_is_stable_and_label_safe() {
2297 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
2298 assert_eq!(
2299 label,
2300 azure_disk_image_label(" docker.io/library/python:3.14-slim ")
2301 );
2302 assert_ne!(
2303 label,
2304 azure_disk_image_label("docker.io/library/python:3.13-slim")
2305 );
2306 assert_eq!(label.len(), 32);
2307 assert!(label.chars().all(|c| c.is_ascii_hexdigit()), "{label}");
2308 }
2309
2310 #[test]
2314 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2315 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2316 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2317
2318 sandbox
2319 .validate_for_platform(Platform::Kubernetes)
2320 .expect("the kubelet enforces activeDeadlineSeconds");
2321 sandbox
2322 .validate_for_platform(Platform::Aws)
2323 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2324
2325 for platform in [Platform::Azure, Platform::Local] {
2326 let error = sandbox
2327 .validate_for_platform(platform)
2328 .expect_err("a deadline nothing applies must be refused");
2329 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2330 }
2331 }
2332
2333 #[test]
2337 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2338 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
2339 let tier = sandbox
2340 .microvm_tier()
2341 .expect("2Gi/1cpu/20Gi is satisfiable");
2342
2343 assert_eq!(
2344 tier.peak_memory_mib, 2048,
2345 "the peak is the declared ceiling"
2346 );
2347 assert_eq!(
2348 tier.baseline_memory_mib, 512,
2349 "which is a quarter of it as the baseline"
2350 );
2351 assert!(tier.max_disk_mib <= 20 * 1024);
2352 }
2353
2354 #[test]
2358 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2359 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2360 {
2361 let limits = sandbox
2362 .limits
2363 .as_mut()
2364 .expect("the fixture declares limits");
2365 limits.cpu = "1".to_string();
2366 limits.memory = "8Gi".to_string();
2367 }
2368
2369 let error = sandbox
2370 .microvm_tier()
2371 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2372 assert!(
2373 error.to_string().contains("4 vCPU"),
2374 "the refusal must say what the memory ceiling implies: {error}"
2375 );
2376
2377 sandbox
2378 .limits
2379 .as_mut()
2380 .expect("the fixture declares limits")
2381 .cpu = "4".to_string();
2382 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2383 assert_eq!(tier.peak_memory_mib, 8192);
2384 }
2385
2386 #[test]
2389 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2390 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2391 sandbox
2392 .limits
2393 .as_mut()
2394 .expect("the fixture declares limits")
2395 .memory = "1Gi".to_string();
2396
2397 let error = sandbox
2398 .validate_for_platform(Platform::Aws)
2399 .expect_err("no MicroVM size peaks at or below 1Gi");
2400 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2401 assert!(
2402 error.to_string().contains("2Gi"),
2403 "the refusal must say what the smallest holdable ceiling is: {error}"
2404 );
2405 }
2406
2407 #[test]
2410 fn source_code_is_refused_where_no_backend_builds_it() {
2411 let sandbox = Sandbox::new("agent".to_string())
2412 .code(SandboxCode::Source {
2413 src: "./sandbox".to_string(),
2414 toolchain: ToolchainConfig::Docker {
2415 dockerfile: None,
2416 build_args: None,
2417 target: None,
2418 },
2419 })
2420 .egress(SandboxEgress::None)
2421 .lifecycle(SandboxLifecyclePolicy {
2422 max_lifetime_seconds: None,
2423 idle_pause_seconds: None,
2424 })
2425 .build();
2426
2427 sandbox
2428 .validate_for_platform(Platform::Aws)
2429 .expect("an AWS sandbox base image is built by `alien build`");
2430 sandbox
2431 .validate_for_platform(Platform::Azure)
2432 .expect("an Azure sandbox base image is built by `alien build`");
2433 sandbox
2434 .validate_for_platform(Platform::Gcp)
2435 .expect("a GCP sandbox base image is built by `alien build`");
2436
2437 for platform in [Platform::Kubernetes, Platform::Local] {
2438 let error = sandbox
2439 .validate_for_platform(platform)
2440 .expect_err("no backend builds a sandbox image from source here");
2441 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2442 assert!(
2443 error.to_string().contains("code.image"),
2444 "the refusal must say what to write instead: {error}"
2445 );
2446 assert!(
2447 error.to_string().contains(&platform.to_string()),
2448 "the refusal must name the platform that cannot build it: {error}"
2449 );
2450 }
2451 }
2452
2453 #[test]
2456 fn every_accepted_unit_converts_rather_than_falling_back() {
2457 assert_eq!(quantity_mib("2Gi"), Some(2048));
2458 assert_eq!(quantity_mib("512Mi"), Some(512));
2459 assert_eq!(quantity_mib("4G"), Some(3814));
2460 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2461 assert_eq!(millicores("1"), Some(1000));
2462 assert_eq!(millicores("500m"), Some(500));
2463 }
2464
2465 #[test]
2466 fn privileged_supervisor_fixes_identity_and_requires_an_enforceable_backend() {
2467 let mut sandbox = sandbox_with(
2468 SandboxEgress::AllowDomains {
2469 domains: vec!["example.com".to_string()],
2470 },
2471 vec![],
2472 );
2473 sandbox.privileged_supervisor = Some(SandboxPrivilegedSupervisor { command_uid: 60001 });
2474 sandbox
2475 .validate_for_platform(Platform::Aws)
2476 .expect("AWS can enforce the policy in the agent");
2477 assert_eq!(sandbox.cloud_egress(), &SandboxEgress::Allow);
2478 assert_eq!(
2479 sandbox.supervisor_environment()["ALIEN_SANDBOX_EXPECTED_EXEC_UID"],
2480 "60001"
2481 );
2482 for platform in [
2483 Platform::Local,
2484 Platform::Kubernetes,
2485 Platform::Azure,
2486 Platform::Gcp,
2487 ] {
2488 let error = sandbox
2489 .validate_for_platform(platform)
2490 .expect_err("cannot grant a privilege boundary the backend lacks");
2491 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2492 assert!(error.message.contains("privilegedSupervisor"));
2493 }
2494 for uid in [0, u32::MAX] {
2495 sandbox.privileged_supervisor.as_mut().unwrap().command_uid = uid;
2496 let error = sandbox
2497 .validate_for_platform(Platform::Aws)
2498 .expect_err("invalid uid must fail at plan time");
2499 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2500 }
2501 }
2502
2503 #[test]
2504 fn unknown_fields_are_rejected() {
2505 let json = r#"{
2506 "id": "sbx",
2507 "code": {"type": "image", "image": "ubuntu:24.04"},
2508 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2509 "egress": {"mode": "deny"},
2510 "lifecycle": {},
2511 "unexpected": true
2512 }"#;
2513
2514 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2515 }
2516
2517 #[test]
2518 fn serialization_roundtrips() {
2519 let sandbox = sandbox_with(
2520 SandboxEgress::AllowDomains {
2521 domains: vec!["example.com".to_string()],
2522 },
2523 vec![8080, 9090],
2524 );
2525
2526 let json = serde_json::to_string(&sandbox).expect("serializes");
2527 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2528 assert_eq!(sandbox, restored);
2529 }
2530
2531 #[test]
2532 fn id_is_immutable_across_updates() {
2533 let original = sandbox_with(SandboxEgress::None, vec![]);
2534 let renamed = Sandbox::new("other".to_string())
2535 .code(SandboxCode::Image {
2536 image: "ubuntu".to_string(),
2537 })
2538 .limits(
2539 original
2540 .limits
2541 .clone()
2542 .expect("the fixture declares limits"),
2543 )
2544 .egress(SandboxEgress::None)
2545 .lifecycle(SandboxLifecyclePolicy {
2546 max_lifetime_seconds: None,
2547 idle_pause_seconds: None,
2548 })
2549 .build();
2550
2551 original
2552 .validate_update(&original.clone())
2553 .expect("an unchanged config is a valid update");
2554 original
2555 .validate_update(&renamed)
2556 .expect_err("renaming a sandbox is not an update");
2557 }
2558
2559 #[test]
2565 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2566 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2567 Sandbox::new("sbx".to_string())
2568 .code(SandboxCode::Image {
2569 image: "ubuntu".to_string(),
2570 })
2571 .egress(SandboxEgress::Allow)
2572 .lifecycle(lifecycle)
2573 .build()
2574 .validate_for_platform(Platform::Azure)
2575 };
2576
2577 with_policy(SandboxLifecyclePolicy {
2578 max_lifetime_seconds: None,
2579 idle_pause_seconds: Some(900),
2580 })
2581 .expect("Azure pauses a sandbox on idle");
2582
2583 let error = with_policy(SandboxLifecyclePolicy {
2584 max_lifetime_seconds: Some(3600),
2585 idle_pause_seconds: None,
2586 })
2587 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2588 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2589 assert!(
2590 error.message.contains("sandboxLifetime"),
2591 "names the capability: {}",
2592 error.message
2593 );
2594 }
2595
2596 #[test]
2602 fn an_allowlist_with_no_domains_is_refused() {
2603 let declared = |domains: Vec<String>| {
2604 Sandbox::new("sbx".to_string())
2605 .code(SandboxCode::Image {
2606 image: "ubuntu".to_string(),
2607 })
2608 .egress(SandboxEgress::AllowDomains { domains })
2609 .lifecycle(SandboxLifecyclePolicy {
2610 max_lifetime_seconds: None,
2611 idle_pause_seconds: None,
2612 })
2613 .build()
2614 .validate_for_platform(Platform::Azure)
2615 };
2616
2617 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2618 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2619
2620 declared(vec!["api.example.com".to_string()])
2621 .expect("a named domain is what an allowlist is for");
2622 }
2623
2624 #[test]
2627 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2628 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2629 assert_eq!(SandboxEgress::None.internet_access_switch(), Some(false));
2630 assert_eq!(
2631 SandboxEgress::AllowDomains {
2632 domains: vec!["api.example.com".to_string()]
2633 }
2634 .internet_access_switch(),
2635 None,
2636 "a host list has no boolean and must not be approximated"
2637 );
2638 }
2639
2640 #[test]
2644 fn stack_state_written_before_the_lifecycle_rename_still_reads() {
2645 let stored: crate::StackResourceState = serde_json::from_value(serde_json::json!({
2646 "type": "sandbox",
2647 "config": {
2648 "id": "sandbox",
2649 "code": {
2650 "type": "image",
2651 "image": "s3://example-bundles/analysis/v1/bundle.zip"
2652 },
2653 "type": "sandbox",
2654 "egress": { "mode": "allow" },
2655 "session": { "maxLifetimeSeconds": 28800, "idleSuspendSeconds": 600 }
2656 },
2657 "status": "running",
2658 "outputs": {
2659 "type": "sandbox",
2660 "identifier": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox",
2661 "parentName": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox"
2662 },
2663 "lifecycle": "frozen",
2664 "dependencies": [
2665 { "id": "management", "type": "remote-stack-management" },
2666 { "id": "access", "type": "resource-access" }
2667 ],
2668 "controllerPlatform": "aws"
2669 }))
2670 .expect("a stack state written before the rename must still deserialize");
2671
2672 let expected = Sandbox::new("sandbox".to_string())
2673 .code(SandboxCode::Image {
2674 image: "s3://example-bundles/analysis/v1/bundle.zip".to_string(),
2675 })
2676 .egress(SandboxEgress::Allow)
2677 .lifecycle(SandboxLifecyclePolicy {
2678 max_lifetime_seconds: Some(28_800),
2679 idle_pause_seconds: Some(600),
2680 })
2681 .build();
2682 assert_eq!(stored.config.downcast_ref::<Sandbox>(), Some(&expected));
2683
2684 let rewritten = serde_json::to_value(&stored.config).expect("the sandbox serializes");
2685 assert_eq!(
2686 rewritten["lifecycle"],
2687 serde_json::json!({ "maxLifetimeSeconds": 28800, "idlePauseSeconds": 600 }),
2688 "the next write stores the current names"
2689 );
2690 assert!(rewritten.get("session").is_none());
2691 }
2692}