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