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 KUBERNETES_MAX_LIFETIME_SECONDS: u32 = i32::MAX as u32;
97
98const AZURE_CPU_STEP_MILLICORES: i64 = 250;
101const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
102const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
103const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
104
105const MICROVM_TIERS: &[MicrovmTier] = &[
106 MicrovmTier {
107 baseline_memory_mib: 512,
108 peak_memory_mib: 2048,
109 peak_vcpu: 1,
110 max_disk_mib: 8192,
111 },
112 MicrovmTier {
113 baseline_memory_mib: 1024,
114 peak_memory_mib: 4096,
115 peak_vcpu: 2,
116 max_disk_mib: 8192,
117 },
118 MicrovmTier {
119 baseline_memory_mib: 2048,
120 peak_memory_mib: 8192,
121 peak_vcpu: 4,
122 max_disk_mib: 8192,
123 },
124 MicrovmTier {
125 baseline_memory_mib: 4096,
126 peak_memory_mib: 16384,
127 peak_vcpu: 8,
128 max_disk_mib: 16384,
129 },
130 MicrovmTier {
131 baseline_memory_mib: 8192,
132 peak_memory_mib: 32768,
133 peak_vcpu: 16,
134 max_disk_mib: 32768,
135 },
136];
137
138pub use super::sandbox_policy::{is_public_ipv4, EgressPolicy};
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum SandboxEgress {
143 None,
144 Internet,
145 Allow,
147 AllowDomains {
149 domains: Vec<String>,
150 },
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
157pub enum LegacySandboxEgress {
158 Deny,
160 Allow,
163 AllowDomains { domains: Vec<String> },
166}
167
168#[derive(Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170enum SandboxEgressMode {
171 None,
172 Internet,
173}
174
175#[derive(Serialize, Deserialize)]
176#[serde(untagged)]
177enum SandboxEgressInput {
178 Mode(SandboxEgressMode),
179 Legacy(LegacySandboxEgress),
180}
181
182impl Serialize for SandboxEgress {
183 fn serialize<S: serde::Serializer>(
184 &self,
185 serializer: S,
186 ) -> std::result::Result<S::Ok, S::Error> {
187 match self {
188 Self::None => serializer.serialize_str("none"),
189 Self::Internet => serializer.serialize_str("internet"),
190 Self::Allow => LegacySandboxEgress::Allow.serialize(serializer),
191 Self::AllowDomains { domains } => LegacySandboxEgress::AllowDomains {
192 domains: domains.clone(),
193 }
194 .serialize(serializer),
195 }
196 }
197}
198
199impl<'de> Deserialize<'de> for SandboxEgress {
200 fn deserialize<D: serde::Deserializer<'de>>(
201 deserializer: D,
202 ) -> std::result::Result<Self, D::Error> {
203 Ok(match SandboxEgressInput::deserialize(deserializer)? {
204 SandboxEgressInput::Mode(SandboxEgressMode::None)
205 | SandboxEgressInput::Legacy(LegacySandboxEgress::Deny) => Self::None,
206 SandboxEgressInput::Mode(SandboxEgressMode::Internet) => Self::Internet,
207 SandboxEgressInput::Legacy(LegacySandboxEgress::Allow) => Self::Allow,
208 SandboxEgressInput::Legacy(LegacySandboxEgress::AllowDomains { domains }) => {
209 Self::AllowDomains { domains }
210 }
211 })
212 }
213}
214
215#[cfg(feature = "openapi")]
216impl utoipa::PartialSchema for SandboxEgress {
217 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
218 use utoipa::openapi::schema::{ObjectBuilder, OneOfBuilder, Type};
219 OneOfBuilder::new()
220 .item(
221 ObjectBuilder::new()
222 .schema_type(Type::String)
223 .enum_values(Some(["none", "internet"])),
224 )
225 .item(LegacySandboxEgress::schema())
226 .into()
227 }
228}
229#[cfg(feature = "openapi")]
230impl utoipa::ToSchema for SandboxEgress {}
231
232impl SandboxEgress {
233 #[allow(non_upper_case_globals)]
235 #[deprecated(note = "use SandboxEgress::None")]
236 pub const Deny: Self = Self::None;
237
238 pub fn internet_access_switch(&self) -> Option<bool> {
240 match self {
241 Self::None => Some(false),
242 Self::Internet | Self::Allow => Some(true),
243 Self::AllowDomains { .. } => None,
244 }
245 }
246}
247
248pub const SANDBOX_EXPIRY_LABEL: &str = "alienExpiresAtMs";
250
251pub const AZURE_GATEWAY_MAX_LIFETIME_SECONDS: u32 = 24 * 60 * 60;
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
260#[serde(rename_all = "camelCase", deny_unknown_fields)]
261pub struct SandboxLifecyclePolicy {
262 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub max_lifetime_seconds: Option<u32>,
270 #[serde(alias = "idleSuspendSeconds", skip_serializing_if = "Option::is_none")]
274 pub idle_pause_seconds: Option<u32>,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
284#[serde(rename_all = "camelCase", deny_unknown_fields)]
285pub struct SandboxCapabilities {
286 pub files: bool,
288 pub reconnect: bool,
290 pub jobs: bool,
293 pub preview: bool,
295 pub pause_resume: bool,
297 pub snapshot: bool,
299 pub domain_egress_rules: bool,
301 pub egress_deny: bool,
304 pub enforced_limits: bool,
306 pub process_limit: bool,
308 pub sandbox_lifetime: bool,
310 pub supervisor_pid_namespace: bool,
316 pub supervisor_isolation: bool,
322}
323
324impl SandboxCapabilities {
325 pub fn for_platform(platform: Platform) -> Result<Self> {
331 match platform {
332 Platform::Aws => Ok(Self {
333 files: true,
334 reconnect: true,
335 jobs: true,
336 preview: true,
337 pause_resume: true,
338 snapshot: false,
339 domain_egress_rules: false,
340 egress_deny: true,
343 enforced_limits: true,
344 process_limit: false,
346 sandbox_lifetime: true,
349 supervisor_pid_namespace: false,
354 supervisor_isolation: true,
357 }),
358 Platform::Azure => Ok(Self::azure()),
359 Platform::Gcp => Ok(Self::gcp_agent_platform()),
360 Platform::Kubernetes => Ok(Self {
363 files: true,
364 reconnect: true,
365 jobs: true,
366 preview: false,
367 pause_resume: false,
368 snapshot: false,
369 domain_egress_rules: false,
370 egress_deny: true,
371 enforced_limits: true,
372 process_limit: false,
374 sandbox_lifetime: true,
376 supervisor_pid_namespace: false,
380 supervisor_isolation: false,
385 }),
386 Platform::Local => Ok(Self {
387 files: true,
388 reconnect: true,
389 jobs: false,
391 preview: true,
392 pause_resume: false,
393 snapshot: false,
394 domain_egress_rules: false,
395 egress_deny: true,
396 enforced_limits: true,
397 process_limit: true,
399 sandbox_lifetime: false,
400 supervisor_pid_namespace: false,
403 supervisor_isolation: true,
407 }),
408 Platform::Machines | Platform::Test => {
409 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
410 platform: platform.to_string(),
411 }))
412 }
413 }
414 }
415
416 pub fn azure() -> Self {
418 Self {
419 files: true,
420 reconnect: true,
421 jobs: false,
423 preview: false,
428 pause_resume: true,
429 snapshot: false,
433 domain_egress_rules: true,
434 egress_deny: true,
435 enforced_limits: true,
439 process_limit: false,
440 sandbox_lifetime: false,
444 supervisor_pid_namespace: false,
446 supervisor_isolation: false,
449 }
450 }
451
452 pub fn gcp_agent_platform() -> Self {
454 Self {
455 files: true,
457 reconnect: true,
461 jobs: true,
462 preview: false,
464 pause_resume: false,
467 snapshot: false,
470 domain_egress_rules: false,
472 egress_deny: true,
474 enforced_limits: true,
478 process_limit: false,
480 sandbox_lifetime: true,
482 supervisor_pid_namespace: false,
484 supervisor_isolation: false,
487 }
488 }
489
490 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
492 let available = match capability {
493 SandboxCapability::Files => self.files,
494 SandboxCapability::Reconnect => self.reconnect,
495 SandboxCapability::Jobs => self.jobs,
496 SandboxCapability::Preview => self.preview,
497 SandboxCapability::PauseResume => self.pause_resume,
498 SandboxCapability::Snapshot => self.snapshot,
499 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
500 SandboxCapability::EgressDeny => self.egress_deny,
501 SandboxCapability::EnforcedLimits => self.enforced_limits,
502 SandboxCapability::ProcessLimit => self.process_limit,
503 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
504 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
505 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
506 };
507
508 if available {
509 return Ok(());
510 }
511
512 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
513 capability: capability.as_str().to_string(),
514 platform: platform.to_string(),
515 }))
516 }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
522#[serde(rename_all = "camelCase")]
523pub enum SandboxCapability {
524 Files,
526 Reconnect,
528 Jobs,
530 Preview,
532 PauseResume,
534 Snapshot,
536 DomainEgressRules,
538 EgressDeny,
540 EnforcedLimits,
542 ProcessLimit,
544 SandboxLifetime,
546 SupervisorPidNamespace,
548 SupervisorIsolation,
550}
551
552impl SandboxCapability {
553 pub fn as_str(&self) -> &'static str {
555 match self {
556 Self::Files => "files",
557 Self::Reconnect => "reconnect",
558 Self::Jobs => "jobs",
559 Self::Preview => "preview",
560 Self::PauseResume => "pauseResume",
561 Self::Snapshot => "snapshot",
562 Self::DomainEgressRules => "domainEgressRules",
563 Self::EgressDeny => "egressDeny",
564 Self::EnforcedLimits => "enforcedLimits",
565 Self::ProcessLimit => "processLimit",
566 Self::SandboxLifetime => "sandboxLifetime",
567 Self::SupervisorPidNamespace => "supervisorPidNamespace",
568 Self::SupervisorIsolation => "supervisorIsolation",
569 }
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
576#[serde(rename_all = "camelCase", deny_unknown_fields)]
577pub struct SandboxPrivilegedSupervisor {
578 pub command_uid: u32,
580}
581
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
584#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
585#[serde(rename_all = "camelCase", deny_unknown_fields)]
586#[builder(start_fn = new)]
587pub struct Sandbox {
588 #[builder(start_fn)]
591 pub id: String,
592 #[builder(field)]
595 #[serde(default, skip_serializing_if = "Vec::is_empty")]
596 #[cfg_attr(feature = "openapi", schema(ignore))]
597 pub links: Vec<ResourceRef>,
598 pub code: SandboxCode,
600 #[builder(skip)]
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 #[cfg_attr(feature = "openapi", schema(ignore))]
605 pub image_user: Option<u32>,
606 #[serde(skip_serializing_if = "Option::is_none")]
610 pub private_base_image: Option<String>,
611 #[serde(skip_serializing_if = "Option::is_none")]
617 pub limits: Option<SandboxLimits>,
618 pub egress: SandboxEgress,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub default_policy: Option<EgressPolicy>,
623 #[builder(skip)]
627 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub privileged_supervisor: Option<SandboxPrivilegedSupervisor>,
629 #[serde(alias = "session")]
634 pub lifecycle: SandboxLifecyclePolicy,
635 #[builder(default)]
639 #[serde(default, skip_serializing_if = "Vec::is_empty")]
640 pub preview_ports: Vec<u16>,
641}
642
643pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
648 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
649}
650
651pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
657 stack.resources().any(|(_, entry)| {
658 entry
659 .config
660 .downcast_ref::<Sandbox>()
661 .is_some_and(|sandbox| crate::sandbox_egress::sandbox_needs_connector(stack, sandbox))
662 })
663}
664
665impl Sandbox {
666 pub(crate) fn setup_digest_value(&self) -> serde_json::Value {
667 match &self.egress {
668 SandboxEgress::None => serde_json::json!({"mode":"deny"}),
669 value => serde_json::to_value(value).expect("egress serializes"),
670 }
671 }
672
673 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
675
676 pub fn id(&self) -> &str {
678 &self.id
679 }
680
681 pub fn cloud_egress(&self) -> &SandboxEgress {
684 if self.privileged_supervisor.is_some() && self.egress != SandboxEgress::Internet {
685 &SandboxEgress::Allow
686 } else {
687 &self.egress
688 }
689 }
690
691 pub fn supervisor_environment(&self) -> std::collections::BTreeMap<String, String> {
693 let mut env = std::collections::BTreeMap::new();
694 if let Some(supervisor) = &self.privileged_supervisor {
695 env.insert(
696 "ALIEN_SANDBOX_EXPECTED_EXEC_UID".to_string(),
697 supervisor.command_uid.to_string(),
698 );
699 env.insert(
700 "ALIEN_SANDBOX_EGRESS".to_string(),
701 if self.egress == SandboxEgress::None {
703 r#"{"mode":"deny"}"#.to_string()
704 } else {
705 serde_json::to_string(&self.egress).expect("egress serializes")
706 },
707 );
708 }
709 env
710 }
711
712 pub fn resolved_limits(&self) -> SandboxLimits {
718 self.limits.clone().unwrap_or_else(default_limits)
719 }
720
721 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
726 if let Some(policy) = &self.default_policy {
727 if self.egress != SandboxEgress::Internet {
728 return Err(AlienError::new(ErrorData::SandboxEgressInvalid {
729 target: "defaultPolicy".into(),
730 reason: "defaultPolicy requires the new internet declaration".into(),
731 }));
732 }
733 policy.validate()?;
734 }
735 if self.image_user == Some(0) && matches!(platform, Platform::Local | Platform::Kubernetes) {
736 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
737 capability: "root image USER: this backend does not yet separate bounded root boot from non-root exec; use a non-root image USER".into(),
738 platform: platform.to_string(),
739 }));
740 }
741 let mut capabilities = SandboxCapabilities::for_platform(platform)?;
742 if let Some(supervisor) = &self.privileged_supervisor {
743 if platform != Platform::Aws {
744 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
745 capability: "privilegedSupervisor".to_string(),
746 platform: platform.to_string(),
747 }));
748 }
749 if supervisor.command_uid == 0 || supervisor.command_uid == u32::MAX {
750 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
751 resource_id: self.id.clone(),
752 field: "privilegedSupervisor.commandUid".to_string(),
753 value: supervisor.command_uid.to_string(),
754 reason: "must be a non-root Linux uid other than the invalid uid sentinel"
755 .to_string(),
756 }));
757 }
758 if let SandboxEgress::AllowDomains { domains } = &self.egress {
759 for domain in domains {
760 let hostname = domain.strip_suffix('.').unwrap_or(domain);
761 if hostname.len() > 253
762 || !hostname.split('.').all(|label| {
763 !label.is_empty()
764 && label.len() <= 63
765 && label
766 .bytes()
767 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
768 && !label.starts_with('-')
769 && !label.ends_with('-')
770 })
771 {
772 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
773 resource_id: self.id.clone(), field: "egress.domains".to_string(), value: domain.clone(),
774 reason: "privileged supervisor allowlists require exact DNS hostnames; wildcards are unsupported".to_string(),
775 }));
776 }
777 }
778 }
779 capabilities.domain_egress_rules = true;
780 }
781
782 if matches!(&self.code, SandboxCode::Source { .. })
787 && !matches!(platform, Platform::Aws | Platform::Azure | Platform::Gcp)
788 {
789 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
790 resource_id: self.id.clone(),
791 field: "code".to_string(),
792 value: "source".to_string(),
793 reason: format!(
794 "no sandbox backend builds an image from source on {platform}; give \
795 code.image a prebuilt reference"
796 ),
797 }));
798 }
799
800 if self.private_base_image.is_some() && platform != Platform::Aws {
803 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
804 capability: "privateBaseImage".to_string(),
805 platform: platform.to_string(),
806 }));
807 }
808
809 if platform == Platform::Azure && matches!(&self.code, SandboxCode::Image { .. }) {
812 self.azure_image()?;
813 }
814
815 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
818 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
820
821 if seconds == 0 {
822 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
823 resource_id: self.id.clone(),
824 field: "maxLifetimeSeconds".to_string(),
825 value: "0".to_string(),
826 reason: "a sandbox must be allowed to run for at least 1 second".to_string(),
827 }));
828 }
829
830 let ceiling = match platform {
835 Platform::Aws => Some((AWS_MAX_LIFETIME_SECONDS, "AWS runs a MicroVM for")),
836 Platform::Kubernetes => {
837 Some((KUBERNETES_MAX_LIFETIME_SECONDS, "Kubernetes runs a pod for"))
838 }
839 Platform::Gcp => None,
842 Platform::Azure | Platform::Local | Platform::Machines | Platform::Test => None,
845 };
846 if let Some((max, runs_for)) = ceiling {
847 if seconds > max {
848 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
849 resource_id: self.id.clone(),
850 field: "maxLifetimeSeconds".to_string(),
851 value: seconds.to_string(),
852 reason: format!("{runs_for} between 1 and {max} seconds"),
853 }));
854 }
855 }
856 }
857
858 let Some(limits) = self.limits.as_ref() else {
859 return self.validate_capabilities(&capabilities, platform);
861 };
862
863 validate_quantity(&self.id, "cpu", &limits.cpu)?;
864 validate_quantity(&self.id, "memory", &limits.memory)?;
865 validate_quantity(&self.id, "disk", &limits.disk)?;
866
867 if let Some(max_processes) = limits.max_processes {
868 if max_processes == 0 {
869 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
870 resource_id: self.id.clone(),
871 field: "maxProcesses".to_string(),
872 value: "0".to_string(),
873 reason: "a sandbox that may run no processes cannot run code".to_string(),
874 }));
875 }
876 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
877 }
878
879 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
882
883 if platform == Platform::Azure {
884 self.azure_sandbox_limits()?;
885 }
886
887 if platform == Platform::Aws {
888 self.microvm_tier()?;
891 }
892
893 self.validate_capabilities(&capabilities, platform)
894 }
895
896 pub fn azure_image(&self) -> Result<AzureSandboxImage<'_>> {
900 let SandboxCode::Image { image } = &self.code else {
901 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
902 resource_id: self.id.clone(),
903 field: "code".to_string(),
904 value: "source".to_string(),
905 reason: "the sandbox still has unbuilt source code. Run 'alien build' first"
906 .to_string(),
907 }));
908 };
909
910 classify_azure_sandbox_image(image).ok_or_else(|| {
911 let image = image.trim();
912 AlienError::new(ErrorData::SandboxLimitInvalid {
913 resource_id: self.id.clone(),
914 field: "code.image".to_string(),
915 value: image.to_string(),
916 reason: if image.is_empty() {
917 "a sandbox has to name an image".to_string()
918 } else {
919 "Azure creates a sandbox from a catalog name such as 'ubuntu' or from a \
920 registry image such as 'docker.io/library/python:3.14-slim'"
921 .to_string()
922 },
923 })
924 })
925 }
926
927 pub fn azure_sandbox_limits(&self) -> Result<()> {
931 let Some(limits) = self.limits.as_ref() else {
932 return Ok(());
935 };
936
937 let refused = |field: &str, value: &str, reason: &str| {
938 AlienError::new(ErrorData::SandboxLimitInvalid {
939 resource_id: self.id.clone(),
940 field: field.to_string(),
941 value: value.to_string(),
942 reason: reason.to_string(),
943 })
944 };
945
946 let cpu_millicores = millicores(&limits.cpu)
947 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
948
949 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
953 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
954 {
955 return Err(refused(
956 "cpu",
957 &limits.cpu,
958 "Azure allocates cpu in steps of 250m from 250m to 16000m",
959 ));
960 }
961
962 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
964 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
965
966 let memory_mib = quantity_mib(&limits.memory)
967 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
968 if memory_mib > memory_ceiling_mib {
969 return Err(refused(
970 "memory",
971 &limits.memory,
972 &format!(
973 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
974 at the declared cpu"
975 ),
976 ));
977 }
978
979 let disk_mib = quantity_mib(&limits.disk)
980 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
981 if disk_mib > disk_ceiling_mib {
982 return Err(refused(
983 "disk",
984 &limits.disk,
985 &format!(
986 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
987 the declared cpu"
988 ),
989 ));
990 }
991
992 Ok(())
993 }
994
995 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
1002 let Some(limits) = self.limits.as_ref() else {
1003 return Ok(MICROVM_TIERS[2]);
1005 };
1006
1007 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
1008 AlienError::new(ErrorData::SandboxLimitInvalid {
1009 resource_id: self.id.clone(),
1010 field: "memory".to_string(),
1011 value: limits.memory.clone(),
1012 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
1013 })
1014 })?;
1015 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
1016 AlienError::new(ErrorData::SandboxLimitInvalid {
1017 resource_id: self.id.clone(),
1018 field: "disk".to_string(),
1019 value: limits.disk.clone(),
1020 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
1021 })
1022 })?;
1023 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
1024 AlienError::new(ErrorData::SandboxLimitInvalid {
1025 resource_id: self.id.clone(),
1026 field: "cpu".to_string(),
1027 value: limits.cpu.clone(),
1028 reason: "expected cores or millicores".to_string(),
1029 })
1030 })?;
1031
1032 let sized = |tier: &&MicrovmTier| {
1037 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
1038 };
1039
1040 let tier = MICROVM_TIERS
1041 .iter()
1042 .rev()
1043 .find(sized)
1044 .copied()
1045 .ok_or_else(|| {
1046 AlienError::new(ErrorData::SandboxLimitInvalid {
1047 resource_id: self.id.clone(),
1048 field: "memory".to_string(),
1049 value: limits.memory.clone(),
1050 reason: format!(
1051 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
1052 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
1053 disk fit no size",
1054 limits.memory, limits.disk
1055 ),
1056 })
1057 })?;
1058
1059 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
1060 if cpu_millicores < required_millicores {
1061 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1062 resource_id: self.id.clone(),
1063 field: "cpu".to_string(),
1064 value: limits.cpu.clone(),
1065 reason: format!(
1066 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
1067 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
1068 limits.memory, tier.peak_vcpu, tier.peak_vcpu
1069 ),
1070 }));
1071 }
1072
1073 Ok(tier)
1074 }
1075
1076 fn validate_capabilities(
1078 &self,
1079 capabilities: &SandboxCapabilities,
1080 platform: Platform,
1081 ) -> Result<()> {
1082 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
1083 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
1084 }
1085
1086 if let SandboxEgress::AllowDomains { domains } = &self.egress {
1092 if domains.is_empty() {
1093 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1094 resource_id: self.id.clone(),
1095 field: "egress.domains".to_string(),
1096 value: "[]".to_string(),
1097 reason: "an allowlist naming no domain denies everything; declare \
1098 egress: deny if that is what was meant"
1099 .to_string(),
1100 }));
1101 }
1102 }
1103
1104 if matches!(self.egress, SandboxEgress::None) {
1105 capabilities.require(SandboxCapability::EgressDeny, platform)?;
1106 }
1107
1108 if !self.preview_ports.is_empty() {
1109 capabilities.require(SandboxCapability::Preview, platform)?;
1110 }
1111
1112 if self.lifecycle.idle_pause_seconds.is_some() {
1113 capabilities.require(SandboxCapability::PauseResume, platform)?;
1114 }
1115
1116 if self.lifecycle.max_lifetime_seconds.is_some() {
1117 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
1118 }
1119
1120 Ok(())
1121 }
1122}
1123
1124fn default_limits() -> SandboxLimits {
1129 SandboxLimits {
1130 cpu: "1".to_string(),
1131 memory: "2Gi".to_string(),
1132 disk: "8Gi".to_string(),
1133 max_processes: None,
1134 }
1135}
1136
1137fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
1139 let invalid = |reason: &str| {
1140 AlienError::new(ErrorData::SandboxLimitInvalid {
1141 resource_id: resource_id.to_string(),
1142 field: field.to_string(),
1143 value: value.to_string(),
1144 reason: reason.to_string(),
1145 })
1146 };
1147
1148 let digits_end = value
1149 .find(|c: char| !c.is_ascii_digit() && c != '.')
1150 .unwrap_or(value.len());
1151 let (number, suffix) = value.split_at(digits_end);
1152
1153 let parsed: f64 = number
1154 .parse()
1155 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
1156
1157 if parsed <= 0.0 {
1158 return Err(invalid("must be greater than zero"));
1159 }
1160
1161 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
1162 if !SUFFIXES.contains(&suffix) {
1163 return Err(invalid(
1164 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
1165 ));
1166 }
1167
1168 Ok(())
1169}
1170
1171fn split_quantity(value: &str) -> Option<(f64, &str)> {
1173 let trimmed = value.trim();
1174 let digits_end = trimmed
1175 .find(|c: char| !c.is_ascii_digit() && c != '.')
1176 .unwrap_or(trimmed.len());
1177 let (number, suffix) = trimmed.split_at(digits_end);
1178 number.parse().ok().map(|number| (number, suffix))
1179}
1180
1181pub fn quantity_mib(value: &str) -> Option<i64> {
1187 let (number, suffix) = split_quantity(value)?;
1188 let bytes = match suffix {
1189 "" => number,
1190 "k" => number * 1e3,
1191 "M" => number * 1e6,
1192 "G" => number * 1e9,
1193 "T" => number * 1e12,
1194 "Ki" => number * 1024.0,
1195 "Mi" => number * 1024.0 * 1024.0,
1196 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
1197 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
1198 _ => return None,
1200 };
1201 Some((bytes / (1024.0 * 1024.0)) as i64)
1202}
1203
1204pub fn millicores(value: &str) -> Option<i64> {
1206 let (number, suffix) = split_quantity(value)?;
1207 match suffix {
1208 "" => Some((number * 1000.0) as i64),
1209 "m" => Some(number as i64),
1210 _ => None,
1211 }
1212}
1213
1214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1216#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1217#[serde(rename_all = "camelCase")]
1218pub struct SandboxOutputs {
1219 pub parent_name: String,
1221 #[serde(skip_serializing_if = "Option::is_none")]
1223 pub identifier: Option<String>,
1224 #[serde(skip_serializing_if = "Option::is_none")]
1226 pub endpoint: Option<String>,
1227}
1228
1229impl ResourceOutputsDefinition for SandboxOutputs {
1230 fn get_resource_type(&self) -> ResourceType {
1231 Sandbox::RESOURCE_TYPE
1232 }
1233
1234 fn as_any(&self) -> &dyn Any {
1235 self
1236 }
1237
1238 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1239 Box::new(self.clone())
1240 }
1241
1242 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1243 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1244 }
1245
1246 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1247 serde_json::to_value(self)
1248 }
1249}
1250
1251impl ResourceDefinition for Sandbox {
1252 fn get_resource_type(&self) -> ResourceType {
1253 Self::RESOURCE_TYPE
1254 }
1255
1256 fn id(&self) -> &str {
1257 &self.id
1258 }
1259
1260 fn replace_after_failed_create_is_safe(&self) -> bool {
1261 false
1264 }
1265
1266 fn get_dependencies(&self) -> Vec<ResourceRef> {
1267 Vec::new()
1268 }
1269
1270 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1271 let new_sandbox = new_config
1272 .as_any()
1273 .downcast_ref::<Sandbox>()
1274 .ok_or_else(|| {
1275 AlienError::new(ErrorData::UnexpectedResourceType {
1276 resource_id: self.id.clone(),
1277 expected: Self::RESOURCE_TYPE,
1278 actual: new_config.get_resource_type(),
1279 })
1280 })?;
1281
1282 if self.id != new_sandbox.id {
1283 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1284 resource_id: self.id.clone(),
1285 reason: "the 'id' field is immutable".to_string(),
1286 }));
1287 }
1288
1289 Ok(())
1290 }
1291
1292 fn as_any(&self) -> &dyn Any {
1293 self
1294 }
1295
1296 fn as_any_mut(&mut self) -> &mut dyn Any {
1297 self
1298 }
1299
1300 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1301 Box::new(self.clone())
1302 }
1303
1304 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1305 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1306 }
1307
1308 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1309 serde_json::to_value(self)
1310 }
1311}
1312
1313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315pub enum AzureSandboxImage<'a> {
1316 Catalog(&'a str),
1318 Registry(&'a str),
1321}
1322
1323impl<'a> AzureSandboxImage<'a> {
1324 pub fn as_str(&self) -> &'a str {
1326 match self {
1327 Self::Catalog(value) | Self::Registry(value) => value,
1328 }
1329 }
1330}
1331
1332pub const AZURE_DISK_IMAGE_LABEL: &str = "alienImage";
1334
1335pub fn classify_azure_sandbox_image(image: &str) -> Option<AzureSandboxImage<'_>> {
1339 let image = image.trim();
1340 if image.is_empty() {
1341 return None;
1342 }
1343 if image
1344 .chars()
1345 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1346 {
1347 return Some(AzureSandboxImage::Catalog(image));
1348 }
1349 (image.contains(|c| matches!(c, '/' | ':' | '@')) && OCI_REFERENCE.is_match(image))
1350 .then_some(AzureSandboxImage::Registry(image))
1351}
1352
1353pub fn azure_disk_image_label(reference: &str) -> String {
1356 let digest = format!("{:x}", Sha256::digest(reference.trim().as_bytes()));
1357 digest[..32].to_string()
1358}
1359
1360static OCI_REFERENCE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1362 let domain_component = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
1363 let domain = format!(r"{domain_component}(?:\.{domain_component})*(?::[0-9]+)?");
1364 let path_component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
1365 let name = format!(r"(?:{domain}/)?{path_component}(?:/{path_component})*");
1366 let tag = r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}";
1367 let digest = r"[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,}";
1368 regex::Regex::new(&format!(r"^{name}(?::{tag})?(?:@{digest})?$"))
1369 .expect("the OCI reference grammar compiles")
1370});
1371
1372pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1377
1378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1380pub enum BundleUri<'a> {
1381 Literal(&'a str),
1383 Regional { before: &'a str, after: &'a str },
1386}
1387
1388pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1392 let (above_file, _) = key.rsplit_once('/')?;
1393 let (above_version, _) = above_file.rsplit_once('/')?;
1394 Some(above_version)
1395}
1396
1397pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1403 let path = uri
1404 .strip_prefix("s3://")
1405 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1406 let (bucket, key) = path
1407 .split_once('/')
1408 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1409
1410 if path.contains('*') || path.contains('?') {
1414 return Err(format!(
1415 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1416 role's grant, so '*' and '?' would widen it past the bundle"
1417 ));
1418 }
1419
1420 if key.contains('{') || key.contains('}') {
1421 return Err(format!(
1422 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1423 bucket name alone"
1424 ));
1425 }
1426
1427 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1428 if bucket.contains('{') || bucket.contains('}') {
1429 return Err(format!(
1430 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1431 only one"
1432 ));
1433 }
1434 return Ok(BundleUri::Literal(uri));
1435 };
1436
1437 if after.contains(BUNDLE_REGION_TOKEN) {
1438 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1439 }
1440 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1441 return Err(format!(
1442 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1443 ));
1444 }
1445
1446 Ok(BundleUri::Regional {
1447 before: &uri[.."s3://".len() + before.len()],
1448 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1449 })
1450}
1451
1452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454pub enum EcrImageRegion<'a> {
1455 Literal(&'a str),
1457 Deployment,
1459}
1460
1461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1463pub struct EcrImageRepository<'a> {
1464 pub account_id: &'a str,
1465 pub region: EcrImageRegion<'a>,
1466 pub repository: &'a str,
1468}
1469
1470impl EcrImageRepository<'_> {
1471 pub fn arn(&self, partition: &str, region: &str) -> String {
1474 let region = match self.region {
1475 EcrImageRegion::Literal(region) => region,
1476 EcrImageRegion::Deployment => region,
1477 };
1478 format!(
1479 "arn:{partition}:ecr:{region}:{}:repository/{}",
1480 self.account_id, self.repository
1481 )
1482 }
1483}
1484
1485pub fn parse_ecr_image_repository(
1491 image: &str,
1492) -> std::result::Result<EcrImageRepository<'_>, String> {
1493 let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1494 let (host, path) = image
1495 .split_once('/')
1496 .ok_or_else(|| refuse("names no repository"))?;
1497 let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1498 refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1499 })?;
1500 let region = rest
1502 .strip_suffix(".amazonaws.com.cn")
1503 .or_else(|| rest.strip_suffix(".amazonaws.com"))
1504 .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1505 if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1506 return Err(refuse("names no 12-digit account in its registry host"));
1507 }
1508 let region = if region == BUNDLE_REGION_TOKEN {
1509 EcrImageRegion::Deployment
1510 } else if !region.is_empty()
1511 && region
1512 .bytes()
1513 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1514 {
1515 EcrImageRegion::Literal(region)
1516 } else {
1517 return Err(refuse(&format!(
1518 "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1519 )));
1520 };
1521
1522 let repository = match path.split_once('@') {
1523 Some((repository, _digest)) => repository,
1524 None => match path.rsplit_once('/') {
1525 Some((parent, last)) => match last.split_once(':') {
1526 Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1527 None => path,
1528 },
1529 None => path.split_once(':').map_or(path, |(name, _tag)| name),
1530 },
1531 };
1532 let valid_segment = |segment: &str| {
1533 !segment.is_empty()
1534 && segment.bytes().all(|b| {
1535 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1536 })
1537 };
1538 if !repository.split('/').all(valid_segment) {
1539 return Err(refuse(
1540 "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1541 ));
1542 }
1543 Ok(EcrImageRepository {
1544 account_id,
1545 region,
1546 repository,
1547 })
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::*;
1553
1554 #[test]
1555 fn private_database_setup_accepts_the_default_network() {
1556 for lifecycle in [
1557 crate::ResourceLifecycle::Frozen,
1558 crate::ResourceLifecycle::Live,
1559 ] {
1560 let stack = crate::Stack::new("database".to_string())
1561 .add(
1562 crate::Postgres::new("metadata".to_string()).build(),
1563 lifecycle,
1564 )
1565 .build();
1566 assert!(!restricts_network_mode(&stack, false));
1567 assert!(!restricts_network_mode(&stack, true));
1568 }
1569 assert!(!restricts_network_mode(
1570 &crate::Stack::new("empty".to_string()).build(),
1571 false,
1572 ));
1573 }
1574
1575 fn repository_key(image: &str) -> String {
1578 match parse_ecr_image_repository(image) {
1579 Ok(parsed) => {
1580 let host = image.split_once('/').map_or(image, |(host, _)| host);
1581 format!("{host}/{}", parsed.repository)
1582 }
1583 Err(_) => image.to_string(),
1584 }
1585 }
1586
1587 #[test]
1588 fn a_private_base_image_names_one_repository() {
1589 let vectors: serde_json::Value = serde_json::from_str(include_str!(
1590 "../../tests/fixtures/ecr-image-repository-parity.json"
1591 ))
1592 .expect("the ECR repository vectors must be JSON");
1593 let field = |case: &serde_json::Value, name: &str| {
1594 case[name]
1595 .as_str()
1596 .unwrap_or_else(|| panic!("vector must carry {name}: {case}"))
1597 .to_string()
1598 };
1599
1600 for case in vectors["accepted"].as_array().expect("accepted vectors") {
1601 let image = field(case, "image");
1602 let region = field(case, "region");
1603 let region = if region == BUNDLE_REGION_TOKEN {
1604 EcrImageRegion::Deployment
1605 } else {
1606 EcrImageRegion::Literal(®ion)
1607 };
1608 assert_eq!(
1609 parse_ecr_image_repository(&image),
1610 Ok(EcrImageRepository {
1611 account_id: &field(case, "accountId"),
1612 region,
1613 repository: &field(case, "repository"),
1614 }),
1615 "{image}"
1616 );
1617 assert_eq!(
1618 repository_key(&image),
1619 field(case, "repositoryKey"),
1620 "{image}"
1621 );
1622 }
1623
1624 for case in vectors["refused"].as_array().expect("refused vectors") {
1625 let image = field(case, "image");
1626 assert!(
1627 parse_ecr_image_repository(&image).is_err(),
1628 "{image} must be refused"
1629 );
1630 assert_eq!(
1631 repository_key(&image),
1632 field(case, "repositoryKey"),
1633 "{image}"
1634 );
1635 }
1636 }
1637
1638 #[test]
1639 fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1640 let regional =
1641 parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1642 .expect("parses");
1643 let pinned =
1644 parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1645 .expect("parses");
1646 assert_eq!(
1647 regional.arn("aws-us-gov", "us-gov-west-1"),
1648 "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1649 );
1650 assert_eq!(
1651 pinned.arn("aws", "us-east-1"),
1652 "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1653 );
1654 }
1655
1656 #[test]
1659 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1660 for uri in [
1661 "s3://acme/team-*/v1/bundle.zip",
1662 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1663 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1664 ] {
1665 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1666 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1667 }
1668
1669 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1670 .expect("an ordinary key still parses");
1671 }
1672
1673 #[test]
1677 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1678 assert_eq!(
1679 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1680 Some("sandbox-bundle")
1681 );
1682 assert_eq!(
1683 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1684 Some("artifacts/team-a/sandbox"),
1685 "a deeper key narrows the prefix, it never widens to the first segment"
1686 );
1687
1688 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1691 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1692 }
1693
1694 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1695 Sandbox::new("agent-sbx".to_string())
1696 .code(SandboxCode::Image {
1697 image: "ubuntu".to_string(),
1698 })
1699 .limits(SandboxLimits {
1700 cpu: "1".to_string(),
1701 memory: "2Gi".to_string(),
1702 disk: "20Gi".to_string(),
1703 max_processes: None,
1704 })
1705 .egress(egress)
1706 .lifecycle(SandboxLifecyclePolicy {
1707 max_lifetime_seconds: None,
1708 idle_pause_seconds: None,
1709 })
1710 .preview_ports(preview_ports)
1711 .build()
1712 }
1713
1714 #[test]
1717 fn a_uri_without_a_token_is_carried_whole() {
1718 assert_eq!(
1719 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1720 Ok(BundleUri::Literal(
1721 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1722 ))
1723 );
1724 }
1725
1726 #[test]
1729 fn a_regional_uri_splits_either_side_of_the_token() {
1730 let BundleUri::Regional { before, after } =
1731 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1732 .expect("the token is accepted in the bucket")
1733 else {
1734 panic!("a bucket-position token must split");
1735 };
1736
1737 assert_eq!(before, "s3://acme-artifacts-");
1738 assert_eq!(after, "/agents/bundle.zip");
1739 assert_eq!(
1740 format!("{before}us-east-2{after}"),
1741 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1742 "the halves must rejoin to the URI the vendor meant"
1743 );
1744 }
1745
1746 #[test]
1749 fn a_token_this_build_cannot_resolve_is_refused() {
1750 for uri in [
1751 "s3://acme-artifacts-{regio}/bundle.zip",
1752 "s3://acme-artifacts/{region}/bundle.zip",
1753 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1754 "s3://acme-artifacts/bundle-{version}.zip",
1755 "s3://acme}-artifacts-{region}/bundle.zip",
1756 "s3://acme{-artifacts-{region}/bundle.zip",
1757 ] {
1758 assert!(
1759 parse_bundle_uri(uri).is_err(),
1760 "'{uri}' must be refused before it can reach an image build"
1761 );
1762 }
1763 }
1764
1765 #[test]
1766 fn resource_type_is_stable() {
1767 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1768 }
1769
1770 #[test]
1771 fn capability_sets_are_per_platform() {
1772 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1773 assert!(
1774 gcp.reconnect,
1775 "generation from the container boot id makes a sandbox reachable across processes"
1776 );
1777 assert!(!gcp.preview);
1778 assert!(gcp.enforced_limits);
1779
1780 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1781 assert!(azure.files, "every backend moves files");
1782 assert!(gcp.files);
1783 assert!(azure.domain_egress_rules);
1786 assert!(azure.egress_deny);
1787 assert!(azure.enforced_limits);
1790 assert!(azure.pause_resume);
1791 assert!(!azure.snapshot);
1795 assert!(!azure.preview);
1796
1797 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1798 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1799 assert!(aws.pause_resume);
1800
1801 let k8s =
1802 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1803 assert!(
1804 !k8s.preview,
1805 "the sandbox-scoped ingress gateway does not exist yet"
1806 );
1807 }
1808
1809 #[test]
1818 fn supervisor_isolation_is_per_platform() {
1819 let value = |platform| {
1820 SandboxCapabilities::for_platform(platform)
1821 .expect("supported")
1822 .supervisor_isolation
1823 };
1824
1825 assert!(
1826 value(Platform::Aws),
1827 "root agent setuids the command to 60000"
1828 );
1829 assert!(
1830 value(Platform::Local),
1831 "the supervisor is on the host, outside the container"
1832 );
1833 assert!(
1834 !value(Platform::Kubernetes),
1835 "a single pinned uid cannot be split"
1836 );
1837 assert!(!value(Platform::Azure), "no Alien process runs the command");
1838 assert!(
1839 !value(Platform::Gcp),
1840 "no separate supervisor identity runs the command"
1841 );
1842 }
1843
1844 #[test]
1848 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1849 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1850 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1851
1852 assert_eq!(
1853 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1854 "the older axis cannot tell them apart"
1855 );
1856 assert!(
1857 aws.supervisor_isolation,
1858 "AWS setuids the command off the supervisor"
1859 );
1860 assert!(
1861 !gcp.supervisor_isolation,
1862 "the command runs under no separate supervisor identity"
1863 );
1864 }
1865
1866 #[test]
1871 fn gcp_agent_platform_row_matches_measured_backend() {
1872 let row = SandboxCapabilities::gcp_agent_platform();
1873
1874 assert!(row.files, "agent file ops move over the sandbox envelope");
1875 assert!(
1876 row.reconnect,
1877 "generation is derived from the container boot id, so a sandbox is reachable across \
1878 processes"
1879 );
1880 assert!(
1881 !row.preview,
1882 "the only ingress is :execute; no port-scoped capability"
1883 );
1884 assert!(
1885 !row.pause_resume,
1886 ":resume can return a fresh container, so a pause keeps no state"
1887 );
1888 assert!(
1889 !row.snapshot,
1890 "the create path never sends a snapshot, so none is reachable through the trait"
1891 );
1892 assert!(
1893 !row.domain_egress_rules,
1894 "VPC and DNS peering is not a hostname allowlist"
1895 );
1896 assert!(
1897 row.egress_deny,
1898 "a declared deny blocks both egress and DNS"
1899 );
1900 assert!(
1901 row.enforced_limits,
1902 "ceilings are enforced, by terminating the sandbox on breach"
1903 );
1904 assert!(!row.process_limit, "no process-count ceiling is observed");
1905 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1906 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1907 assert!(
1908 !row.supervisor_isolation,
1909 "the command is not run under a separate supervisor identity"
1910 );
1911
1912 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1914 assert_eq!(
1915 live, row,
1916 "the Platform::Gcp arm is the Agent Platform capability row"
1917 );
1918 }
1919
1920 #[test]
1921 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1922 let error = SandboxCapabilities::for_platform(Platform::Machines)
1923 .expect_err("Machines has no sandbox backend");
1924 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1925 }
1926
1927 #[test]
1928 fn unsupported_capability_names_platform_and_capability() {
1929 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1930 let error = capabilities
1931 .require(SandboxCapability::Preview, Platform::Gcp)
1932 .expect_err("GCP has no preview");
1933
1934 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1935 let rendered = error.to_string();
1936 assert!(
1937 rendered.contains("preview"),
1938 "names the capability: {rendered}"
1939 );
1940 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1941 }
1942
1943 #[test]
1947 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1948 let sandbox = sandbox_with(
1949 SandboxEgress::AllowDomains {
1950 domains: vec!["example.com".to_string()],
1951 },
1952 vec![],
1953 );
1954
1955 for platform in [
1956 Platform::Aws,
1957 Platform::Gcp,
1958 Platform::Kubernetes,
1959 Platform::Local,
1960 ] {
1961 let error = sandbox
1962 .validate_for_platform(platform)
1963 .expect_err("only Azure expresses a hostname allowlist");
1964 assert_eq!(
1965 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1966 "on {platform:?}"
1967 );
1968 }
1969
1970 assert!(
1971 SandboxCapabilities::for_platform(Platform::Azure)
1972 .expect("supported")
1973 .domain_egress_rules,
1974 "Azure's egress policy matches on host pattern"
1975 );
1976 }
1977
1978 #[test]
1981 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1982 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
1983
1984 assert!(
1987 SandboxCapabilities::for_platform(Platform::Gcp)
1988 .expect("supported")
1989 .egress_deny
1990 );
1991
1992 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1993 sandbox
1994 .validate_for_platform(platform)
1995 .expect("deny is enforced here");
1996 }
1997
1998 let egress_only = Sandbox::new("sbx".to_string())
2000 .code(SandboxCode::Image {
2001 image: "alpine".to_string(),
2002 })
2003 .egress(SandboxEgress::None)
2004 .lifecycle(SandboxLifecyclePolicy {
2005 max_lifetime_seconds: None,
2006 idle_pause_seconds: None,
2007 })
2008 .build();
2009
2010 egress_only
2011 .validate_for_platform(Platform::Azure)
2012 .expect("Azure creates the sandbox under a Deny policy with full inspection");
2013 }
2014
2015 #[test]
2019 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
2020 let undeclared = Sandbox::new("sbx".to_string())
2021 .code(SandboxCode::Image {
2022 image: "alpine".to_string(),
2023 })
2024 .egress(SandboxEgress::None)
2025 .lifecycle(SandboxLifecyclePolicy {
2026 max_lifetime_seconds: None,
2027 idle_pause_seconds: None,
2028 })
2029 .build();
2030
2031 undeclared
2032 .validate_for_platform(Platform::Azure)
2033 .expect("a sandbox naming no ceilings takes the platform's own");
2034
2035 assert_eq!(undeclared.resolved_limits().cpu, "1");
2037 }
2038
2039 #[test]
2043 fn azure_sizes_follow_the_rule_the_data_plane_states() {
2044 let sized = |cpu: &str, memory: &str, disk: &str| {
2045 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2046 let limits = sandbox
2047 .limits
2048 .as_mut()
2049 .expect("the fixture declares limits");
2050 limits.cpu = cpu.to_string();
2051 limits.memory = memory.to_string();
2052 limits.disk = disk.to_string();
2053 sandbox.validate_for_platform(Platform::Azure)
2054 };
2055
2056 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
2057 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
2058 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
2059
2060 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
2062 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
2063 assert!(off_step.to_string().contains("cpu"), "{off_step}");
2064
2065 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
2066 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
2067
2068 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
2071 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
2072 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
2073 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
2074
2075 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
2076 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
2077 }
2078
2079 #[test]
2080 fn preview_ports_require_the_preview_capability() {
2081 let sandbox = sandbox_with(SandboxEgress::None, vec![8080]);
2082
2083 sandbox
2084 .validate_for_platform(Platform::Aws)
2085 .expect("AWS mints a port-scoped JWE");
2086
2087 let error = sandbox
2088 .validate_for_platform(Platform::Kubernetes)
2089 .expect_err("Kubernetes preview is deferred");
2090 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2091 }
2092
2093 #[test]
2097 fn a_private_base_image_is_refused_off_aws() {
2098 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2099 sandbox.code = SandboxCode::Image {
2100 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
2101 };
2102 sandbox.private_base_image =
2103 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
2104
2105 sandbox
2106 .validate_for_platform(Platform::Aws)
2107 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
2108
2109 for platform in [
2110 Platform::Gcp,
2111 Platform::Azure,
2112 Platform::Kubernetes,
2113 Platform::Local,
2114 ] {
2115 let error = sandbox
2116 .validate_for_platform(platform)
2117 .expect_err("a backend that builds no image must refuse a base image for one");
2118 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2119 assert!(
2120 error.to_string().contains("privateBaseImage"),
2121 "the refusal must name the field the user declared: {error}"
2122 );
2123 }
2124 }
2125
2126 #[test]
2127 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
2128 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2129 sandbox
2130 .validate_for_platform(Platform::Gcp)
2131 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
2132 }
2133
2134 #[test]
2135 fn invalid_quantities_are_rejected_with_the_offending_field() {
2136 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2137 sandbox
2138 .limits
2139 .as_mut()
2140 .expect("the fixture declares limits")
2141 .memory = "2Gb".to_string();
2142
2143 let error = sandbox
2144 .validate_for_platform(Platform::Aws)
2145 .expect_err("Gb is not a valid suffix");
2146 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2147 assert!(error.to_string().contains("memory"));
2148
2149 sandbox
2150 .limits
2151 .as_mut()
2152 .expect("the fixture declares limits")
2153 .memory = "2Gi".to_string();
2154 sandbox
2155 .limits
2156 .as_mut()
2157 .expect("the fixture declares limits")
2158 .cpu = "0".to_string();
2159 let error = sandbox
2160 .validate_for_platform(Platform::Aws)
2161 .expect_err("zero cpu is not a ceiling");
2162 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2163 }
2164
2165 #[test]
2166 fn zero_max_processes_is_rejected() {
2167 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2168 sandbox
2169 .limits
2170 .as_mut()
2171 .expect("the fixture declares limits")
2172 .max_processes = Some(0);
2173
2174 let error = sandbox
2175 .validate_for_platform(Platform::Local)
2176 .expect_err("a sandbox must be able to run at least one process");
2177 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2178 assert!(error.to_string().contains("maxProcesses"));
2179 }
2180
2181 #[test]
2185 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
2186 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2187 sandbox
2188 .limits
2189 .as_mut()
2190 .expect("the fixture declares limits")
2191 .max_processes = Some(256);
2192
2193 sandbox
2194 .validate_for_platform(Platform::Local)
2195 .expect("Docker takes a pids limit");
2196
2197 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
2198 let error = sandbox
2199 .validate_for_platform(platform)
2200 .expect_err("a process ceiling nothing applies must be refused");
2201 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2202 }
2203 }
2204
2205 #[test]
2210 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
2211 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2212 let mut without_limits = with_limits.clone();
2213 without_limits.limits = None;
2214
2215 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2216 for seconds in [28_801, 30_000, 100_000] {
2217 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2218 let error = sandbox
2219 .validate_for_platform(Platform::Aws)
2220 .expect_err("a lifetime above what AWS runs is refused");
2221 assert_eq!(
2222 error.code, "SANDBOX_LIMIT_INVALID",
2223 "{seconds}s {declared} limits"
2224 );
2225
2226 sandbox
2228 .validate_for_platform(Platform::Kubernetes)
2229 .expect("Kubernetes takes an activeDeadlineSeconds above 28,800");
2230 }
2231
2232 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
2233 sandbox
2234 .validate_for_platform(Platform::Aws)
2235 .expect("the ceiling itself is allowed");
2236 }
2237 }
2238
2239 #[test]
2243 fn a_zero_lifetime_is_refused_while_planning() {
2244 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2245 let mut without_limits = with_limits.clone();
2246 without_limits.limits = None;
2247
2248 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2249 sandbox.lifecycle.max_lifetime_seconds = Some(0);
2250 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Gcp] {
2251 let error = sandbox
2252 .validate_for_platform(platform)
2253 .expect_err("a zero lifetime is refused");
2254 assert_eq!(
2255 error.code, "SANDBOX_LIMIT_INVALID",
2256 "{platform} {declared} limits"
2257 );
2258 assert!(
2259 error.to_string().contains("maxLifetimeSeconds"),
2260 "{platform} {declared} limits"
2261 );
2262 }
2263
2264 for platform in [Platform::Azure, Platform::Local] {
2266 let error = sandbox
2267 .validate_for_platform(platform)
2268 .expect_err("a lifetime is refused where nothing applies it");
2269 assert_eq!(
2270 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
2271 "{platform} {declared} limits"
2272 );
2273 }
2274
2275 sandbox.lifecycle.max_lifetime_seconds = Some(1);
2276 sandbox
2277 .validate_for_platform(Platform::Kubernetes)
2278 .expect("one second is the API server's floor");
2279 }
2280 }
2281
2282 #[test]
2286 fn a_lifetime_kubernetes_would_reject_is_refused_while_planning() {
2287 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2288 let mut without_limits = with_limits.clone();
2289 without_limits.limits = None;
2290
2291 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2292 for seconds in [2_147_483_648, u32::MAX] {
2293 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2294 let error = sandbox
2295 .validate_for_platform(Platform::Kubernetes)
2296 .expect_err("a lifetime above what the API server takes is refused");
2297 assert_eq!(
2298 error.code, "SANDBOX_LIMIT_INVALID",
2299 "{seconds}s {declared} limits"
2300 );
2301 assert!(
2302 error.to_string().contains("maxLifetimeSeconds"),
2303 "{seconds}s {declared} limits"
2304 );
2305 }
2306
2307 sandbox.lifecycle.max_lifetime_seconds = Some(2_147_483_647);
2308 sandbox
2309 .validate_for_platform(Platform::Kubernetes)
2310 .expect("the ceiling itself is allowed");
2311 }
2312 }
2313
2314 #[test]
2317 fn an_image_azure_cannot_pull_is_refused_while_planning() {
2318 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2319 sandbox.limits = None;
2322
2323 for image in ["ubuntu@sha256:abc", "", " ", "ubuntu latest", "ubuntu?x"] {
2325 sandbox.code = SandboxCode::Image {
2326 image: image.to_string(),
2327 };
2328 let error = sandbox
2329 .validate_for_platform(Platform::Azure)
2330 .expect_err("an image Azure has nowhere to put is refused");
2331 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
2332 }
2333
2334 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
2335 sandbox.code = SandboxCode::Image {
2336 image: image.to_string(),
2337 };
2338 sandbox
2339 .validate_for_platform(Platform::Azure)
2340 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
2341 }
2342
2343 for image in [
2344 "ubuntu:24.04",
2345 "ghcr.io/myorg/sandbox:latest",
2346 "docker.io/library/python:3.14-slim",
2347 "localhost:5000/team/agent@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d",
2348 ] {
2349 sandbox.code = SandboxCode::Image {
2350 image: image.to_string(),
2351 };
2352 sandbox
2353 .validate_for_platform(Platform::Azure)
2354 .unwrap_or_else(|error| panic!("'{image}' is a registry image: {error}"));
2355 }
2356
2357 sandbox.code = SandboxCode::Image {
2359 image: " ubuntu ".to_string(),
2360 };
2361 assert_eq!(
2362 sandbox
2363 .azure_image()
2364 .expect("a padded name is still a name"),
2365 AzureSandboxImage::Catalog("ubuntu")
2366 );
2367 }
2368
2369 #[test]
2373 fn every_value_the_catalog_allowlist_accepted_is_still_a_catalog_name() {
2374 const ALPHABET: &[u8] =
2375 b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
2376
2377 let assert_catalog = |value: &str| {
2378 assert_eq!(
2379 classify_azure_sandbox_image(value),
2380 Some(AzureSandboxImage::Catalog(value.trim())),
2381 "'{value}'"
2382 );
2383 };
2384
2385 for a in ALPHABET {
2386 assert_catalog(&String::from_utf8(vec![*a]).unwrap());
2387 for b in ALPHABET {
2388 assert_catalog(&String::from_utf8(vec![*a, *b]).unwrap());
2389 for c in ALPHABET {
2390 assert_catalog(&String::from_utf8(vec![*a, *b, *c]).unwrap());
2391 }
2392 }
2393 }
2394
2395 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2396 let mut next = || {
2397 state ^= state << 13;
2398 state ^= state >> 7;
2399 state ^= state << 17;
2400 state
2401 };
2402 for _ in 0..20_000 {
2403 let len = 4 + (next() % 60) as usize;
2404 let mut value: String = (0..len)
2405 .map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize] as char)
2406 .collect();
2407 if next() % 4 == 0 {
2408 value = format!(" {value}\t");
2409 }
2410 assert_catalog(&value);
2411 }
2412 }
2413
2414 #[test]
2417 fn the_disk_image_label_is_stable_and_label_safe() {
2418 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
2419 assert_eq!(
2420 label,
2421 azure_disk_image_label(" docker.io/library/python:3.14-slim ")
2422 );
2423 assert_ne!(
2424 label,
2425 azure_disk_image_label("docker.io/library/python:3.13-slim")
2426 );
2427 assert_eq!(label.len(), 32);
2428 assert!(label.chars().all(|c| c.is_ascii_hexdigit()), "{label}");
2429 }
2430
2431 #[test]
2435 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2436 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2437 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2438
2439 sandbox
2440 .validate_for_platform(Platform::Kubernetes)
2441 .expect("the kubelet enforces activeDeadlineSeconds");
2442 sandbox
2443 .validate_for_platform(Platform::Aws)
2444 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2445
2446 for platform in [Platform::Azure, Platform::Local] {
2447 let error = sandbox
2448 .validate_for_platform(platform)
2449 .expect_err("a deadline nothing applies must be refused");
2450 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2451 }
2452 }
2453
2454 #[test]
2458 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2459 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
2460 let tier = sandbox
2461 .microvm_tier()
2462 .expect("2Gi/1cpu/20Gi is satisfiable");
2463
2464 assert_eq!(
2465 tier.peak_memory_mib, 2048,
2466 "the peak is the declared ceiling"
2467 );
2468 assert_eq!(
2469 tier.baseline_memory_mib, 512,
2470 "which is a quarter of it as the baseline"
2471 );
2472 assert!(tier.max_disk_mib <= 20 * 1024);
2473 }
2474
2475 #[test]
2479 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2480 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2481 {
2482 let limits = sandbox
2483 .limits
2484 .as_mut()
2485 .expect("the fixture declares limits");
2486 limits.cpu = "1".to_string();
2487 limits.memory = "8Gi".to_string();
2488 }
2489
2490 let error = sandbox
2491 .microvm_tier()
2492 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2493 assert!(
2494 error.to_string().contains("4 vCPU"),
2495 "the refusal must say what the memory ceiling implies: {error}"
2496 );
2497
2498 sandbox
2499 .limits
2500 .as_mut()
2501 .expect("the fixture declares limits")
2502 .cpu = "4".to_string();
2503 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2504 assert_eq!(tier.peak_memory_mib, 8192);
2505 }
2506
2507 #[test]
2510 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2511 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2512 sandbox
2513 .limits
2514 .as_mut()
2515 .expect("the fixture declares limits")
2516 .memory = "1Gi".to_string();
2517
2518 let error = sandbox
2519 .validate_for_platform(Platform::Aws)
2520 .expect_err("no MicroVM size peaks at or below 1Gi");
2521 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2522 assert!(
2523 error.to_string().contains("2Gi"),
2524 "the refusal must say what the smallest holdable ceiling is: {error}"
2525 );
2526 }
2527
2528 #[test]
2531 fn source_code_is_refused_where_no_backend_builds_it() {
2532 let sandbox = Sandbox::new("agent".to_string())
2533 .code(SandboxCode::Source {
2534 src: "./sandbox".to_string(),
2535 toolchain: ToolchainConfig::Docker {
2536 dockerfile: None,
2537 build_args: None,
2538 target: None,
2539 },
2540 })
2541 .egress(SandboxEgress::None)
2542 .lifecycle(SandboxLifecyclePolicy {
2543 max_lifetime_seconds: None,
2544 idle_pause_seconds: None,
2545 })
2546 .build();
2547
2548 sandbox
2549 .validate_for_platform(Platform::Aws)
2550 .expect("an AWS sandbox base image is built by `alien build`");
2551 sandbox
2552 .validate_for_platform(Platform::Azure)
2553 .expect("an Azure sandbox base image is built by `alien build`");
2554 sandbox
2555 .validate_for_platform(Platform::Gcp)
2556 .expect("a GCP sandbox base image is built by `alien build`");
2557
2558 for platform in [Platform::Kubernetes, Platform::Local] {
2559 let error = sandbox
2560 .validate_for_platform(platform)
2561 .expect_err("no backend builds a sandbox image from source here");
2562 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2563 assert!(
2564 error.to_string().contains("code.image"),
2565 "the refusal must say what to write instead: {error}"
2566 );
2567 assert!(
2568 error.to_string().contains(&platform.to_string()),
2569 "the refusal must name the platform that cannot build it: {error}"
2570 );
2571 }
2572 }
2573
2574 #[test]
2577 fn every_accepted_unit_converts_rather_than_falling_back() {
2578 assert_eq!(quantity_mib("2Gi"), Some(2048));
2579 assert_eq!(quantity_mib("512Mi"), Some(512));
2580 assert_eq!(quantity_mib("4G"), Some(3814));
2581 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2582 assert_eq!(millicores("1"), Some(1000));
2583 assert_eq!(millicores("500m"), Some(500));
2584 }
2585
2586 #[test]
2587 fn privileged_supervisor_fixes_identity_and_requires_an_enforceable_backend() {
2588 let mut sandbox = sandbox_with(
2589 SandboxEgress::AllowDomains {
2590 domains: vec!["example.com".to_string()],
2591 },
2592 vec![],
2593 );
2594 sandbox.privileged_supervisor = Some(SandboxPrivilegedSupervisor { command_uid: 60001 });
2595 sandbox
2596 .validate_for_platform(Platform::Aws)
2597 .expect("AWS can enforce the policy in the agent");
2598 assert_eq!(sandbox.cloud_egress(), &SandboxEgress::Allow);
2599 assert_eq!(
2600 sandbox.supervisor_environment()["ALIEN_SANDBOX_EXPECTED_EXEC_UID"],
2601 "60001"
2602 );
2603 for platform in [
2604 Platform::Local,
2605 Platform::Kubernetes,
2606 Platform::Azure,
2607 Platform::Gcp,
2608 ] {
2609 let error = sandbox
2610 .validate_for_platform(platform)
2611 .expect_err("cannot grant a privilege boundary the backend lacks");
2612 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2613 assert!(error.message.contains("privilegedSupervisor"));
2614 }
2615 for uid in [0, u32::MAX] {
2616 sandbox.privileged_supervisor.as_mut().unwrap().command_uid = uid;
2617 let error = sandbox
2618 .validate_for_platform(Platform::Aws)
2619 .expect_err("invalid uid must fail at plan time");
2620 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2621 }
2622 }
2623
2624 #[test]
2625 fn unknown_fields_are_rejected() {
2626 let json = r#"{
2627 "id": "sbx",
2628 "code": {"type": "image", "image": "ubuntu:24.04"},
2629 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2630 "egress": {"mode": "deny"},
2631 "lifecycle": {},
2632 "unexpected": true
2633 }"#;
2634
2635 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2636 }
2637
2638 #[test]
2639 fn serialization_roundtrips() {
2640 let sandbox = sandbox_with(
2641 SandboxEgress::AllowDomains {
2642 domains: vec!["example.com".to_string()],
2643 },
2644 vec![8080, 9090],
2645 );
2646
2647 let json = serde_json::to_string(&sandbox).expect("serializes");
2648 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2649 assert_eq!(sandbox, restored);
2650 }
2651
2652 #[test]
2653 fn id_is_immutable_across_updates() {
2654 let original = sandbox_with(SandboxEgress::None, vec![]);
2655 let renamed = Sandbox::new("other".to_string())
2656 .code(SandboxCode::Image {
2657 image: "ubuntu".to_string(),
2658 })
2659 .limits(
2660 original
2661 .limits
2662 .clone()
2663 .expect("the fixture declares limits"),
2664 )
2665 .egress(SandboxEgress::None)
2666 .lifecycle(SandboxLifecyclePolicy {
2667 max_lifetime_seconds: None,
2668 idle_pause_seconds: None,
2669 })
2670 .build();
2671
2672 original
2673 .validate_update(&original.clone())
2674 .expect("an unchanged config is a valid update");
2675 original
2676 .validate_update(&renamed)
2677 .expect_err("renaming a sandbox is not an update");
2678 }
2679
2680 #[test]
2686 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2687 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2688 Sandbox::new("sbx".to_string())
2689 .code(SandboxCode::Image {
2690 image: "ubuntu".to_string(),
2691 })
2692 .egress(SandboxEgress::Allow)
2693 .lifecycle(lifecycle)
2694 .build()
2695 .validate_for_platform(Platform::Azure)
2696 };
2697
2698 with_policy(SandboxLifecyclePolicy {
2699 max_lifetime_seconds: None,
2700 idle_pause_seconds: Some(900),
2701 })
2702 .expect("Azure pauses a sandbox on idle");
2703
2704 let error = with_policy(SandboxLifecyclePolicy {
2705 max_lifetime_seconds: Some(3600),
2706 idle_pause_seconds: None,
2707 })
2708 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2709 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2710 assert!(
2711 error.message.contains("sandboxLifetime"),
2712 "names the capability: {}",
2713 error.message
2714 );
2715 }
2716
2717 #[test]
2723 fn an_allowlist_with_no_domains_is_refused() {
2724 let declared = |domains: Vec<String>| {
2725 Sandbox::new("sbx".to_string())
2726 .code(SandboxCode::Image {
2727 image: "ubuntu".to_string(),
2728 })
2729 .egress(SandboxEgress::AllowDomains { domains })
2730 .lifecycle(SandboxLifecyclePolicy {
2731 max_lifetime_seconds: None,
2732 idle_pause_seconds: None,
2733 })
2734 .build()
2735 .validate_for_platform(Platform::Azure)
2736 };
2737
2738 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2739 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2740
2741 declared(vec!["api.example.com".to_string()])
2742 .expect("a named domain is what an allowlist is for");
2743 }
2744
2745 #[test]
2748 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2749 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2750 assert_eq!(SandboxEgress::None.internet_access_switch(), Some(false));
2751 assert_eq!(
2752 SandboxEgress::AllowDomains {
2753 domains: vec!["api.example.com".to_string()]
2754 }
2755 .internet_access_switch(),
2756 None,
2757 "a host list has no boolean and must not be approximated"
2758 );
2759 }
2760
2761 #[test]
2765 fn stack_state_written_before_the_lifecycle_rename_still_reads() {
2766 let stored: crate::StackResourceState = serde_json::from_value(serde_json::json!({
2767 "type": "sandbox",
2768 "config": {
2769 "id": "sandbox",
2770 "code": {
2771 "type": "image",
2772 "image": "s3://example-bundles/analysis/v1/bundle.zip"
2773 },
2774 "type": "sandbox",
2775 "egress": { "mode": "allow" },
2776 "session": { "maxLifetimeSeconds": 28800, "idleSuspendSeconds": 600 }
2777 },
2778 "status": "running",
2779 "outputs": {
2780 "type": "sandbox",
2781 "identifier": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox",
2782 "parentName": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox"
2783 },
2784 "lifecycle": "frozen",
2785 "dependencies": [
2786 { "id": "management", "type": "remote-stack-management" },
2787 { "id": "access", "type": "resource-access" }
2788 ],
2789 "controllerPlatform": "aws"
2790 }))
2791 .expect("a stack state written before the rename must still deserialize");
2792
2793 let expected = Sandbox::new("sandbox".to_string())
2794 .code(SandboxCode::Image {
2795 image: "s3://example-bundles/analysis/v1/bundle.zip".to_string(),
2796 })
2797 .egress(SandboxEgress::Allow)
2798 .lifecycle(SandboxLifecyclePolicy {
2799 max_lifetime_seconds: Some(28_800),
2800 idle_pause_seconds: Some(600),
2801 })
2802 .build();
2803 assert_eq!(stored.config.downcast_ref::<Sandbox>(), Some(&expected));
2804
2805 let rewritten = serde_json::to_value(&stored.config).expect("the sandbox serializes");
2806 assert_eq!(
2807 rewritten["lifecycle"],
2808 serde_json::json!({ "maxLifetimeSeconds": 28800, "idlePauseSeconds": 600 }),
2809 "the next write stores the current names"
2810 );
2811 assert!(rewritten.get("session").is_none());
2812 }
2813}