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 interactive_commands: bool,
303 pub preview: bool,
305 pub pause_resume: bool,
307 pub snapshot: bool,
309 pub domain_egress_rules: bool,
311 pub egress_deny: bool,
314 pub enforced_limits: bool,
316 pub process_limit: bool,
318 pub sandbox_lifetime: bool,
320 pub supervisor_pid_namespace: bool,
326 pub supervisor_isolation: bool,
332}
333
334impl SandboxCapabilities {
335 pub fn for_platform(platform: Platform) -> Result<Self> {
341 match platform {
342 Platform::Aws => Ok(Self {
343 files: true,
344 reconnect: true,
345 jobs: true,
346 interactive_commands: true,
349 preview: true,
350 pause_resume: true,
351 snapshot: false,
352 domain_egress_rules: false,
353 egress_deny: true,
356 enforced_limits: true,
357 process_limit: false,
359 sandbox_lifetime: true,
362 supervisor_pid_namespace: false,
367 supervisor_isolation: true,
370 }),
371 Platform::Azure => Ok(Self::azure()),
372 Platform::Gcp => Ok(Self::gcp_agent_platform()),
373 Platform::Kubernetes => Ok(Self {
376 files: true,
377 reconnect: true,
378 jobs: true,
379 interactive_commands: false,
382 preview: false,
383 pause_resume: false,
384 snapshot: false,
385 domain_egress_rules: false,
386 egress_deny: true,
387 enforced_limits: true,
388 process_limit: false,
390 sandbox_lifetime: true,
392 supervisor_pid_namespace: false,
396 supervisor_isolation: false,
401 }),
402 Platform::Local => Ok(Self {
403 files: true,
404 reconnect: true,
405 jobs: false,
407 interactive_commands: true,
410 preview: true,
411 pause_resume: false,
412 snapshot: false,
413 domain_egress_rules: false,
414 egress_deny: true,
415 enforced_limits: true,
416 process_limit: true,
418 sandbox_lifetime: false,
419 supervisor_pid_namespace: false,
422 supervisor_isolation: true,
426 }),
427 Platform::Machines | Platform::Test => {
428 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
429 platform: platform.to_string(),
430 }))
431 }
432 }
433 }
434
435 pub fn azure() -> Self {
437 Self {
438 files: true,
439 reconnect: true,
440 jobs: false,
442 interactive_commands: false,
445 preview: false,
450 pause_resume: true,
451 snapshot: false,
455 domain_egress_rules: true,
456 egress_deny: true,
457 enforced_limits: true,
461 process_limit: false,
462 sandbox_lifetime: false,
466 supervisor_pid_namespace: false,
468 supervisor_isolation: false,
471 }
472 }
473
474 pub fn gcp_agent_platform() -> Self {
476 Self {
477 files: true,
479 reconnect: true,
483 jobs: true,
484 interactive_commands: false,
487 preview: false,
489 pause_resume: false,
492 snapshot: false,
495 domain_egress_rules: false,
497 egress_deny: true,
499 enforced_limits: true,
503 process_limit: false,
505 sandbox_lifetime: true,
507 supervisor_pid_namespace: false,
509 supervisor_isolation: false,
512 }
513 }
514
515 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
517 let available = match capability {
518 SandboxCapability::Files => self.files,
519 SandboxCapability::Reconnect => self.reconnect,
520 SandboxCapability::Jobs => self.jobs,
521 SandboxCapability::InteractiveCommands => self.interactive_commands,
522 SandboxCapability::Preview => self.preview,
523 SandboxCapability::PauseResume => self.pause_resume,
524 SandboxCapability::Snapshot => self.snapshot,
525 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
526 SandboxCapability::EgressDeny => self.egress_deny,
527 SandboxCapability::EnforcedLimits => self.enforced_limits,
528 SandboxCapability::ProcessLimit => self.process_limit,
529 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
530 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
531 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
532 };
533
534 if available {
535 return Ok(());
536 }
537
538 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
539 capability: capability.as_str().to_string(),
540 platform: platform.to_string(),
541 }))
542 }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
547#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
548#[serde(rename_all = "camelCase")]
549pub enum SandboxCapability {
550 Files,
552 Reconnect,
554 Jobs,
556 InteractiveCommands,
558 Preview,
560 PauseResume,
562 Snapshot,
564 DomainEgressRules,
566 EgressDeny,
568 EnforcedLimits,
570 ProcessLimit,
572 SandboxLifetime,
574 SupervisorPidNamespace,
576 SupervisorIsolation,
578}
579
580impl SandboxCapability {
581 pub fn as_str(&self) -> &'static str {
583 match self {
584 Self::Files => "files",
585 Self::Reconnect => "reconnect",
586 Self::Jobs => "jobs",
587 Self::InteractiveCommands => "interactiveCommands",
588 Self::Preview => "preview",
589 Self::PauseResume => "pauseResume",
590 Self::Snapshot => "snapshot",
591 Self::DomainEgressRules => "domainEgressRules",
592 Self::EgressDeny => "egressDeny",
593 Self::EnforcedLimits => "enforcedLimits",
594 Self::ProcessLimit => "processLimit",
595 Self::SandboxLifetime => "sandboxLifetime",
596 Self::SupervisorPidNamespace => "supervisorPidNamespace",
597 Self::SupervisorIsolation => "supervisorIsolation",
598 }
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
605#[serde(rename_all = "camelCase", deny_unknown_fields)]
606pub struct SandboxPrivilegedSupervisor {
607 pub command_uid: u32,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
613#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
614#[serde(rename_all = "camelCase", deny_unknown_fields)]
615#[builder(start_fn = new)]
616pub struct Sandbox {
617 #[builder(start_fn)]
620 pub id: String,
621 #[builder(field)]
624 #[serde(default, skip_serializing_if = "Vec::is_empty")]
625 #[cfg_attr(feature = "openapi", schema(ignore))]
626 pub links: Vec<ResourceRef>,
627 pub code: SandboxCode,
629 #[builder(skip)]
632 #[serde(default, skip_serializing_if = "Option::is_none")]
633 #[cfg_attr(feature = "openapi", schema(ignore))]
634 pub image_user: Option<u32>,
635 #[serde(skip_serializing_if = "Option::is_none")]
639 pub private_base_image: Option<String>,
640 #[serde(skip_serializing_if = "Option::is_none")]
646 pub limits: Option<SandboxLimits>,
647 pub egress: SandboxEgress,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub default_policy: Option<EgressPolicy>,
652 #[builder(skip)]
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub privileged_supervisor: Option<SandboxPrivilegedSupervisor>,
658 #[serde(alias = "session")]
663 pub lifecycle: SandboxLifecyclePolicy,
664 #[builder(default)]
668 #[serde(default, skip_serializing_if = "Vec::is_empty")]
669 pub preview_ports: Vec<u16>,
670}
671
672pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
677 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
678}
679
680pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
686 stack.resources().any(|(_, entry)| {
687 entry
688 .config
689 .downcast_ref::<Sandbox>()
690 .is_some_and(|sandbox| crate::sandbox_egress::sandbox_needs_connector(stack, sandbox))
691 })
692}
693
694impl Sandbox {
695 pub(crate) fn setup_digest_value(&self) -> serde_json::Value {
696 match &self.egress {
697 SandboxEgress::None => serde_json::json!({"mode":"deny"}),
698 value => serde_json::to_value(value).expect("egress serializes"),
699 }
700 }
701
702 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
704
705 pub fn id(&self) -> &str {
707 &self.id
708 }
709
710 pub fn cloud_egress(&self) -> &SandboxEgress {
713 if self.privileged_supervisor.is_some() && self.egress != SandboxEgress::Internet {
714 &SandboxEgress::Allow
715 } else {
716 &self.egress
717 }
718 }
719
720 pub fn supervisor_environment(&self) -> std::collections::BTreeMap<String, String> {
722 let mut env = std::collections::BTreeMap::new();
723 if let Some(supervisor) = &self.privileged_supervisor {
724 env.insert(
725 "ALIEN_SANDBOX_EXPECTED_EXEC_UID".to_string(),
726 supervisor.command_uid.to_string(),
727 );
728 env.insert(
729 "ALIEN_SANDBOX_EGRESS".to_string(),
730 if self.egress == SandboxEgress::None {
732 r#"{"mode":"deny"}"#.to_string()
733 } else {
734 serde_json::to_string(&self.egress).expect("egress serializes")
735 },
736 );
737 }
738 env
739 }
740
741 pub fn resolved_limits(&self) -> SandboxLimits {
747 self.limits.clone().unwrap_or_else(default_limits)
748 }
749
750 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
755 if let Some(policy) = &self.default_policy {
756 if self.egress != SandboxEgress::Internet {
757 return Err(AlienError::new(ErrorData::SandboxEgressInvalid {
758 target: "defaultPolicy".into(),
759 reason: "defaultPolicy requires the new internet declaration".into(),
760 }));
761 }
762 policy.validate()?;
763 }
764 if self.image_user == Some(0) && matches!(platform, Platform::Local | Platform::Kubernetes)
765 {
766 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
767 capability: "root image USER: this backend does not yet separate bounded root boot from non-root exec; use a non-root image USER".into(),
768 platform: platform.to_string(),
769 }));
770 }
771 let mut capabilities = SandboxCapabilities::for_platform(platform)?;
772 if let Some(supervisor) = &self.privileged_supervisor {
773 if platform != Platform::Aws {
774 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
775 capability: "privilegedSupervisor".to_string(),
776 platform: platform.to_string(),
777 }));
778 }
779 if supervisor.command_uid == 0 || supervisor.command_uid == u32::MAX {
780 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
781 resource_id: self.id.clone(),
782 field: "privilegedSupervisor.commandUid".to_string(),
783 value: supervisor.command_uid.to_string(),
784 reason: "must be a non-root Linux uid other than the invalid uid sentinel"
785 .to_string(),
786 }));
787 }
788 if let SandboxEgress::AllowDomains { domains } = &self.egress {
789 for domain in domains {
790 let hostname = domain.strip_suffix('.').unwrap_or(domain);
791 if hostname.len() > 253
792 || !hostname.split('.').all(|label| {
793 !label.is_empty()
794 && label.len() <= 63
795 && label
796 .bytes()
797 .all(|c| c.is_ascii_alphanumeric() || c == b'-')
798 && !label.starts_with('-')
799 && !label.ends_with('-')
800 })
801 {
802 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
803 resource_id: self.id.clone(), field: "egress.domains".to_string(), value: domain.clone(),
804 reason: "privileged supervisor allowlists require exact DNS hostnames; wildcards are unsupported".to_string(),
805 }));
806 }
807 }
808 }
809 capabilities.domain_egress_rules = true;
810 }
811
812 if matches!(&self.code, SandboxCode::Source { .. })
817 && !matches!(platform, Platform::Aws | Platform::Azure | Platform::Gcp)
818 {
819 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
820 resource_id: self.id.clone(),
821 field: "code".to_string(),
822 value: "source".to_string(),
823 reason: format!(
824 "no sandbox backend builds an image from source on {platform}; give \
825 code.image a prebuilt reference"
826 ),
827 }));
828 }
829
830 if self.private_base_image.is_some() && platform != Platform::Aws {
833 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
834 capability: "privateBaseImage".to_string(),
835 platform: platform.to_string(),
836 }));
837 }
838
839 if platform == Platform::Azure && matches!(&self.code, SandboxCode::Image { .. }) {
842 self.azure_image()?;
843 }
844
845 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
848 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
850
851 if seconds == 0 {
852 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
853 resource_id: self.id.clone(),
854 field: "maxLifetimeSeconds".to_string(),
855 value: "0".to_string(),
856 reason: "a sandbox must be allowed to run for at least 1 second".to_string(),
857 }));
858 }
859
860 let ceiling = match platform {
865 Platform::Aws => Some((AWS_MAX_LIFETIME_SECONDS, "AWS runs a MicroVM for")),
866 Platform::Kubernetes => {
867 Some((KUBERNETES_MAX_LIFETIME_SECONDS, "Kubernetes runs a pod for"))
868 }
869 Platform::Gcp => None,
872 Platform::Azure | Platform::Local | Platform::Machines | Platform::Test => None,
875 };
876 if let Some((max, runs_for)) = ceiling {
877 if seconds > max {
878 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
879 resource_id: self.id.clone(),
880 field: "maxLifetimeSeconds".to_string(),
881 value: seconds.to_string(),
882 reason: format!("{runs_for} between 1 and {max} seconds"),
883 }));
884 }
885 }
886 }
887
888 let Some(limits) = self.limits.as_ref() else {
889 return self.validate_capabilities(&capabilities, platform);
891 };
892
893 validate_quantity(&self.id, "cpu", &limits.cpu)?;
894 validate_quantity(&self.id, "memory", &limits.memory)?;
895 validate_quantity(&self.id, "disk", &limits.disk)?;
896
897 if let Some(max_processes) = limits.max_processes {
898 if max_processes == 0 {
899 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
900 resource_id: self.id.clone(),
901 field: "maxProcesses".to_string(),
902 value: "0".to_string(),
903 reason: "a sandbox that may run no processes cannot run code".to_string(),
904 }));
905 }
906 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
907 }
908
909 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
912
913 if platform == Platform::Azure {
914 self.azure_sandbox_limits()?;
915 }
916
917 if platform == Platform::Aws {
918 self.microvm_tier()?;
921 }
922
923 self.validate_capabilities(&capabilities, platform)
924 }
925
926 pub fn azure_image(&self) -> Result<AzureSandboxImage<'_>> {
930 let SandboxCode::Image { image, .. } = &self.code else {
931 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
932 resource_id: self.id.clone(),
933 field: "code".to_string(),
934 value: "source".to_string(),
935 reason: "the sandbox still has unbuilt source code. Run 'alien build' first"
936 .to_string(),
937 }));
938 };
939
940 classify_azure_sandbox_image(image).ok_or_else(|| {
941 let image = image.trim();
942 AlienError::new(ErrorData::SandboxLimitInvalid {
943 resource_id: self.id.clone(),
944 field: "code.image".to_string(),
945 value: image.to_string(),
946 reason: if image.is_empty() {
947 "a sandbox has to name an image".to_string()
948 } else {
949 "Azure creates a sandbox from a catalog name such as 'ubuntu' or from a \
950 registry image such as 'docker.io/library/python:3.14-slim'"
951 .to_string()
952 },
953 })
954 })
955 }
956
957 pub fn azure_sandbox_limits(&self) -> Result<()> {
961 let Some(limits) = self.limits.as_ref() else {
962 return Ok(());
965 };
966
967 let refused = |field: &str, value: &str, reason: &str| {
968 AlienError::new(ErrorData::SandboxLimitInvalid {
969 resource_id: self.id.clone(),
970 field: field.to_string(),
971 value: value.to_string(),
972 reason: reason.to_string(),
973 })
974 };
975
976 let cpu_millicores = millicores(&limits.cpu)
977 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
978
979 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
983 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
984 {
985 return Err(refused(
986 "cpu",
987 &limits.cpu,
988 "Azure allocates cpu in steps of 250m from 250m to 16000m",
989 ));
990 }
991
992 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
994 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
995
996 let memory_mib = quantity_mib(&limits.memory)
997 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
998 if memory_mib > memory_ceiling_mib {
999 return Err(refused(
1000 "memory",
1001 &limits.memory,
1002 &format!(
1003 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
1004 at the declared cpu"
1005 ),
1006 ));
1007 }
1008
1009 let disk_mib = quantity_mib(&limits.disk)
1010 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
1011 if disk_mib > disk_ceiling_mib {
1012 return Err(refused(
1013 "disk",
1014 &limits.disk,
1015 &format!(
1016 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
1017 the declared cpu"
1018 ),
1019 ));
1020 }
1021
1022 Ok(())
1023 }
1024
1025 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
1032 let Some(limits) = self.limits.as_ref() else {
1033 return Ok(MICROVM_TIERS[2]);
1037 };
1038
1039 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
1040 AlienError::new(ErrorData::SandboxLimitInvalid {
1041 resource_id: self.id.clone(),
1042 field: "memory".to_string(),
1043 value: limits.memory.clone(),
1044 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
1045 })
1046 })?;
1047 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
1048 AlienError::new(ErrorData::SandboxLimitInvalid {
1049 resource_id: self.id.clone(),
1050 field: "disk".to_string(),
1051 value: limits.disk.clone(),
1052 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
1053 })
1054 })?;
1055 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
1056 AlienError::new(ErrorData::SandboxLimitInvalid {
1057 resource_id: self.id.clone(),
1058 field: "cpu".to_string(),
1059 value: limits.cpu.clone(),
1060 reason: "expected cores or millicores".to_string(),
1061 })
1062 })?;
1063
1064 let sized = |tier: &&MicrovmTier| {
1069 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
1070 };
1071
1072 let tier = MICROVM_TIERS
1073 .iter()
1074 .rev()
1075 .find(sized)
1076 .copied()
1077 .ok_or_else(|| {
1078 AlienError::new(ErrorData::SandboxLimitInvalid {
1079 resource_id: self.id.clone(),
1080 field: "memory".to_string(),
1081 value: limits.memory.clone(),
1082 reason: format!(
1083 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
1084 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
1085 disk fit no size",
1086 limits.memory, limits.disk
1087 ),
1088 })
1089 })?;
1090
1091 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
1092 if cpu_millicores < required_millicores {
1093 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1094 resource_id: self.id.clone(),
1095 field: "cpu".to_string(),
1096 value: limits.cpu.clone(),
1097 reason: format!(
1098 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
1099 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
1100 limits.memory, tier.peak_vcpu, tier.peak_vcpu
1101 ),
1102 }));
1103 }
1104
1105 Ok(tier)
1106 }
1107
1108 fn validate_capabilities(
1110 &self,
1111 capabilities: &SandboxCapabilities,
1112 platform: Platform,
1113 ) -> Result<()> {
1114 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
1115 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
1116 }
1117
1118 if let SandboxEgress::AllowDomains { domains } = &self.egress {
1124 if domains.is_empty() {
1125 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1126 resource_id: self.id.clone(),
1127 field: "egress.domains".to_string(),
1128 value: "[]".to_string(),
1129 reason: "an allowlist naming no domain denies everything; declare \
1130 egress: deny if that is what was meant"
1131 .to_string(),
1132 }));
1133 }
1134 }
1135
1136 if matches!(self.egress, SandboxEgress::None) {
1137 capabilities.require(SandboxCapability::EgressDeny, platform)?;
1138 }
1139
1140 if !self.preview_ports.is_empty() {
1141 capabilities.require(SandboxCapability::Preview, platform)?;
1142 }
1143
1144 if self.lifecycle.idle_pause_seconds.is_some() {
1145 capabilities.require(SandboxCapability::PauseResume, platform)?;
1146 }
1147
1148 if self.lifecycle.max_lifetime_seconds.is_some() {
1149 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
1150 }
1151
1152 Ok(())
1153 }
1154}
1155
1156fn default_limits() -> SandboxLimits {
1161 SandboxLimits {
1162 cpu: "1".to_string(),
1163 memory: "2Gi".to_string(),
1164 disk: "8Gi".to_string(),
1165 max_processes: None,
1166 }
1167}
1168
1169fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
1171 let invalid = |reason: &str| {
1172 AlienError::new(ErrorData::SandboxLimitInvalid {
1173 resource_id: resource_id.to_string(),
1174 field: field.to_string(),
1175 value: value.to_string(),
1176 reason: reason.to_string(),
1177 })
1178 };
1179
1180 let digits_end = value
1181 .find(|c: char| !c.is_ascii_digit() && c != '.')
1182 .unwrap_or(value.len());
1183 let (number, suffix) = value.split_at(digits_end);
1184
1185 let parsed: f64 = number
1186 .parse()
1187 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
1188
1189 if parsed <= 0.0 {
1190 return Err(invalid("must be greater than zero"));
1191 }
1192
1193 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
1194 if !SUFFIXES.contains(&suffix) {
1195 return Err(invalid(
1196 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
1197 ));
1198 }
1199
1200 Ok(())
1201}
1202
1203fn split_quantity(value: &str) -> Option<(f64, &str)> {
1205 let trimmed = value.trim();
1206 let digits_end = trimmed
1207 .find(|c: char| !c.is_ascii_digit() && c != '.')
1208 .unwrap_or(trimmed.len());
1209 let (number, suffix) = trimmed.split_at(digits_end);
1210 number.parse().ok().map(|number| (number, suffix))
1211}
1212
1213pub fn quantity_mib(value: &str) -> Option<i64> {
1219 let (number, suffix) = split_quantity(value)?;
1220 let bytes = match suffix {
1221 "" => number,
1222 "k" => number * 1e3,
1223 "M" => number * 1e6,
1224 "G" => number * 1e9,
1225 "T" => number * 1e12,
1226 "Ki" => number * 1024.0,
1227 "Mi" => number * 1024.0 * 1024.0,
1228 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
1229 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
1230 _ => return None,
1232 };
1233 Some((bytes / (1024.0 * 1024.0)) as i64)
1234}
1235
1236pub fn millicores(value: &str) -> Option<i64> {
1238 let (number, suffix) = split_quantity(value)?;
1239 match suffix {
1240 "" => Some((number * 1000.0) as i64),
1241 "m" => Some(number as i64),
1242 _ => None,
1243 }
1244}
1245
1246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1248#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1249#[serde(rename_all = "camelCase")]
1250pub struct SandboxOutputs {
1251 pub parent_name: String,
1253 #[serde(skip_serializing_if = "Option::is_none")]
1255 pub identifier: Option<String>,
1256 #[serde(skip_serializing_if = "Option::is_none")]
1258 pub endpoint: Option<String>,
1259}
1260
1261impl ResourceOutputsDefinition for SandboxOutputs {
1262 fn get_resource_type(&self) -> ResourceType {
1263 Sandbox::RESOURCE_TYPE
1264 }
1265
1266 fn as_any(&self) -> &dyn Any {
1267 self
1268 }
1269
1270 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1271 Box::new(self.clone())
1272 }
1273
1274 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1275 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1276 }
1277
1278 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1279 serde_json::to_value(self)
1280 }
1281}
1282
1283impl ResourceDefinition for Sandbox {
1284 fn get_resource_type(&self) -> ResourceType {
1285 Self::RESOURCE_TYPE
1286 }
1287
1288 fn id(&self) -> &str {
1289 &self.id
1290 }
1291
1292 fn replace_after_failed_create_is_safe(&self) -> bool {
1293 false
1296 }
1297
1298 fn get_dependencies(&self) -> Vec<ResourceRef> {
1299 Vec::new()
1300 }
1301
1302 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1303 let new_sandbox = new_config
1304 .as_any()
1305 .downcast_ref::<Sandbox>()
1306 .ok_or_else(|| {
1307 AlienError::new(ErrorData::UnexpectedResourceType {
1308 resource_id: self.id.clone(),
1309 expected: Self::RESOURCE_TYPE,
1310 actual: new_config.get_resource_type(),
1311 })
1312 })?;
1313
1314 if self.id != new_sandbox.id {
1315 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1316 resource_id: self.id.clone(),
1317 reason: "the 'id' field is immutable".to_string(),
1318 }));
1319 }
1320
1321 Ok(())
1322 }
1323
1324 fn as_any(&self) -> &dyn Any {
1325 self
1326 }
1327
1328 fn as_any_mut(&mut self) -> &mut dyn Any {
1329 self
1330 }
1331
1332 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1333 Box::new(self.clone())
1334 }
1335
1336 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1337 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1338 }
1339
1340 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1341 serde_json::to_value(self)
1342 }
1343}
1344
1345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1347pub enum AzureSandboxImage<'a> {
1348 Catalog(&'a str),
1350 Registry(&'a str),
1353}
1354
1355impl<'a> AzureSandboxImage<'a> {
1356 pub fn as_str(&self) -> &'a str {
1358 match self {
1359 Self::Catalog(value) | Self::Registry(value) => value,
1360 }
1361 }
1362}
1363
1364pub const AZURE_DISK_IMAGE_LABEL: &str = "alienImage";
1366
1367pub fn classify_azure_sandbox_image(image: &str) -> Option<AzureSandboxImage<'_>> {
1371 let image = image.trim();
1372 if image.is_empty() {
1373 return None;
1374 }
1375 if image
1376 .chars()
1377 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1378 {
1379 return Some(AzureSandboxImage::Catalog(image));
1380 }
1381 (image.contains(|c| matches!(c, '/' | ':' | '@')) && OCI_REFERENCE.is_match(image))
1382 .then_some(AzureSandboxImage::Registry(image))
1383}
1384
1385pub fn azure_disk_image_label(reference: &str) -> String {
1388 let digest = format!("{:x}", Sha256::digest(reference.trim().as_bytes()));
1389 digest[..32].to_string()
1390}
1391
1392static OCI_REFERENCE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1394 let domain_component = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
1395 let domain = format!(r"{domain_component}(?:\.{domain_component})*(?::[0-9]+)?");
1396 let path_component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
1397 let name = format!(r"(?:{domain}/)?{path_component}(?:/{path_component})*");
1398 let tag = r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}";
1399 let digest = r"[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,}";
1400 regex::Regex::new(&format!(r"^{name}(?::{tag})?(?:@{digest})?$"))
1401 .expect("the OCI reference grammar compiles")
1402});
1403
1404pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1409
1410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1412pub enum BundleUri<'a> {
1413 Literal(&'a str),
1415 Regional { before: &'a str, after: &'a str },
1418}
1419
1420pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1424 let (above_file, _) = key.rsplit_once('/')?;
1425 let (above_version, _) = above_file.rsplit_once('/')?;
1426 Some(above_version)
1427}
1428
1429pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1435 let path = uri
1436 .strip_prefix("s3://")
1437 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1438 let (bucket, key) = path
1439 .split_once('/')
1440 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1441
1442 if path.contains('*') || path.contains('?') {
1446 return Err(format!(
1447 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1448 role's grant, so '*' and '?' would widen it past the bundle"
1449 ));
1450 }
1451
1452 if key.contains('{') || key.contains('}') {
1453 return Err(format!(
1454 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1455 bucket name alone"
1456 ));
1457 }
1458
1459 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1460 if bucket.contains('{') || bucket.contains('}') {
1461 return Err(format!(
1462 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1463 only one"
1464 ));
1465 }
1466 return Ok(BundleUri::Literal(uri));
1467 };
1468
1469 if after.contains(BUNDLE_REGION_TOKEN) {
1470 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1471 }
1472 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1473 return Err(format!(
1474 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1475 ));
1476 }
1477
1478 Ok(BundleUri::Regional {
1479 before: &uri[.."s3://".len() + before.len()],
1480 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1481 })
1482}
1483
1484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1486pub enum EcrImageRegion<'a> {
1487 Literal(&'a str),
1489 Deployment,
1491}
1492
1493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1495pub struct EcrImageRepository<'a> {
1496 pub account_id: &'a str,
1497 pub region: EcrImageRegion<'a>,
1498 pub repository: &'a str,
1500}
1501
1502impl EcrImageRepository<'_> {
1503 pub fn arn(&self, partition: &str, region: &str) -> String {
1506 let region = match self.region {
1507 EcrImageRegion::Literal(region) => region,
1508 EcrImageRegion::Deployment => region,
1509 };
1510 format!(
1511 "arn:{partition}:ecr:{region}:{}:repository/{}",
1512 self.account_id, self.repository
1513 )
1514 }
1515}
1516
1517pub fn parse_ecr_image_repository(
1523 image: &str,
1524) -> std::result::Result<EcrImageRepository<'_>, String> {
1525 let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1526 let (host, path) = image
1527 .split_once('/')
1528 .ok_or_else(|| refuse("names no repository"))?;
1529 let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1530 refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1531 })?;
1532 let region = rest
1534 .strip_suffix(".amazonaws.com.cn")
1535 .or_else(|| rest.strip_suffix(".amazonaws.com"))
1536 .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1537 if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1538 return Err(refuse("names no 12-digit account in its registry host"));
1539 }
1540 let region = if region == BUNDLE_REGION_TOKEN {
1541 EcrImageRegion::Deployment
1542 } else if !region.is_empty()
1543 && region
1544 .bytes()
1545 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1546 {
1547 EcrImageRegion::Literal(region)
1548 } else {
1549 return Err(refuse(&format!(
1550 "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1551 )));
1552 };
1553
1554 let repository = match path.split_once('@') {
1555 Some((repository, _digest)) => repository,
1556 None => match path.rsplit_once('/') {
1557 Some((parent, last)) => match last.split_once(':') {
1558 Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1559 None => path,
1560 },
1561 None => path.split_once(':').map_or(path, |(name, _tag)| name),
1562 },
1563 };
1564 let valid_segment = |segment: &str| {
1565 !segment.is_empty()
1566 && segment.bytes().all(|b| {
1567 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1568 })
1569 };
1570 if !repository.split('/').all(valid_segment) {
1571 return Err(refuse(
1572 "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1573 ));
1574 }
1575 Ok(EcrImageRepository {
1576 account_id,
1577 region,
1578 repository,
1579 })
1580}
1581
1582#[cfg(test)]
1583mod tests {
1584 use super::*;
1585
1586 #[test]
1587 fn private_database_setup_accepts_the_default_network() {
1588 for lifecycle in [
1589 crate::ResourceLifecycle::Frozen,
1590 crate::ResourceLifecycle::Live,
1591 ] {
1592 let stack = crate::Stack::new("database".to_string())
1593 .add(
1594 crate::Postgres::new("metadata".to_string()).build(),
1595 lifecycle,
1596 )
1597 .build();
1598 assert!(!restricts_network_mode(&stack, false));
1599 assert!(!restricts_network_mode(&stack, true));
1600 }
1601 assert!(!restricts_network_mode(
1602 &crate::Stack::new("empty".to_string()).build(),
1603 false,
1604 ));
1605 }
1606
1607 fn repository_key(image: &str) -> String {
1610 match parse_ecr_image_repository(image) {
1611 Ok(parsed) => {
1612 let host = image.split_once('/').map_or(image, |(host, _)| host);
1613 format!("{host}/{}", parsed.repository)
1614 }
1615 Err(_) => image.to_string(),
1616 }
1617 }
1618
1619 #[test]
1620 fn a_private_base_image_names_one_repository() {
1621 let vectors: serde_json::Value = serde_json::from_str(include_str!(
1622 "../../tests/fixtures/ecr-image-repository-parity.json"
1623 ))
1624 .expect("the ECR repository vectors must be JSON");
1625 let field = |case: &serde_json::Value, name: &str| {
1626 case[name]
1627 .as_str()
1628 .unwrap_or_else(|| panic!("vector must carry {name}: {case}"))
1629 .to_string()
1630 };
1631
1632 for case in vectors["accepted"].as_array().expect("accepted vectors") {
1633 let image = field(case, "image");
1634 let region = field(case, "region");
1635 let region = if region == BUNDLE_REGION_TOKEN {
1636 EcrImageRegion::Deployment
1637 } else {
1638 EcrImageRegion::Literal(®ion)
1639 };
1640 assert_eq!(
1641 parse_ecr_image_repository(&image),
1642 Ok(EcrImageRepository {
1643 account_id: &field(case, "accountId"),
1644 region,
1645 repository: &field(case, "repository"),
1646 }),
1647 "{image}"
1648 );
1649 assert_eq!(
1650 repository_key(&image),
1651 field(case, "repositoryKey"),
1652 "{image}"
1653 );
1654 }
1655
1656 for case in vectors["refused"].as_array().expect("refused vectors") {
1657 let image = field(case, "image");
1658 assert!(
1659 parse_ecr_image_repository(&image).is_err(),
1660 "{image} must be refused"
1661 );
1662 assert_eq!(
1663 repository_key(&image),
1664 field(case, "repositoryKey"),
1665 "{image}"
1666 );
1667 }
1668 }
1669
1670 #[test]
1671 fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1672 let regional =
1673 parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1674 .expect("parses");
1675 let pinned =
1676 parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1677 .expect("parses");
1678 assert_eq!(
1679 regional.arn("aws-us-gov", "us-gov-west-1"),
1680 "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1681 );
1682 assert_eq!(
1683 pinned.arn("aws", "us-east-1"),
1684 "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1685 );
1686 }
1687
1688 #[test]
1691 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1692 for uri in [
1693 "s3://acme/team-*/v1/bundle.zip",
1694 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1695 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1696 ] {
1697 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1698 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1699 }
1700
1701 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1702 .expect("an ordinary key still parses");
1703 }
1704
1705 #[test]
1709 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1710 assert_eq!(
1711 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1712 Some("sandbox-bundle")
1713 );
1714 assert_eq!(
1715 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1716 Some("artifacts/team-a/sandbox"),
1717 "a deeper key narrows the prefix, it never widens to the first segment"
1718 );
1719
1720 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1723 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1724 }
1725
1726 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1727 Sandbox::new("agent-sbx".to_string())
1728 .code(SandboxCode::Image {
1729 image: "ubuntu".to_string(),
1730 bundled_from: None,
1731 })
1732 .limits(SandboxLimits {
1733 cpu: "1".to_string(),
1734 memory: "2Gi".to_string(),
1735 disk: "20Gi".to_string(),
1736 max_processes: None,
1737 })
1738 .egress(egress)
1739 .lifecycle(SandboxLifecyclePolicy {
1740 max_lifetime_seconds: None,
1741 idle_pause_seconds: None,
1742 })
1743 .preview_ports(preview_ports)
1744 .build()
1745 }
1746
1747 #[test]
1750 fn a_uri_without_a_token_is_carried_whole() {
1751 assert_eq!(
1752 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1753 Ok(BundleUri::Literal(
1754 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1755 ))
1756 );
1757 }
1758
1759 #[test]
1762 fn a_regional_uri_splits_either_side_of_the_token() {
1763 let BundleUri::Regional { before, after } =
1764 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1765 .expect("the token is accepted in the bucket")
1766 else {
1767 panic!("a bucket-position token must split");
1768 };
1769
1770 assert_eq!(before, "s3://acme-artifacts-");
1771 assert_eq!(after, "/agents/bundle.zip");
1772 assert_eq!(
1773 format!("{before}us-east-2{after}"),
1774 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1775 "the halves must rejoin to the URI the vendor meant"
1776 );
1777 }
1778
1779 #[test]
1782 fn a_token_this_build_cannot_resolve_is_refused() {
1783 for uri in [
1784 "s3://acme-artifacts-{regio}/bundle.zip",
1785 "s3://acme-artifacts/{region}/bundle.zip",
1786 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1787 "s3://acme-artifacts/bundle-{version}.zip",
1788 "s3://acme}-artifacts-{region}/bundle.zip",
1789 "s3://acme{-artifacts-{region}/bundle.zip",
1790 ] {
1791 assert!(
1792 parse_bundle_uri(uri).is_err(),
1793 "'{uri}' must be refused before it can reach an image build"
1794 );
1795 }
1796 }
1797
1798 #[test]
1799 fn resource_type_is_stable() {
1800 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1801 }
1802
1803 #[test]
1804 fn capability_sets_are_per_platform() {
1805 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1806 assert!(
1807 gcp.reconnect,
1808 "generation from the container boot id makes a sandbox reachable across processes"
1809 );
1810 assert!(!gcp.preview);
1811 assert!(gcp.enforced_limits);
1812
1813 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1814 assert!(azure.files, "every backend moves files");
1815 assert!(gcp.files);
1816 assert!(azure.domain_egress_rules);
1819 assert!(azure.egress_deny);
1820 assert!(azure.enforced_limits);
1823 assert!(azure.pause_resume);
1824 assert!(!azure.snapshot);
1828 assert!(!azure.preview);
1829
1830 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1831 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1832 assert!(aws.pause_resume);
1833
1834 let k8s =
1835 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1836 assert!(
1837 !k8s.preview,
1838 "the sandbox-scoped ingress gateway does not exist yet"
1839 );
1840 }
1841
1842 #[test]
1851 fn supervisor_isolation_is_per_platform() {
1852 let value = |platform| {
1853 SandboxCapabilities::for_platform(platform)
1854 .expect("supported")
1855 .supervisor_isolation
1856 };
1857
1858 assert!(
1859 value(Platform::Aws),
1860 "root agent setuids the command to 60000"
1861 );
1862 assert!(
1863 value(Platform::Local),
1864 "the supervisor is on the host, outside the container"
1865 );
1866 assert!(
1867 !value(Platform::Kubernetes),
1868 "a single pinned uid cannot be split"
1869 );
1870 assert!(!value(Platform::Azure), "no Alien process runs the command");
1871 assert!(
1872 !value(Platform::Gcp),
1873 "no separate supervisor identity runs the command"
1874 );
1875 }
1876
1877 #[test]
1881 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1882 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1883 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1884
1885 assert_eq!(
1886 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1887 "the older axis cannot tell them apart"
1888 );
1889 assert!(
1890 aws.supervisor_isolation,
1891 "AWS setuids the command off the supervisor"
1892 );
1893 assert!(
1894 !gcp.supervisor_isolation,
1895 "the command runs under no separate supervisor identity"
1896 );
1897 }
1898
1899 #[test]
1904 fn gcp_agent_platform_row_matches_measured_backend() {
1905 let row = SandboxCapabilities::gcp_agent_platform();
1906
1907 assert!(row.files, "agent file ops move over the sandbox envelope");
1908 assert!(
1909 row.reconnect,
1910 "generation is derived from the container boot id, so a sandbox is reachable across \
1911 processes"
1912 );
1913 assert!(
1914 !row.preview,
1915 "the only ingress is :execute; no port-scoped capability"
1916 );
1917 assert!(
1918 !row.pause_resume,
1919 ":resume can return a fresh container, so a pause keeps no state"
1920 );
1921 assert!(
1922 !row.snapshot,
1923 "the create path never sends a snapshot, so none is reachable through the trait"
1924 );
1925 assert!(
1926 !row.domain_egress_rules,
1927 "VPC and DNS peering is not a hostname allowlist"
1928 );
1929 assert!(
1930 row.egress_deny,
1931 "a declared deny blocks both egress and DNS"
1932 );
1933 assert!(
1934 row.enforced_limits,
1935 "ceilings are enforced, by terminating the sandbox on breach"
1936 );
1937 assert!(!row.process_limit, "no process-count ceiling is observed");
1938 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1939 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1940 assert!(
1941 !row.supervisor_isolation,
1942 "the command is not run under a separate supervisor identity"
1943 );
1944
1945 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1947 assert_eq!(
1948 live, row,
1949 "the Platform::Gcp arm is the Agent Platform capability row"
1950 );
1951 }
1952
1953 #[test]
1954 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1955 let error = SandboxCapabilities::for_platform(Platform::Machines)
1956 .expect_err("Machines has no sandbox backend");
1957 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1958 }
1959
1960 #[test]
1961 fn unsupported_capability_names_platform_and_capability() {
1962 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1963 let error = capabilities
1964 .require(SandboxCapability::Preview, Platform::Gcp)
1965 .expect_err("GCP has no preview");
1966
1967 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1968 let rendered = error.to_string();
1969 assert!(
1970 rendered.contains("preview"),
1971 "names the capability: {rendered}"
1972 );
1973 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1974 }
1975
1976 #[test]
1980 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1981 let sandbox = sandbox_with(
1982 SandboxEgress::AllowDomains {
1983 domains: vec!["example.com".to_string()],
1984 },
1985 vec![],
1986 );
1987
1988 for platform in [
1989 Platform::Aws,
1990 Platform::Gcp,
1991 Platform::Kubernetes,
1992 Platform::Local,
1993 ] {
1994 let error = sandbox
1995 .validate_for_platform(platform)
1996 .expect_err("only Azure expresses a hostname allowlist");
1997 assert_eq!(
1998 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1999 "on {platform:?}"
2000 );
2001 }
2002
2003 assert!(
2004 SandboxCapabilities::for_platform(Platform::Azure)
2005 .expect("supported")
2006 .domain_egress_rules,
2007 "Azure's egress policy matches on host pattern"
2008 );
2009 }
2010
2011 #[test]
2014 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
2015 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
2016
2017 assert!(
2020 SandboxCapabilities::for_platform(Platform::Gcp)
2021 .expect("supported")
2022 .egress_deny
2023 );
2024
2025 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
2026 sandbox
2027 .validate_for_platform(platform)
2028 .expect("deny is enforced here");
2029 }
2030
2031 let egress_only = Sandbox::new("sbx".to_string())
2033 .code(SandboxCode::Image {
2034 image: "alpine".to_string(),
2035 bundled_from: None,
2036 })
2037 .egress(SandboxEgress::None)
2038 .lifecycle(SandboxLifecyclePolicy {
2039 max_lifetime_seconds: None,
2040 idle_pause_seconds: None,
2041 })
2042 .build();
2043
2044 egress_only
2045 .validate_for_platform(Platform::Azure)
2046 .expect("Azure creates the sandbox under a Deny policy with full inspection");
2047 }
2048
2049 #[test]
2053 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
2054 let undeclared = Sandbox::new("sbx".to_string())
2055 .code(SandboxCode::Image {
2056 image: "alpine".to_string(),
2057 bundled_from: None,
2058 })
2059 .egress(SandboxEgress::None)
2060 .lifecycle(SandboxLifecyclePolicy {
2061 max_lifetime_seconds: None,
2062 idle_pause_seconds: None,
2063 })
2064 .build();
2065
2066 undeclared
2067 .validate_for_platform(Platform::Azure)
2068 .expect("a sandbox naming no ceilings takes the platform's own");
2069
2070 assert_eq!(undeclared.resolved_limits().cpu, "1");
2072 }
2073
2074 #[test]
2078 fn azure_sizes_follow_the_rule_the_data_plane_states() {
2079 let sized = |cpu: &str, memory: &str, disk: &str| {
2080 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2081 let limits = sandbox
2082 .limits
2083 .as_mut()
2084 .expect("the fixture declares limits");
2085 limits.cpu = cpu.to_string();
2086 limits.memory = memory.to_string();
2087 limits.disk = disk.to_string();
2088 sandbox.validate_for_platform(Platform::Azure)
2089 };
2090
2091 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
2092 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
2093 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
2094
2095 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
2097 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
2098 assert!(off_step.to_string().contains("cpu"), "{off_step}");
2099
2100 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
2101 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
2102
2103 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
2106 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
2107 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
2108 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
2109
2110 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
2111 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
2112 }
2113
2114 #[test]
2115 fn preview_ports_require_the_preview_capability() {
2116 let sandbox = sandbox_with(SandboxEgress::None, vec![8080]);
2117
2118 sandbox
2119 .validate_for_platform(Platform::Aws)
2120 .expect("AWS mints a port-scoped JWE");
2121
2122 let error = sandbox
2123 .validate_for_platform(Platform::Kubernetes)
2124 .expect_err("Kubernetes preview is deferred");
2125 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2126 }
2127
2128 #[test]
2132 fn a_private_base_image_is_refused_off_aws() {
2133 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2134 sandbox.code = SandboxCode::Image {
2135 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
2136 bundled_from: None,
2137 };
2138 sandbox.private_base_image =
2139 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
2140
2141 sandbox
2142 .validate_for_platform(Platform::Aws)
2143 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
2144
2145 for platform in [
2146 Platform::Gcp,
2147 Platform::Azure,
2148 Platform::Kubernetes,
2149 Platform::Local,
2150 ] {
2151 let error = sandbox
2152 .validate_for_platform(platform)
2153 .expect_err("a backend that builds no image must refuse a base image for one");
2154 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2155 assert!(
2156 error.to_string().contains("privateBaseImage"),
2157 "the refusal must name the field the user declared: {error}"
2158 );
2159 }
2160 }
2161
2162 #[test]
2163 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
2164 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2165 sandbox
2166 .validate_for_platform(Platform::Gcp)
2167 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
2168 }
2169
2170 #[test]
2171 fn invalid_quantities_are_rejected_with_the_offending_field() {
2172 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2173 sandbox
2174 .limits
2175 .as_mut()
2176 .expect("the fixture declares limits")
2177 .memory = "2Gb".to_string();
2178
2179 let error = sandbox
2180 .validate_for_platform(Platform::Aws)
2181 .expect_err("Gb is not a valid suffix");
2182 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2183 assert!(error.to_string().contains("memory"));
2184
2185 sandbox
2186 .limits
2187 .as_mut()
2188 .expect("the fixture declares limits")
2189 .memory = "2Gi".to_string();
2190 sandbox
2191 .limits
2192 .as_mut()
2193 .expect("the fixture declares limits")
2194 .cpu = "0".to_string();
2195 let error = sandbox
2196 .validate_for_platform(Platform::Aws)
2197 .expect_err("zero cpu is not a ceiling");
2198 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2199 }
2200
2201 #[test]
2202 fn zero_max_processes_is_rejected() {
2203 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2204 sandbox
2205 .limits
2206 .as_mut()
2207 .expect("the fixture declares limits")
2208 .max_processes = Some(0);
2209
2210 let error = sandbox
2211 .validate_for_platform(Platform::Local)
2212 .expect_err("a sandbox must be able to run at least one process");
2213 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2214 assert!(error.to_string().contains("maxProcesses"));
2215 }
2216
2217 #[test]
2221 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
2222 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2223 sandbox
2224 .limits
2225 .as_mut()
2226 .expect("the fixture declares limits")
2227 .max_processes = Some(256);
2228
2229 sandbox
2230 .validate_for_platform(Platform::Local)
2231 .expect("Docker takes a pids limit");
2232
2233 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
2234 let error = sandbox
2235 .validate_for_platform(platform)
2236 .expect_err("a process ceiling nothing applies must be refused");
2237 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2238 }
2239 }
2240
2241 #[test]
2246 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
2247 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2248 let mut without_limits = with_limits.clone();
2249 without_limits.limits = None;
2250
2251 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2252 for seconds in [28_801, 30_000, 100_000] {
2253 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2254 let error = sandbox
2255 .validate_for_platform(Platform::Aws)
2256 .expect_err("a lifetime above what AWS runs is refused");
2257 assert_eq!(
2258 error.code, "SANDBOX_LIMIT_INVALID",
2259 "{seconds}s {declared} limits"
2260 );
2261
2262 sandbox
2264 .validate_for_platform(Platform::Kubernetes)
2265 .expect("Kubernetes takes an activeDeadlineSeconds above 28,800");
2266 }
2267
2268 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
2269 sandbox
2270 .validate_for_platform(Platform::Aws)
2271 .expect("the ceiling itself is allowed");
2272 }
2273 }
2274
2275 #[test]
2279 fn a_zero_lifetime_is_refused_while_planning() {
2280 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2281 let mut without_limits = with_limits.clone();
2282 without_limits.limits = None;
2283
2284 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2285 sandbox.lifecycle.max_lifetime_seconds = Some(0);
2286 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Gcp] {
2287 let error = sandbox
2288 .validate_for_platform(platform)
2289 .expect_err("a zero lifetime is refused");
2290 assert_eq!(
2291 error.code, "SANDBOX_LIMIT_INVALID",
2292 "{platform} {declared} limits"
2293 );
2294 assert!(
2295 error.to_string().contains("maxLifetimeSeconds"),
2296 "{platform} {declared} limits"
2297 );
2298 }
2299
2300 for platform in [Platform::Azure, Platform::Local] {
2302 let error = sandbox
2303 .validate_for_platform(platform)
2304 .expect_err("a lifetime is refused where nothing applies it");
2305 assert_eq!(
2306 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
2307 "{platform} {declared} limits"
2308 );
2309 }
2310
2311 sandbox.lifecycle.max_lifetime_seconds = Some(1);
2312 sandbox
2313 .validate_for_platform(Platform::Kubernetes)
2314 .expect("one second is the API server's floor");
2315 }
2316 }
2317
2318 #[test]
2322 fn a_lifetime_kubernetes_would_reject_is_refused_while_planning() {
2323 let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2324 let mut without_limits = with_limits.clone();
2325 without_limits.limits = None;
2326
2327 for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2328 for seconds in [2_147_483_648, u32::MAX] {
2329 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2330 let error = sandbox
2331 .validate_for_platform(Platform::Kubernetes)
2332 .expect_err("a lifetime above what the API server takes is refused");
2333 assert_eq!(
2334 error.code, "SANDBOX_LIMIT_INVALID",
2335 "{seconds}s {declared} limits"
2336 );
2337 assert!(
2338 error.to_string().contains("maxLifetimeSeconds"),
2339 "{seconds}s {declared} limits"
2340 );
2341 }
2342
2343 sandbox.lifecycle.max_lifetime_seconds = Some(2_147_483_647);
2344 sandbox
2345 .validate_for_platform(Platform::Kubernetes)
2346 .expect("the ceiling itself is allowed");
2347 }
2348 }
2349
2350 #[test]
2353 fn an_image_azure_cannot_pull_is_refused_while_planning() {
2354 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2355 sandbox.limits = None;
2358
2359 for image in ["ubuntu@sha256:abc", "", " ", "ubuntu latest", "ubuntu?x"] {
2361 sandbox.code = SandboxCode::Image {
2362 image: image.to_string(),
2363 bundled_from: None,
2364 };
2365 let error = sandbox
2366 .validate_for_platform(Platform::Azure)
2367 .expect_err("an image Azure has nowhere to put is refused");
2368 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
2369 }
2370
2371 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
2372 sandbox.code = SandboxCode::Image {
2373 image: image.to_string(),
2374 bundled_from: None,
2375 };
2376 sandbox
2377 .validate_for_platform(Platform::Azure)
2378 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
2379 }
2380
2381 for image in [
2382 "ubuntu:24.04",
2383 "ghcr.io/myorg/sandbox:latest",
2384 "docker.io/library/python:3.14-slim",
2385 "localhost:5000/team/agent@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d",
2386 ] {
2387 sandbox.code = SandboxCode::Image {
2388 image: image.to_string(),
2389 bundled_from: None,
2390 };
2391 sandbox
2392 .validate_for_platform(Platform::Azure)
2393 .unwrap_or_else(|error| panic!("'{image}' is a registry image: {error}"));
2394 }
2395
2396 sandbox.code = SandboxCode::Image {
2398 image: " ubuntu ".to_string(),
2399 bundled_from: None,
2400 };
2401 assert_eq!(
2402 sandbox
2403 .azure_image()
2404 .expect("a padded name is still a name"),
2405 AzureSandboxImage::Catalog("ubuntu")
2406 );
2407 }
2408
2409 #[test]
2413 fn every_value_the_catalog_allowlist_accepted_is_still_a_catalog_name() {
2414 const ALPHABET: &[u8] =
2415 b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
2416
2417 let assert_catalog = |value: &str| {
2418 assert_eq!(
2419 classify_azure_sandbox_image(value),
2420 Some(AzureSandboxImage::Catalog(value.trim())),
2421 "'{value}'"
2422 );
2423 };
2424
2425 for a in ALPHABET {
2426 assert_catalog(&String::from_utf8(vec![*a]).unwrap());
2427 for b in ALPHABET {
2428 assert_catalog(&String::from_utf8(vec![*a, *b]).unwrap());
2429 for c in ALPHABET {
2430 assert_catalog(&String::from_utf8(vec![*a, *b, *c]).unwrap());
2431 }
2432 }
2433 }
2434
2435 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2436 let mut next = || {
2437 state ^= state << 13;
2438 state ^= state >> 7;
2439 state ^= state << 17;
2440 state
2441 };
2442 for _ in 0..20_000 {
2443 let len = 4 + (next() % 60) as usize;
2444 let mut value: String = (0..len)
2445 .map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize] as char)
2446 .collect();
2447 if next() % 4 == 0 {
2448 value = format!(" {value}\t");
2449 }
2450 assert_catalog(&value);
2451 }
2452 }
2453
2454 #[test]
2457 fn the_disk_image_label_is_stable_and_label_safe() {
2458 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
2459 assert_eq!(
2460 label,
2461 azure_disk_image_label(" docker.io/library/python:3.14-slim ")
2462 );
2463 assert_ne!(
2464 label,
2465 azure_disk_image_label("docker.io/library/python:3.13-slim")
2466 );
2467 assert_eq!(label.len(), 32);
2468 assert!(label.chars().all(|c| c.is_ascii_hexdigit()), "{label}");
2469 }
2470
2471 #[test]
2475 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2476 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2477 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2478
2479 sandbox
2480 .validate_for_platform(Platform::Kubernetes)
2481 .expect("the kubelet enforces activeDeadlineSeconds");
2482 sandbox
2483 .validate_for_platform(Platform::Aws)
2484 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2485
2486 for platform in [Platform::Azure, Platform::Local] {
2487 let error = sandbox
2488 .validate_for_platform(platform)
2489 .expect_err("a deadline nothing applies must be refused");
2490 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2491 }
2492 }
2493
2494 #[test]
2498 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2499 let sandbox = sandbox_with(SandboxEgress::None, vec![]);
2500 let tier = sandbox
2501 .microvm_tier()
2502 .expect("2Gi/1cpu/20Gi is satisfiable");
2503
2504 assert_eq!(
2505 tier.peak_memory_mib, 2048,
2506 "the peak is the declared ceiling"
2507 );
2508 assert_eq!(
2509 tier.baseline_memory_mib, 512,
2510 "which is a quarter of it as the baseline"
2511 );
2512 assert!(tier.max_disk_mib <= 20 * 1024);
2513 }
2514
2515 #[test]
2519 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2520 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2521 {
2522 let limits = sandbox
2523 .limits
2524 .as_mut()
2525 .expect("the fixture declares limits");
2526 limits.cpu = "1".to_string();
2527 limits.memory = "8Gi".to_string();
2528 }
2529
2530 let error = sandbox
2531 .microvm_tier()
2532 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2533 assert!(
2534 error.to_string().contains("4 vCPU"),
2535 "the refusal must say what the memory ceiling implies: {error}"
2536 );
2537
2538 sandbox
2539 .limits
2540 .as_mut()
2541 .expect("the fixture declares limits")
2542 .cpu = "4".to_string();
2543 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2544 assert_eq!(tier.peak_memory_mib, 8192);
2545 }
2546
2547 #[test]
2550 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2551 let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2552 sandbox
2553 .limits
2554 .as_mut()
2555 .expect("the fixture declares limits")
2556 .memory = "1Gi".to_string();
2557
2558 let error = sandbox
2559 .validate_for_platform(Platform::Aws)
2560 .expect_err("no MicroVM size peaks at or below 1Gi");
2561 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2562 assert!(
2563 error.to_string().contains("2Gi"),
2564 "the refusal must say what the smallest holdable ceiling is: {error}"
2565 );
2566 }
2567
2568 #[test]
2571 fn source_code_is_refused_where_no_backend_builds_it() {
2572 let sandbox = Sandbox::new("agent".to_string())
2573 .code(SandboxCode::Source {
2574 src: "./sandbox".to_string(),
2575 toolchain: ToolchainConfig::Docker {
2576 dockerfile: None,
2577 build_args: None,
2578 target: None,
2579 },
2580 })
2581 .egress(SandboxEgress::None)
2582 .lifecycle(SandboxLifecyclePolicy {
2583 max_lifetime_seconds: None,
2584 idle_pause_seconds: None,
2585 })
2586 .build();
2587
2588 sandbox
2589 .validate_for_platform(Platform::Aws)
2590 .expect("an AWS sandbox base image is built by `alien build`");
2591 sandbox
2592 .validate_for_platform(Platform::Azure)
2593 .expect("an Azure sandbox base image is built by `alien build`");
2594 sandbox
2595 .validate_for_platform(Platform::Gcp)
2596 .expect("a GCP sandbox base image is built by `alien build`");
2597
2598 for platform in [Platform::Kubernetes, Platform::Local] {
2599 let error = sandbox
2600 .validate_for_platform(platform)
2601 .expect_err("no backend builds a sandbox image from source here");
2602 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2603 assert!(
2604 error.to_string().contains("code.image"),
2605 "the refusal must say what to write instead: {error}"
2606 );
2607 assert!(
2608 error.to_string().contains(&platform.to_string()),
2609 "the refusal must name the platform that cannot build it: {error}"
2610 );
2611 }
2612 }
2613
2614 #[test]
2617 fn every_accepted_unit_converts_rather_than_falling_back() {
2618 assert_eq!(quantity_mib("2Gi"), Some(2048));
2619 assert_eq!(quantity_mib("512Mi"), Some(512));
2620 assert_eq!(quantity_mib("4G"), Some(3814));
2621 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2622 assert_eq!(millicores("1"), Some(1000));
2623 assert_eq!(millicores("500m"), Some(500));
2624 }
2625
2626 #[test]
2627 fn privileged_supervisor_fixes_identity_and_requires_an_enforceable_backend() {
2628 let mut sandbox = sandbox_with(
2629 SandboxEgress::AllowDomains {
2630 domains: vec!["example.com".to_string()],
2631 },
2632 vec![],
2633 );
2634 sandbox.privileged_supervisor = Some(SandboxPrivilegedSupervisor { command_uid: 60001 });
2635 sandbox
2636 .validate_for_platform(Platform::Aws)
2637 .expect("AWS can enforce the policy in the agent");
2638 assert_eq!(sandbox.cloud_egress(), &SandboxEgress::Allow);
2639 assert_eq!(
2640 sandbox.supervisor_environment()["ALIEN_SANDBOX_EXPECTED_EXEC_UID"],
2641 "60001"
2642 );
2643 for platform in [
2644 Platform::Local,
2645 Platform::Kubernetes,
2646 Platform::Azure,
2647 Platform::Gcp,
2648 ] {
2649 let error = sandbox
2650 .validate_for_platform(platform)
2651 .expect_err("cannot grant a privilege boundary the backend lacks");
2652 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2653 assert!(error.message.contains("privilegedSupervisor"));
2654 }
2655 for uid in [0, u32::MAX] {
2656 sandbox.privileged_supervisor.as_mut().unwrap().command_uid = uid;
2657 let error = sandbox
2658 .validate_for_platform(Platform::Aws)
2659 .expect_err("invalid uid must fail at plan time");
2660 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2661 }
2662 }
2663
2664 #[test]
2665 fn unknown_fields_are_rejected() {
2666 let json = r#"{
2667 "id": "sbx",
2668 "code": {"type": "image", "image": "ubuntu:24.04"},
2669 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2670 "egress": {"mode": "deny"},
2671 "lifecycle": {},
2672 "unexpected": true
2673 }"#;
2674
2675 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2676 }
2677
2678 #[test]
2679 fn serialization_roundtrips() {
2680 let sandbox = sandbox_with(
2681 SandboxEgress::AllowDomains {
2682 domains: vec!["example.com".to_string()],
2683 },
2684 vec![8080, 9090],
2685 );
2686
2687 let json = serde_json::to_string(&sandbox).expect("serializes");
2688 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2689 assert_eq!(sandbox, restored);
2690 }
2691
2692 #[test]
2693 fn id_is_immutable_across_updates() {
2694 let original = sandbox_with(SandboxEgress::None, vec![]);
2695 let renamed = Sandbox::new("other".to_string())
2696 .code(SandboxCode::Image {
2697 image: "ubuntu".to_string(),
2698 bundled_from: None,
2699 })
2700 .limits(
2701 original
2702 .limits
2703 .clone()
2704 .expect("the fixture declares limits"),
2705 )
2706 .egress(SandboxEgress::None)
2707 .lifecycle(SandboxLifecyclePolicy {
2708 max_lifetime_seconds: None,
2709 idle_pause_seconds: None,
2710 })
2711 .build();
2712
2713 original
2714 .validate_update(&original.clone())
2715 .expect("an unchanged config is a valid update");
2716 original
2717 .validate_update(&renamed)
2718 .expect_err("renaming a sandbox is not an update");
2719 }
2720
2721 #[test]
2727 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2728 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2729 Sandbox::new("sbx".to_string())
2730 .code(SandboxCode::Image {
2731 image: "ubuntu".to_string(),
2732 bundled_from: None,
2733 })
2734 .egress(SandboxEgress::Allow)
2735 .lifecycle(lifecycle)
2736 .build()
2737 .validate_for_platform(Platform::Azure)
2738 };
2739
2740 with_policy(SandboxLifecyclePolicy {
2741 max_lifetime_seconds: None,
2742 idle_pause_seconds: Some(900),
2743 })
2744 .expect("Azure pauses a sandbox on idle");
2745
2746 let error = with_policy(SandboxLifecyclePolicy {
2747 max_lifetime_seconds: Some(3600),
2748 idle_pause_seconds: None,
2749 })
2750 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2751 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2752 assert!(
2753 error.message.contains("sandboxLifetime"),
2754 "names the capability: {}",
2755 error.message
2756 );
2757 }
2758
2759 #[test]
2765 fn an_allowlist_with_no_domains_is_refused() {
2766 let declared = |domains: Vec<String>| {
2767 Sandbox::new("sbx".to_string())
2768 .code(SandboxCode::Image {
2769 image: "ubuntu".to_string(),
2770 bundled_from: None,
2771 })
2772 .egress(SandboxEgress::AllowDomains { domains })
2773 .lifecycle(SandboxLifecyclePolicy {
2774 max_lifetime_seconds: None,
2775 idle_pause_seconds: None,
2776 })
2777 .build()
2778 .validate_for_platform(Platform::Azure)
2779 };
2780
2781 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2782 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2783
2784 declared(vec!["api.example.com".to_string()])
2785 .expect("a named domain is what an allowlist is for");
2786 }
2787
2788 #[test]
2791 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2792 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2793 assert_eq!(SandboxEgress::None.internet_access_switch(), Some(false));
2794 assert_eq!(
2795 SandboxEgress::AllowDomains {
2796 domains: vec!["api.example.com".to_string()]
2797 }
2798 .internet_access_switch(),
2799 None,
2800 "a host list has no boolean and must not be approximated"
2801 );
2802 }
2803
2804 #[test]
2808 fn stack_state_written_before_the_lifecycle_rename_still_reads() {
2809 let stored: crate::StackResourceState = serde_json::from_value(serde_json::json!({
2810 "type": "sandbox",
2811 "config": {
2812 "id": "sandbox",
2813 "code": {
2814 "type": "image",
2815 "image": "s3://example-bundles/analysis/v1/bundle.zip"
2816 },
2817 "type": "sandbox",
2818 "egress": { "mode": "allow" },
2819 "session": { "maxLifetimeSeconds": 28800, "idleSuspendSeconds": 600 }
2820 },
2821 "status": "running",
2822 "outputs": {
2823 "type": "sandbox",
2824 "identifier": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox",
2825 "parentName": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox"
2826 },
2827 "lifecycle": "frozen",
2828 "dependencies": [
2829 { "id": "management", "type": "remote-stack-management" },
2830 { "id": "access", "type": "resource-access" }
2831 ],
2832 "controllerPlatform": "aws"
2833 }))
2834 .expect("a stack state written before the rename must still deserialize");
2835
2836 let expected = Sandbox::new("sandbox".to_string())
2837 .code(SandboxCode::Image {
2838 image: "s3://example-bundles/analysis/v1/bundle.zip".to_string(),
2839 bundled_from: None,
2840 })
2841 .egress(SandboxEgress::Allow)
2842 .lifecycle(SandboxLifecyclePolicy {
2843 max_lifetime_seconds: Some(28_800),
2844 idle_pause_seconds: Some(600),
2845 })
2846 .build();
2847 assert_eq!(stored.config.downcast_ref::<Sandbox>(), Some(&expected));
2848
2849 let rewritten = serde_json::to_value(&stored.config).expect("the sandbox serializes");
2850 assert_eq!(
2851 rewritten["lifecycle"],
2852 serde_json::json!({ "maxLifetimeSeconds": 28800, "idlePauseSeconds": 600 }),
2853 "the next write stores the current names"
2854 );
2855 assert!(rewritten.get("session").is_none());
2856 }
2857}