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