Skip to main content

alien_core/resources/
sandbox.rs

1//! Sandbox resource for running untrusted code in an isolated environment.
2//!
3//! The declaration provisions a durable parent, and the application creates and destroys
4//! individual sandboxes through its binding at runtime.
5//!
6//! The capability set differs per platform and is published rather than assumed. Calling an
7//! unsupported capability is a typed error naming both the platform and the capability, so a
8//! portable application can branch on `SandboxCapabilities` before it calls.
9
10use 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/// Specifies where the sandbox's root filesystem comes from.
22#[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    /// A prebuilt container image used as the sandbox root filesystem.
27    #[serde(rename_all = "camelCase")]
28    Image {
29        /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`). AWS wants an
30        /// `s3://` bundle; Azure takes a catalog name such as `ubuntu` or an amd64 registry
31        /// image, told apart by syntax. Each refuses what it cannot take while planning.
32        image: String,
33    },
34    /// A Dockerfile `alien build` builds into the sandbox's base image.
35    ///
36    /// AWS, Azure and GCP, and docker only: the base image is a root filesystem, not a binary laid
37    /// on one. `alien release` pushes it; on AWS the bundle layers the sandbox agent on afterwards,
38    /// and on GCP the release layers it on and pushes the result.
39    #[serde(rename_all = "camelCase")]
40    Source {
41        /// The source directory to build from
42        src: String,
43        /// Toolchain configuration with type-safe options
44        toolchain: ToolchainConfig,
45    },
46}
47
48/// Hard ceilings enforced on a sandbox.
49///
50/// These are limits, not scheduling requests. Untrusted code does not respect a hint, so every
51/// field is enforced by the platform and a platform that cannot enforce one is rejected at plan
52/// time rather than silently ignoring it.
53#[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    /// CPU ceiling in cores or millicores (e.g. `"1"`, `"500m"`)
58    pub cpu: String,
59    /// Memory ceiling (e.g. `"2Gi"`, `"512Mi"`)
60    pub memory: String,
61    /// Disk ceiling (e.g. `"20Gi"`)
62    pub disk: String,
63    /// Maximum number of processes, which bounds fork bombs.
64    ///
65    /// Optional because only a container runtime has the primitive: Kubernetes sets a pid ceiling
66    /// per node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring
67    /// it on a platform that cannot apply it is refused at plan time.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub max_processes: Option<u32>,
70}
71
72/// One of the five sizes a Lambda MicroVM can be built at.
73///
74/// AWS has no ceiling knob: `minimumMemoryInMiB` sets a *baseline* and a running MicroVM bursts
75/// vertically to four times it with no way to opt out. A declared ceiling is therefore honoured by
76/// picking the tier whose **peak** stays inside it, not the tier whose baseline matches it.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct MicrovmTier {
79    /// What `minimumMemoryInMiB` is set to.
80    pub baseline_memory_mib: i64,
81    /// The most memory the MicroVM can reach, in MiB.
82    pub peak_memory_mib: i64,
83    /// The most vCPU the MicroVM can reach.
84    pub peak_vcpu: u32,
85    /// The most disk the MicroVM can use, in MiB.
86    pub max_disk_mib: i64,
87}
88
89/// The published sizes, smallest first. Baseline memory to vCPU is 2 GB per vCPU, peak is four
90/// times baseline, and disk is fixed per tier rather than independently selectable.
91/// Longest life AWS will run a MicroVM for, from `RunMicrovm`'s `maximumDurationInSeconds`.
92const AWS_MAX_LIFETIME_SECONDS: u32 = 28_800;
93
94/// Azure's sandbox sizing rule, quoted from the data plane's own refusal of an oversized request:
95/// *CPU must be n×250m for n=1..64 (0.25–16 cores); Memory ≤ cores × 2Gi; Disk ≤ cores × 20Gi*.
96const AZURE_CPU_STEP_MILLICORES: i64 = 250;
97const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
98const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
99const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
100
101const MICROVM_TIERS: &[MicrovmTier] = &[
102    MicrovmTier {
103        baseline_memory_mib: 512,
104        peak_memory_mib: 2048,
105        peak_vcpu: 1,
106        max_disk_mib: 8192,
107    },
108    MicrovmTier {
109        baseline_memory_mib: 1024,
110        peak_memory_mib: 4096,
111        peak_vcpu: 2,
112        max_disk_mib: 8192,
113    },
114    MicrovmTier {
115        baseline_memory_mib: 2048,
116        peak_memory_mib: 8192,
117        peak_vcpu: 4,
118        max_disk_mib: 8192,
119    },
120    MicrovmTier {
121        baseline_memory_mib: 4096,
122        peak_memory_mib: 16384,
123        peak_vcpu: 8,
124        max_disk_mib: 16384,
125    },
126    MicrovmTier {
127        baseline_memory_mib: 8192,
128        peak_memory_mib: 32768,
129        peak_vcpu: 16,
130        max_disk_mib: 32768,
131    },
132];
133
134pub use super::sandbox_policy::{is_public_ipv4, EgressPolicy};
135
136/// Outbound policy. Only the new Internet value opts into gateway enforcement.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum SandboxEgress {
139    None,
140    Internet,
141    /// Deprecated direct backend access, unchanged from released deployments.
142    Allow,
143    /// Deprecated backend hostname allowlist, unchanged from released deployments.
144    AllowDomains {
145        domains: Vec<String>,
146    },
147}
148
149/// Deprecated object declarations preserve their released backend behavior.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
152#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
153pub enum LegacySandboxEgress {
154    /// Deprecated alias of "none".
155    Deny,
156    /// Deprecated direct backend access. Use "internet" to opt into the gateway,
157    /// fixed outbound IP, connection logs and per-sandbox runtime policies.
158    Allow,
159    /// Deprecated backend hostname rules. Use "internet" and runtime policies
160    /// to opt into gateway enforcement instead.
161    AllowDomains { domains: Vec<String> },
162}
163
164#[derive(Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166enum SandboxEgressMode {
167    None,
168    Internet,
169}
170
171#[derive(Serialize, Deserialize)]
172#[serde(untagged)]
173enum SandboxEgressInput {
174    Mode(SandboxEgressMode),
175    Legacy(LegacySandboxEgress),
176}
177
178impl Serialize for SandboxEgress {
179    fn serialize<S: serde::Serializer>(
180        &self,
181        serializer: S,
182    ) -> std::result::Result<S::Ok, S::Error> {
183        match self {
184            Self::None => serializer.serialize_str("none"),
185            Self::Internet => serializer.serialize_str("internet"),
186            Self::Allow => LegacySandboxEgress::Allow.serialize(serializer),
187            Self::AllowDomains { domains } => LegacySandboxEgress::AllowDomains {
188                domains: domains.clone(),
189            }
190            .serialize(serializer),
191        }
192    }
193}
194
195impl<'de> Deserialize<'de> for SandboxEgress {
196    fn deserialize<D: serde::Deserializer<'de>>(
197        deserializer: D,
198    ) -> std::result::Result<Self, D::Error> {
199        Ok(match SandboxEgressInput::deserialize(deserializer)? {
200            SandboxEgressInput::Mode(SandboxEgressMode::None)
201            | SandboxEgressInput::Legacy(LegacySandboxEgress::Deny) => Self::None,
202            SandboxEgressInput::Mode(SandboxEgressMode::Internet) => Self::Internet,
203            SandboxEgressInput::Legacy(LegacySandboxEgress::Allow) => Self::Allow,
204            SandboxEgressInput::Legacy(LegacySandboxEgress::AllowDomains { domains }) => {
205                Self::AllowDomains { domains }
206            }
207        })
208    }
209}
210
211#[cfg(feature = "openapi")]
212impl utoipa::PartialSchema for SandboxEgress {
213    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
214        use utoipa::openapi::schema::{ObjectBuilder, OneOfBuilder, Type};
215        OneOfBuilder::new()
216            .item(
217                ObjectBuilder::new()
218                    .schema_type(Type::String)
219                    .enum_values(Some(["none", "internet"])),
220            )
221            .item(LegacySandboxEgress::schema())
222            .into()
223    }
224}
225#[cfg(feature = "openapi")]
226impl utoipa::ToSchema for SandboxEgress {}
227
228impl SandboxEgress {
229    /// Deprecated spelling retained for Rust callers of the released API.
230    #[allow(non_upper_case_globals)]
231    #[deprecated(note = "use SandboxEgress::None")]
232    pub const Deny: Self = Self::None;
233
234    /// Backend switch; hostname rules must be handled by a backend matcher.
235    pub fn internet_access_switch(&self) -> Option<bool> {
236        match self {
237            Self::None => Some(false),
238            Self::Internet | Self::Allow => Some(true),
239            Self::AllowDomains { .. } => None,
240        }
241    }
242}
243
244/// How long a sandbox may live and when it is paused.
245///
246/// Declaration-time ceilings, not per-request values: every sandbox created through this
247/// declaration's binding is held to them, whatever a caller asks for at runtime.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
250#[serde(rename_all = "camelCase", deny_unknown_fields)]
251pub struct SandboxLifecyclePolicy {
252    /// Wall-clock ceiling on a single sandbox, after which the platform terminates it.
253    ///
254    /// Optional because not every backend has the primitive: Kubernetes has
255    /// `activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local
256    /// expose one, so declaring a ceiling there is refused at plan time rather than accepted and
257    /// never applied. AWS caps it at 8 hours.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub max_lifetime_seconds: Option<u32>,
260    /// Idle period after which the sandbox is paused, where the platform supports it.
261    ///
262    /// Stored state written before the rename calls this `idleSuspendSeconds`.
263    #[serde(alias = "idleSuspendSeconds", skip_serializing_if = "Option::is_none")]
264    pub idle_pause_seconds: Option<u32>,
265}
266
267/// What a platform's sandbox backend can actually do.
268///
269/// Published so portable code can branch before calling rather than discovering a gap through
270/// an error. Every field here corresponds to a capability that at least one platform lacks;
271/// create, exec and terminate are the guaranteed floor and are therefore not listed.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct SandboxCapabilities {
276    /// Files can be moved in and out of a sandbox
277    pub files: bool,
278    /// A later call can reach a sandbox created by an earlier one
279    pub reconnect: bool,
280    /// A command can be started, polled and cancelled across separate calls, so it outlives the
281    /// one that started it. False where nothing inside the sandbox owns the process in between.
282    pub jobs: bool,
283    /// An authenticated, port-scoped capability to reach a service inside the sandbox
284    pub preview: bool,
285    /// Sandbox state can be paused and resumed
286    pub pause_resume: bool,
287    /// A sandbox's full state can be captured and used to create another
288    pub snapshot: bool,
289    /// Egress can be restricted to a hostname allowlist
290    pub domain_egress_rules: bool,
291    /// Whether a declared `deny` is actually enforced, rather than accepted and dropped.
292    /// It covers routed traffic; the `deny` mode says where DNS still resolves.
293    pub egress_deny: bool,
294    /// The platform enforces the declared cpu, memory and disk ceilings
295    pub enforced_limits: bool,
296    /// The platform can cap how many processes a sandbox runs
297    pub process_limit: bool,
298    /// The platform terminates a sandbox at a declared wall-clock deadline
299    pub sandbox_lifetime: bool,
300    /// A command runs in its own PID namespace and cannot see or signal the agent's processes.
301    ///
302    /// Only where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the
303    /// Kubernetes sandbox pod drops every capability — which is also what denies `ptrace` by
304    /// construction, so granting it there would remove a lock to add one.
305    pub supervisor_pid_namespace: bool,
306    /// The process supervising a command is a different identity from the command.
307    ///
308    /// False where a command runs as the agent's own user: it can then read the supervisor's
309    /// environment and signal it. Separate from `supervisorPidNamespace`, which is about
310    /// visibility rather than identity — a backend can have one without the other.
311    pub supervisor_isolation: bool,
312}
313
314impl SandboxCapabilities {
315    /// Returns what the given platform's sandbox backend supports.
316    ///
317    /// Errors for platforms with no sandbox backend, rather than returning an all-false set —
318    /// "every capability is missing" and "this platform has no sandboxes" are different
319    /// conditions and an application should not have to tell them apart by inspection.
320    pub fn for_platform(platform: Platform) -> Result<Self> {
321        match platform {
322            Platform::Aws => Ok(Self {
323                files: true,
324                reconnect: true,
325                jobs: true,
326                preview: true,
327                pause_resume: true,
328                snapshot: false,
329                domain_egress_rules: false,
330                // Routed egress only: while the connector VPC has DNS support on, a session
331                // resolves names through its resolver, which no security group filters.
332                egress_deny: true,
333                enforced_limits: true,
334                // Nothing in the API bounds process count.
335                process_limit: false,
336                // `maximumDurationInSeconds` on `RunMicrovm`, which Lambda enforces by
337                // terminating the MicroVM. Capped at 8 hours by the service.
338                sandbox_lifetime: true,
339                // Measured, not assumed: the agent inside a Lambda MicroVM runs as uid 0 with
340                // `CapEff: 00000000a80425fb`, the standard container default set, which excludes
341                // `CAP_SYS_ADMIN`. It can drop privilege (`CAP_SETUID`/`CAP_SETGID` are held) and
342                // it cannot create a namespace. No backend offers this today.
343                supervisor_pid_namespace: false,
344                // The agent runs as uid 0 and `setuid`s the command to uid 60000, so the command
345                // runs under a different identity than the process supervising it.
346                supervisor_isolation: true,
347            }),
348            Platform::Azure => Ok(Self::azure()),
349            Platform::Gcp => Ok(Self::gcp_agent_platform()),
350            // Preview needs a gateway that validates a sandbox-and-port capability, which this
351            // backend has none of.
352            Platform::Kubernetes => Ok(Self {
353                files: true,
354                reconnect: true,
355                jobs: true,
356                preview: false,
357                pause_resume: false,
358                snapshot: false,
359                domain_egress_rules: false,
360                egress_deny: true,
361                enforced_limits: true,
362                // A pid ceiling is a kubelet setting per node, not a pod field.
363                process_limit: false,
364                // `activeDeadlineSeconds` on the pod, which the kubelet enforces.
365                sandbox_lifetime: true,
366                // The pod drops every capability, including the `CAP_SYS_ADMIN` the agent would
367                // need to unshare. That is also what denies `ptrace`, so this stays false rather
368                // than the pod being weakened to make it true.
369                supervisor_pid_namespace: false,
370                // The pod pins one uid (`run_as_user: 65534` on both pod and container) with
371                // `capabilities.drop: [ALL]` and `allow_privilege_escalation: false`, so no
372                // process can setuid to split the command off from a supervisor. No uid split is
373                // possible, so none exists.
374                supervisor_isolation: false,
375            }),
376            Platform::Local => Ok(Self {
377                files: true,
378                reconnect: true,
379                // Nothing runs inside the sandbox: the manager drives Docker from outside it.
380                jobs: false,
381                preview: true,
382                pause_resume: false,
383                snapshot: false,
384                domain_egress_rules: false,
385                egress_deny: true,
386                enforced_limits: true,
387                // Docker's `--pids-limit`.
388                process_limit: true,
389                sandbox_lifetime: false,
390                // Local has no in-sandbox agent: the manager drives Docker from outside, so
391                // there is no supervisor inside the sandbox to isolate from.
392                supervisor_pid_namespace: false,
393                // The supervisor is the manager on the host, outside the container entirely, and
394                // `docker exec` runs the command as the workload uid — a different identity by
395                // construction.
396                supervisor_isolation: true,
397            }),
398            Platform::Machines | Platform::Test => {
399                Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
400                    platform: platform.to_string(),
401                }))
402            }
403        }
404    }
405
406    /// What the Azure sandbox backend supports; the body of the `Platform::Azure` arm.
407    pub fn azure() -> Self {
408        Self {
409            files: true,
410            reconnect: true,
411            // No Alien process runs inside the sandbox to own a command between two calls.
412            jobs: false,
413            // A sandbox port carries a URL and an auth config, and the auth config offers two
414            // things: anonymous, or Entra ID with an allowlist of human email addresses.
415            // Neither is a credential scoped to a port for a fixed time, which is what a
416            // preview capability is. Returning the anonymous URL would publish the port.
417            preview: false,
418            pause_resume: true,
419            // False for a client reason, not a cloud one: this client has no snapshot call, and
420            // `CreateSandboxRequest` has no field to consume the id it would return. Also
421            // unclaimed: Microsoft does not garbage-collect snapshots, so an id is a bill that grows.
422            snapshot: false,
423            domain_egress_rules: true,
424            egress_deny: true,
425            // Enforced inside the sandbox, not at create: an over-allocation raises `MemoryError`
426            // while the sandbox keeps running. `azure_sandbox_limits` checks the continuous
427            // sizing rule at plan time instead of matching a tier.
428            enforced_limits: true,
429            process_limit: false,
430            // Auto-suspend and auto-delete exist; a wall-clock ceiling does not. Accepting
431            // `maxLifetimeSeconds` here would be the silent no-op the capability set exists
432            // to prevent, so this is a decision rather than a gap.
433            sandbox_lifetime: false,
434            // No Alien process inside an Azure sandbox, so there is no supervisor to isolate.
435            supervisor_pid_namespace: false,
436            // No Alien process runs the command at all — the platform's own data plane does,
437            // so there is no separate supervisor identity to speak of.
438            supervisor_isolation: false,
439        }
440    }
441
442    /// What the GCP Agent Platform sandbox backend supports; the body of the `Platform::Gcp` arm.
443    pub fn gcp_agent_platform() -> Self {
444        Self {
445            // Agent file operations move over the sandbox envelope.
446            files: true,
447            // Reaching a sandbox across processes is safe because `generation` is derived from the
448            // container boot id read through the agent's health op, so a caller detects a container
449            // replaced under a stable sandbox name rather than reconnecting to a blank one.
450            reconnect: true,
451            jobs: true,
452            // No method mints a port-scoped ingress capability; the only ingress is `:execute`.
453            preview: false,
454            // `:resume` can return a fresh container while reporting success, so a pause does not
455            // keep the sandbox's state.
456            pause_resume: false,
457            // The create path never sends `sandbox_environment_snapshot`, so no sandbox state is
458            // reachable through the trait; declared false until the client carries it.
459            snapshot: false,
460            // Egress is shaped by VPC and DNS peering, which is not a hostname allowlist.
461            domain_egress_rules: false,
462            // A declared `deny` blocks both routed egress and DNS.
463            egress_deny: true,
464            // The declared ceilings are enforced, but by terminating the sandbox on breach rather
465            // than by refusing the allocation — a caller reading `true` should expect the sandbox
466            // to die, not a clean error at the point of the request.
467            enforced_limits: true,
468            // No ceiling on process count is observed.
469            process_limit: false,
470            // `ttl` maps to a sandbox `expireTime` the platform terminates at.
471            sandbox_lifetime: true,
472            // No PID-namespace isolation between the command and anything supervising it.
473            supervisor_pid_namespace: false,
474            // No separate supervisor identity: the command is not run under a different identity
475            // than the process supervising it.
476            supervisor_isolation: false,
477        }
478    }
479
480    /// Returns a typed error if the named capability is absent on this platform.
481    pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
482        let available = match capability {
483            SandboxCapability::Files => self.files,
484            SandboxCapability::Reconnect => self.reconnect,
485            SandboxCapability::Jobs => self.jobs,
486            SandboxCapability::Preview => self.preview,
487            SandboxCapability::PauseResume => self.pause_resume,
488            SandboxCapability::Snapshot => self.snapshot,
489            SandboxCapability::DomainEgressRules => self.domain_egress_rules,
490            SandboxCapability::EgressDeny => self.egress_deny,
491            SandboxCapability::EnforcedLimits => self.enforced_limits,
492            SandboxCapability::ProcessLimit => self.process_limit,
493            SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
494            SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
495            SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
496        };
497
498        if available {
499            return Ok(());
500        }
501
502        Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
503            capability: capability.as_str().to_string(),
504            platform: platform.to_string(),
505        }))
506    }
507}
508
509/// Names a single sandbox capability, so an unsupported call can report which one it needed.
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
511#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
512#[serde(rename_all = "camelCase")]
513pub enum SandboxCapability {
514    /// Moving files in and out of a sandbox
515    Files,
516    /// Reaching a sandbox created by an earlier call
517    Reconnect,
518    /// Starting, polling and cancelling a command across separate calls
519    Jobs,
520    /// An authenticated, port-scoped ingress capability
521    Preview,
522    /// Pausing and resuming sandbox state
523    PauseResume,
524    /// Capturing full sandbox state
525    Snapshot,
526    /// Restricting egress to a hostname allowlist
527    DomainEgressRules,
528    /// Refusing outbound access when a sandbox declares none
529    EgressDeny,
530    /// Platform-enforced resource ceilings
531    EnforcedLimits,
532    /// A ceiling on the number of processes a sandbox may run
533    ProcessLimit,
534    /// A wall-clock ceiling on a sandbox, applied by the platform rather than by a caller
535    SandboxLifetime,
536    /// A command runs in its own PID namespace, isolated from the agent supervising it
537    SupervisorPidNamespace,
538    /// A command runs under a different identity than the process supervising it
539    SupervisorIsolation,
540}
541
542impl SandboxCapability {
543    /// Returns the stable identifier used in errors and capability queries.
544    pub fn as_str(&self) -> &'static str {
545        match self {
546            Self::Files => "files",
547            Self::Reconnect => "reconnect",
548            Self::Jobs => "jobs",
549            Self::Preview => "preview",
550            Self::PauseResume => "pauseResume",
551            Self::Snapshot => "snapshot",
552            Self::DomainEgressRules => "domainEgressRules",
553            Self::EgressDeny => "egressDeny",
554            Self::EnforcedLimits => "enforcedLimits",
555            Self::ProcessLimit => "processLimit",
556            Self::SandboxLifetime => "sandboxLifetime",
557            Self::SupervisorPidNamespace => "supervisorPidNamespace",
558            Self::SupervisorIsolation => "supervisorIsolation",
559        }
560    }
561}
562
563/// Opt-in supervisor-owned network enforcement. Caller requests cannot change this identity.
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
566#[serde(rename_all = "camelCase", deny_unknown_fields)]
567pub struct SandboxPrivilegedSupervisor {
568    /// Nonzero numeric uid and primary gid for every command, including the image entrypoint.
569    pub command_uid: u32,
570}
571
572/// An isolated environment for running untrusted code, created at runtime.
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
574#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
575#[serde(rename_all = "camelCase", deny_unknown_fields)]
576#[builder(start_fn = new)]
577pub struct Sandbox {
578    /// Identifier for the sandbox. Must be DNS-compatible: lowercase letters, digits and hyphens,
579    /// starting and ending with a letter or digit, with no consecutive hyphens.
580    #[builder(start_fn)]
581    pub id: String,
582    /// Runtime network links authored by preflight, not parent lifecycle dependencies.
583    /// Sandbox has no public link builder; gateway reach is wired during planning.
584    #[builder(field)]
585    #[serde(default, skip_serializing_if = "Vec::is_empty")]
586    #[cfg_attr(feature = "openapi", schema(ignore))]
587    pub links: Vec<ResourceRef>,
588    /// Where the sandbox's root filesystem comes from
589    pub code: SandboxCode,
590    /// Private ECR base image an AWS build pulls; `code.image` names only the S3 bundle, so this is
591    /// what the cross-account grant opens. Live only: the grant needs the customer account,
592    /// which registration reports. Absent means the base image is pulled anonymously.
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub private_base_image: Option<String>,
595    /// Enforced resource ceilings.
596    ///
597    /// Optional because not every platform can enforce them, and a declaration that names none
598    /// takes the platform's own defaults. Naming them on a platform that cannot enforce them is
599    /// rejected at plan time rather than silently ignored.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub limits: Option<SandboxLimits>,
602    /// Outbound network policy
603    pub egress: SandboxEgress,
604    /// Default runtime policy for creates that omit one.
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub default_policy: Option<EgressPolicy>,
607    /// Have Alien's agent install the declared egress policy before running any image code.
608    /// Unsupported backends refuse this at plan time.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub privileged_supervisor: Option<SandboxPrivilegedSupervisor>,
611    /// Sandbox lifetime ceiling and idle behaviour.
612    ///
613    /// Stored state written before the rename calls this `session`. A stack state or release
614    /// that old must stay readable, otherwise its deployment can no longer be updated or deleted.
615    #[serde(alias = "session")]
616    pub lifecycle: SandboxLifecyclePolicy,
617    /// Ports eligible for a preview capability. An application reaches its sandbox through the
618    /// provider, so it cannot widen its own ingress at runtime; a holder of a remote binding's
619    /// credentials is bounded by no port condition, which is why a remote sandbox declares none.
620    #[builder(default)]
621    #[serde(default, skip_serializing_if = "Vec::is_empty")]
622    pub preview_ports: Vec<u16>,
623}
624
625/// Whether the artifact being rendered restricts which network modes it accepts.
626///
627/// Cloud setup needs explicit subnets for restricted sandbox connectors. Kubernetes targets do
628/// not emit these cloud backends.
629pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
630    !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
631}
632
633/// Whether any setup-owned resource forces setup to name subnets.
634///
635/// Restricted sandbox connectors require subnet IDs. Callers rendering an artifact want
636/// [`restricts_network_mode`] instead:
637/// this one answers for the declaration, which on a Kubernetes target is not what gets emitted.
638pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
639    stack.resources().any(|(_, entry)| {
640        entry
641            .config
642            .downcast_ref::<Sandbox>()
643            .is_some_and(|sandbox| crate::sandbox_egress::sandbox_needs_connector(stack, sandbox))
644    })
645}
646
647impl Sandbox {
648    pub(crate) fn setup_digest_value(&self) -> serde_json::Value {
649        match &self.egress {
650            SandboxEgress::None => serde_json::json!({"mode":"deny"}),
651            value => serde_json::to_value(value).expect("egress serializes"),
652        }
653    }
654
655    /// The resource type identifier for Sandbox
656    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
657
658    /// Returns the sandbox's unique identifier.
659    pub fn id(&self) -> &str {
660        &self.id
661    }
662
663    /// Released supervisor declarations retain their direct cloud route; only
664    /// the new Internet declaration uses the deployment gateway.
665    pub fn cloud_egress(&self) -> &SandboxEgress {
666        if self.privileged_supervisor.is_some() && self.egress != SandboxEgress::Internet {
667            &SandboxEgress::Allow
668        } else {
669            &self.egress
670        }
671    }
672
673    /// Startup contract for the privileged agent; never supplied by an exec caller.
674    pub fn supervisor_environment(&self) -> std::collections::BTreeMap<String, String> {
675        let mut env = std::collections::BTreeMap::new();
676        if let Some(supervisor) = &self.privileged_supervisor {
677            env.insert(
678                "ALIEN_SANDBOX_EXPECTED_EXEC_UID".to_string(),
679                supervisor.command_uid.to_string(),
680            );
681            env.insert(
682                "ALIEN_SANDBOX_EGRESS".to_string(),
683                // A runtime default is mutable gateway policy, never a startup allowlist.
684                if self.egress == SandboxEgress::None {
685                    r#"{"mode":"deny"}"#.to_string()
686                } else {
687                    serde_json::to_string(&self.egress).expect("egress serializes")
688                },
689            );
690        }
691        env
692    }
693
694    /// The declared ceilings, or the defaults a platform applies when none were named.
695    ///
696    /// Backends want a concrete set: a sandbox with no declared ceilings still runs inside
697    /// whatever the platform gives it, and a backend that had to branch on `None` would end up
698    /// inventing its own default anyway.
699    pub fn resolved_limits(&self) -> SandboxLimits {
700        self.limits.clone().unwrap_or_else(default_limits)
701    }
702
703    /// Validates the declaration against what the target platform can enforce.
704    ///
705    /// Runs at plan time so an unenforceable limit or an unsupported egress mode fails before
706    /// anything is provisioned, rather than at the first exec.
707    pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
708        if let Some(policy) = &self.default_policy {
709            if self.egress != SandboxEgress::Internet {
710                return Err(AlienError::new(ErrorData::SandboxEgressInvalid {
711                    target: "defaultPolicy".into(),
712                    reason: "defaultPolicy requires the new internet declaration".into(),
713                }));
714            }
715            policy.validate()?;
716        }
717        let mut capabilities = SandboxCapabilities::for_platform(platform)?;
718        if let Some(supervisor) = &self.privileged_supervisor {
719            if platform != Platform::Aws {
720                return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
721                    capability: "privilegedSupervisor".to_string(),
722                    platform: platform.to_string(),
723                }));
724            }
725            if supervisor.command_uid == 0 || supervisor.command_uid == u32::MAX {
726                return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
727                    resource_id: self.id.clone(),
728                    field: "privilegedSupervisor.commandUid".to_string(),
729                    value: supervisor.command_uid.to_string(),
730                    reason: "must be a non-root Linux uid other than the invalid uid sentinel"
731                        .to_string(),
732                }));
733            }
734            if let SandboxEgress::AllowDomains { domains } = &self.egress {
735                for domain in domains {
736                    let hostname = domain.strip_suffix('.').unwrap_or(domain);
737                    if hostname.len() > 253
738                        || !hostname.split('.').all(|label| {
739                            !label.is_empty()
740                                && label.len() <= 63
741                                && label
742                                    .bytes()
743                                    .all(|c| c.is_ascii_alphanumeric() || c == b'-')
744                                && !label.starts_with('-')
745                                && !label.ends_with('-')
746                        })
747                    {
748                        return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
749                            resource_id: self.id.clone(), field: "egress.domains".to_string(), value: domain.clone(),
750                            reason: "privileged supervisor allowlists require exact DNS hostnames; wildcards are unsupported".to_string(),
751                        }));
752                    }
753                }
754            }
755            capabilities.domain_egress_rules = true;
756        }
757
758        // `alien build` builds an AWS, Azure or GCP sandbox's base image, so source is a
759        // declaration there and the emitters refuse it only if it reaches them unbuilt. Everywhere
760        // else the image is pulled as declared, and an empty image string would schedule a pod
761        // that can never run.
762        if matches!(&self.code, SandboxCode::Source { .. })
763            && !matches!(platform, Platform::Aws | Platform::Azure | Platform::Gcp)
764        {
765            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
766                resource_id: self.id.clone(),
767                field: "code".to_string(),
768                value: "source".to_string(),
769                reason: format!(
770                    "no sandbox backend builds an image from source on {platform}; give \
771                     code.image a prebuilt reference"
772                ),
773            }));
774        }
775
776        // Elsewhere `code.image` is pulled directly, so a second reference would be a grant
777        // nothing reads.
778        if self.private_base_image.is_some() && platform != Platform::Aws {
779            return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
780                capability: "privateBaseImage".to_string(),
781                platform: platform.to_string(),
782            }));
783        }
784
785        // Read before the limits, because the image is declared whether or not any are. A source
786        // declaration has no image until `alien build` writes one.
787        if platform == Platform::Azure && matches!(&self.code, SandboxCode::Image { .. }) {
788            self.azure_image()?;
789        }
790
791        // Read before the limits, like the image, because a lifetime is declared whether or not
792        // any limits are. The ceiling is Lambda's, and it rejects the run rather than clamping, so
793        // a value outside it would pass planning, render into the package, and fail at the first
794        // sandbox. Kubernetes takes the same field with no such bound, which is why this sits
795        // under the AWS gate rather than on the type.
796        if platform == Platform::Aws {
797            if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
798                if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
799                    return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
800                        resource_id: self.id.clone(),
801                        field: "maxLifetimeSeconds".to_string(),
802                        value: seconds.to_string(),
803                        reason: format!(
804                            "AWS runs a MicroVM for between 1 and \
805                             {AWS_MAX_LIFETIME_SECONDS} seconds"
806                        ),
807                    }));
808                }
809            }
810        }
811
812        let Some(limits) = self.limits.as_ref() else {
813            // Nothing declared, so nothing to enforce and nothing to reject.
814            return self.validate_capabilities(&capabilities, platform);
815        };
816
817        validate_quantity(&self.id, "cpu", &limits.cpu)?;
818        validate_quantity(&self.id, "memory", &limits.memory)?;
819        validate_quantity(&self.id, "disk", &limits.disk)?;
820
821        if let Some(max_processes) = limits.max_processes {
822            if max_processes == 0 {
823                return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
824                    resource_id: self.id.clone(),
825                    field: "maxProcesses".to_string(),
826                    value: "0".to_string(),
827                    reason: "a sandbox that may run no processes cannot run code".to_string(),
828                }));
829            }
830            capabilities.require(SandboxCapability::ProcessLimit, platform)?;
831        }
832
833        // Declaring limits a platform ignores is worse than not declaring them: the stack reads
834        // as bounded while the sandbox is not.
835        capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
836
837        if platform == Platform::Azure {
838            self.azure_sandbox_limits()?;
839        }
840
841        if platform == Platform::Aws {
842            // Refused here rather than at emit so a customer sees it while planning, and so both
843            // package formats inherit the same answer.
844            self.microvm_tier()?;
845        }
846
847        self.validate_capabilities(&capabilities, platform)
848    }
849
850    /// What Azure creates this sandbox from: a catalog name or a registry image, told apart by
851    /// [`classify_azure_sandbox_image`]. Refused while planning, because a value the data plane
852    /// rejects would otherwise surface at the first sandbox, long after the apply.
853    pub fn azure_image(&self) -> Result<AzureSandboxImage<'_>> {
854        let SandboxCode::Image { image } = &self.code else {
855            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
856                resource_id: self.id.clone(),
857                field: "code".to_string(),
858                value: "source".to_string(),
859                reason: "the sandbox still has unbuilt source code. Run 'alien build' first"
860                    .to_string(),
861            }));
862        };
863
864        classify_azure_sandbox_image(image).ok_or_else(|| {
865            let image = image.trim();
866            AlienError::new(ErrorData::SandboxLimitInvalid {
867                resource_id: self.id.clone(),
868                field: "code.image".to_string(),
869                value: image.to_string(),
870                reason: if image.is_empty() {
871                    "a sandbox has to name an image".to_string()
872                } else {
873                    "Azure creates a sandbox from a catalog name such as 'ubuntu' or from a \
874                     registry image such as 'docker.io/library/python:3.14-slim'"
875                        .to_string()
876                },
877            })
878        })
879    }
880
881    /// Checks the declared ceilings against Azure's sizing rule (the `AZURE_*` constants above).
882    /// Refused at plan time, like [`Self::microvm_tier`], so a bad value is a declaration to fix
883    /// rather than a runtime fault at create.
884    pub fn azure_sandbox_limits(&self) -> Result<()> {
885        let Some(limits) = self.limits.as_ref() else {
886            // Nothing declared means the binding substitutes Alien's own default sizing, which is
887            // inside the rule — asserted where those constants live, since this cannot see them.
888            return Ok(());
889        };
890
891        let refused = |field: &str, value: &str, reason: &str| {
892            AlienError::new(ErrorData::SandboxLimitInvalid {
893                resource_id: self.id.clone(),
894                field: field.to_string(),
895                value: value.to_string(),
896                reason: reason.to_string(),
897            })
898        };
899
900        let cpu_millicores = millicores(&limits.cpu)
901            .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
902
903        // The multiple is checked, not just the range: `333m` sits inside 0.25–16 cores and is
904        // still refused on the wire, so a bounds-only check would pass a declaration that fails
905        // at create.
906        if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
907            || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
908        {
909            return Err(refused(
910                "cpu",
911                &limits.cpu,
912                "Azure allocates cpu in steps of 250m from 250m to 16000m",
913            ));
914        }
915
916        // Both ceilings are derived from the cpu, so they cannot be checked before it is known.
917        let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
918        let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
919
920        let memory_mib = quantity_mib(&limits.memory)
921            .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
922        if memory_mib > memory_ceiling_mib {
923            return Err(refused(
924                "memory",
925                &limits.memory,
926                &format!(
927                    "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
928                          at the declared cpu"
929                ),
930            ));
931        }
932
933        let disk_mib = quantity_mib(&limits.disk)
934            .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
935        if disk_mib > disk_ceiling_mib {
936            return Err(refused(
937                "disk",
938                &limits.disk,
939                &format!(
940                    "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
941                          the declared cpu"
942                ),
943            ));
944        }
945
946        Ok(())
947    }
948
949    /// The MicroVM size that keeps every declared ceiling, or why none does.
950    ///
951    /// AWS sizes are discrete and a running MicroVM bursts to four times its baseline, so the
952    /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier
953    /// satisfies is refused: shipping the nearest size would give the customer a sandbox that
954    /// exceeds the bound they wrote down.
955    pub fn microvm_tier(&self) -> Result<MicrovmTier> {
956        let Some(limits) = self.limits.as_ref() else {
957            // Nothing declared: AWS's own default baseline, which is also `default_limits`.
958            return Ok(MICROVM_TIERS[2]);
959        };
960
961        let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
962            AlienError::new(ErrorData::SandboxLimitInvalid {
963                resource_id: self.id.clone(),
964                field: "memory".to_string(),
965                value: limits.memory.clone(),
966                reason: "AWS sizes a MicroVM in whole MiB".to_string(),
967            })
968        })?;
969        let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
970            AlienError::new(ErrorData::SandboxLimitInvalid {
971                resource_id: self.id.clone(),
972                field: "disk".to_string(),
973                value: limits.disk.clone(),
974                reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
975            })
976        })?;
977        let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
978            AlienError::new(ErrorData::SandboxLimitInvalid {
979                resource_id: self.id.clone(),
980                field: "cpu".to_string(),
981                value: limits.cpu.clone(),
982                reason: "expected cores or millicores".to_string(),
983            })
984        })?;
985
986        // Memory and disk choose the size; cpu is then checked rather than used to choose.
987        // AWS couples cpu to memory at 2 GB per vCPU, so letting a low cpu ceiling select the
988        // size too would quietly hand back a machine four times smaller than the memory ceiling
989        // asked for, with nothing to indicate it.
990        let sized = |tier: &&MicrovmTier| {
991            tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
992        };
993
994        let tier = MICROVM_TIERS
995            .iter()
996            .rev()
997            .find(sized)
998            .copied()
999            .ok_or_else(|| {
1000                AlienError::new(ErrorData::SandboxLimitInvalid {
1001                    resource_id: self.id.clone(),
1002                    field: "memory".to_string(),
1003                    value: limits.memory.clone(),
1004                    reason: format!(
1005                        "a Lambda MicroVM bursts to four times its baseline, so the smallest \
1006                         ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
1007                         disk fit no size",
1008                        limits.memory, limits.disk
1009                    ),
1010                })
1011            })?;
1012
1013        let required_millicores = i64::from(tier.peak_vcpu) * 1000;
1014        if cpu_millicores < required_millicores {
1015            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1016                resource_id: self.id.clone(),
1017                field: "cpu".to_string(),
1018                value: limits.cpu.clone(),
1019                reason: format!(
1020                    "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
1021                     reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
1022                    limits.memory, tier.peak_vcpu, tier.peak_vcpu
1023                ),
1024            }));
1025        }
1026
1027        Ok(tier)
1028    }
1029
1030    /// The capability checks that do not depend on declared limits.
1031    fn validate_capabilities(
1032        &self,
1033        capabilities: &SandboxCapabilities,
1034        platform: Platform,
1035    ) -> Result<()> {
1036        if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
1037            capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
1038        }
1039
1040        // `allow` asks for no restriction, so a backend that ignores it fails loudly on the first
1041        // blocked connection. `deny` asks for one, and a backend that ignores it puts untrusted
1042        // code on the internet with nothing to notice — so only this direction is gated.
1043        // An empty list is not a restriction anyone wrote down: it renders as a deny-all wearing
1044        // an allowlist's label, which reads at a glance as the opposite of what it does.
1045        if let SandboxEgress::AllowDomains { domains } = &self.egress {
1046            if domains.is_empty() {
1047                return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
1048                    resource_id: self.id.clone(),
1049                    field: "egress.domains".to_string(),
1050                    value: "[]".to_string(),
1051                    reason: "an allowlist naming no domain denies everything; declare \
1052                             egress: deny if that is what was meant"
1053                        .to_string(),
1054                }));
1055            }
1056        }
1057
1058        if matches!(self.egress, SandboxEgress::None) {
1059            capabilities.require(SandboxCapability::EgressDeny, platform)?;
1060        }
1061
1062        if !self.preview_ports.is_empty() {
1063            capabilities.require(SandboxCapability::Preview, platform)?;
1064        }
1065
1066        if self.lifecycle.idle_pause_seconds.is_some() {
1067            capabilities.require(SandboxCapability::PauseResume, platform)?;
1068        }
1069
1070        if self.lifecycle.max_lifetime_seconds.is_some() {
1071            capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
1072        }
1073
1074        Ok(())
1075    }
1076}
1077
1078/// Ceilings applied when a declaration names none.
1079///
1080/// Modest on purpose: an undeclared sandbox is one whose author did not think about sizing, and
1081/// the safe reading of that is a small box rather than a generous one.
1082fn default_limits() -> SandboxLimits {
1083    SandboxLimits {
1084        cpu: "1".to_string(),
1085        memory: "2Gi".to_string(),
1086        disk: "8Gi".to_string(),
1087        max_processes: None,
1088    }
1089}
1090
1091/// Validates a Kubernetes-style resource quantity such as `500m`, `2Gi` or `1`.
1092fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
1093    let invalid = |reason: &str| {
1094        AlienError::new(ErrorData::SandboxLimitInvalid {
1095            resource_id: resource_id.to_string(),
1096            field: field.to_string(),
1097            value: value.to_string(),
1098            reason: reason.to_string(),
1099        })
1100    };
1101
1102    let digits_end = value
1103        .find(|c: char| !c.is_ascii_digit() && c != '.')
1104        .unwrap_or(value.len());
1105    let (number, suffix) = value.split_at(digits_end);
1106
1107    let parsed: f64 = number
1108        .parse()
1109        .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
1110
1111    if parsed <= 0.0 {
1112        return Err(invalid("must be greater than zero"));
1113    }
1114
1115    const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
1116    if !SUFFIXES.contains(&suffix) {
1117        return Err(invalid(
1118            "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
1119        ));
1120    }
1121
1122    Ok(())
1123}
1124
1125/// Splits a quantity into its number and unit suffix.
1126fn split_quantity(value: &str) -> Option<(f64, &str)> {
1127    let trimmed = value.trim();
1128    let digits_end = trimmed
1129        .find(|c: char| !c.is_ascii_digit() && c != '.')
1130        .unwrap_or(trimmed.len());
1131    let (number, suffix) = trimmed.split_at(digits_end);
1132    number.parse().ok().map(|number| (number, suffix))
1133}
1134
1135/// A memory or disk quantity in whole MiB, rounded down.
1136///
1137/// Every suffix `validate_quantity` accepts is handled here. Reading only `Gi` and `Mi` and
1138/// falling back for the rest would turn a declared `4G` into a different size than the customer
1139/// asked for, which for a ceiling means a sandbox larger than its bound.
1140pub fn quantity_mib(value: &str) -> Option<i64> {
1141    let (number, suffix) = split_quantity(value)?;
1142    let bytes = match suffix {
1143        "" => number,
1144        "k" => number * 1e3,
1145        "M" => number * 1e6,
1146        "G" => number * 1e9,
1147        "T" => number * 1e12,
1148        "Ki" => number * 1024.0,
1149        "Mi" => number * 1024.0 * 1024.0,
1150        "Gi" => number * 1024.0 * 1024.0 * 1024.0,
1151        "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
1152        // `m` is a millicore suffix; memory has no use for it.
1153        _ => return None,
1154    };
1155    Some((bytes / (1024.0 * 1024.0)) as i64)
1156}
1157
1158/// A CPU quantity in millicores.
1159pub fn millicores(value: &str) -> Option<i64> {
1160    let (number, suffix) = split_quantity(value)?;
1161    match suffix {
1162        "" => Some((number * 1000.0) as i64),
1163        "m" => Some(number as i64),
1164        _ => None,
1165    }
1166}
1167
1168/// Outputs generated by a successfully provisioned Sandbox parent.
1169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1171#[serde(rename_all = "camelCase")]
1172pub struct SandboxOutputs {
1173    /// Name of the durable parent that sandboxes are created inside
1174    pub parent_name: String,
1175    /// Platform-specific identifier for the parent (image ARN, sandbox group id, namespace)
1176    #[serde(skip_serializing_if = "Option::is_none")]
1177    pub identifier: Option<String>,
1178    /// Data-plane endpoint sandboxes are created through, where the platform has one
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    pub endpoint: Option<String>,
1181}
1182
1183impl ResourceOutputsDefinition for SandboxOutputs {
1184    fn get_resource_type(&self) -> ResourceType {
1185        Sandbox::RESOURCE_TYPE
1186    }
1187
1188    fn as_any(&self) -> &dyn Any {
1189        self
1190    }
1191
1192    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1193        Box::new(self.clone())
1194    }
1195
1196    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1197        other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1198    }
1199
1200    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1201        serde_json::to_value(self)
1202    }
1203}
1204
1205impl ResourceDefinition for Sandbox {
1206    fn get_resource_type(&self) -> ResourceType {
1207        Self::RESOURCE_TYPE
1208    }
1209
1210    fn id(&self) -> &str {
1211        &self.id
1212    }
1213
1214    fn replace_after_failed_create_is_safe(&self) -> bool {
1215        // A create adopts an existing image with the same name, and deleting it deletes that image,
1216        // which this deployment may never have built.
1217        false
1218    }
1219
1220    fn get_dependencies(&self) -> Vec<ResourceRef> {
1221        Vec::new()
1222    }
1223
1224    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1225        let new_sandbox = new_config
1226            .as_any()
1227            .downcast_ref::<Sandbox>()
1228            .ok_or_else(|| {
1229                AlienError::new(ErrorData::UnexpectedResourceType {
1230                    resource_id: self.id.clone(),
1231                    expected: Self::RESOURCE_TYPE,
1232                    actual: new_config.get_resource_type(),
1233                })
1234            })?;
1235
1236        if self.id != new_sandbox.id {
1237            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1238                resource_id: self.id.clone(),
1239                reason: "the 'id' field is immutable".to_string(),
1240            }));
1241        }
1242
1243        Ok(())
1244    }
1245
1246    fn as_any(&self) -> &dyn Any {
1247        self
1248    }
1249
1250    fn as_any_mut(&mut self) -> &mut dyn Any {
1251        self
1252    }
1253
1254    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1255        Box::new(self.clone())
1256    }
1257
1258    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1259        other.as_any().downcast_ref::<Sandbox>() == Some(self)
1260    }
1261
1262    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1263        serde_json::to_value(self)
1264    }
1265}
1266
1267/// What an Azure sandbox starts from.
1268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1269pub enum AzureSandboxImage<'a> {
1270    /// A public catalog disk image, such as `ubuntu`, which the data plane names directly.
1271    Catalog(&'a str),
1272    /// A registry image, which the controller builds into a disk image in the sandbox's group
1273    /// before any sandbox can start from it.
1274    Registry(&'a str),
1275}
1276
1277impl<'a> AzureSandboxImage<'a> {
1278    /// The declared value, trimmed.
1279    pub fn as_str(&self) -> &'a str {
1280        match self {
1281            Self::Catalog(value) | Self::Registry(value) => value,
1282        }
1283    }
1284}
1285
1286/// Label key the controller writes on every disk image it builds, and the provider finds it by.
1287pub const AZURE_DISK_IMAGE_LABEL: &str = "alienImage";
1288
1289/// Classifies a declared `code.image` for Azure, or `None` when it is neither kind. A bare
1290/// `[A-Za-z0-9._-]+` is checked first and always a catalog name; anything else must carry `/`,
1291/// `:` or `@` and parse as an OCI reference.
1292pub fn classify_azure_sandbox_image(image: &str) -> Option<AzureSandboxImage<'_>> {
1293    let image = image.trim();
1294    if image.is_empty() {
1295        return None;
1296    }
1297    if image
1298        .chars()
1299        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1300    {
1301        return Some(AzureSandboxImage::Catalog(image));
1302    }
1303    (image.contains(|c| matches!(c, '/' | ':' | '@')) && OCI_REFERENCE.is_match(image))
1304        .then_some(AzureSandboxImage::Registry(image))
1305}
1306
1307/// The label value naming the disk image built from `reference`: a digest, since a label value
1308/// may not carry a reference's `/`, `:` and `@`.
1309pub fn azure_disk_image_label(reference: &str) -> String {
1310    let digest = format!("{:x}", Sha256::digest(reference.trim().as_bytes()));
1311    digest[..32].to_string()
1312}
1313
1314/// The distribution reference grammar: `[host[:port]/]path[:tag][@digest]`.
1315static OCI_REFERENCE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1316    let domain_component = r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
1317    let domain = format!(r"{domain_component}(?:\.{domain_component})*(?::[0-9]+)?");
1318    let path_component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
1319    let name = format!(r"(?:{domain}/)?{path_component}(?:/{path_component})*");
1320    let tag = r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}";
1321    let digest = r"[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,}";
1322    regex::Regex::new(&format!(r"^{name}(?::{tag})?(?:@{digest})?$"))
1323        .expect("the OCI reference grammar compiles")
1324});
1325
1326/// The one token a sandbox bundle URI may carry, replaced with the deploying region.
1327///
1328/// AWS builds a MicroVM image only from a bucket in the image's own region, so a vendor
1329/// publishing to every supported region needs one stored URI that resolves per region.
1330pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1331
1332/// A bundle URI split around its region token, or carried whole when it has none.
1333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1334pub enum BundleUri<'a> {
1335    /// No token: emitted exactly as it is today.
1336    Literal(&'a str),
1337    /// The text either side of the token, for an emitter to rejoin around its own region
1338    /// expression.
1339    Regional { before: &'a str, after: &'a str },
1340}
1341
1342/// The prefix a rebuild's new key still sits under: everything above the file name and the
1343/// version segment beneath it. `None` when nothing sits there, meaning no prefix can be granted
1344/// without also granting objects a rebuild never reads. Shared so both emitters agree on it.
1345pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1346    let (above_file, _) = key.rsplit_once('/')?;
1347    let (above_version, _) = above_file.rsplit_once('/')?;
1348    Some(above_version)
1349}
1350
1351/// Reads a sandbox bundle URI, refusing anything an image build would only reject later.
1352///
1353/// The token is accepted in the bucket alone. A key-position token would name an object that does
1354/// not exist, and any other brace is a typo that would otherwise reach S3 verbatim and fail ~160s
1355/// into the build — which is the failure this whole check exists to move to plan time.
1356pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1357    let path = uri
1358        .strip_prefix("s3://")
1359        .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1360    let (bucket, key) = path
1361        .split_once('/')
1362        .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1363
1364    // Both emitters interpolate this path into the build role's resource ARN, where `*` and `?`
1365    // are IAM wildcards rather than literal characters. S3 accepts them in a key, so a bundle
1366    // published under one would silently widen the grant past the bundle it names.
1367    if path.contains('*') || path.contains('?') {
1368        return Err(format!(
1369            "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1370             role's grant, so '*' and '?' would widen it past the bundle"
1371        ));
1372    }
1373
1374    if key.contains('{') || key.contains('}') {
1375        return Err(format!(
1376            "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1377             bucket name alone"
1378        ));
1379    }
1380
1381    let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1382        if bucket.contains('{') || bucket.contains('}') {
1383            return Err(format!(
1384                "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1385                 only one"
1386            ));
1387        }
1388        return Ok(BundleUri::Literal(uri));
1389    };
1390
1391    if after.contains(BUNDLE_REGION_TOKEN) {
1392        return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1393    }
1394    if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1395        return Err(format!(
1396            "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1397        ));
1398    }
1399
1400    Ok(BundleUri::Regional {
1401        before: &uri[.."s3://".len() + before.len()],
1402        after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1403    })
1404}
1405
1406/// Where a private ECR image's region comes from.
1407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1408pub enum EcrImageRegion<'a> {
1409    /// A region named in the host.
1410    Literal(&'a str),
1411    /// [`BUNDLE_REGION_TOKEN`] in the host: the region the deployment renders.
1412    Deployment,
1413}
1414
1415/// The ECR repository a private image reference is pulled from.
1416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1417pub struct EcrImageRepository<'a> {
1418    pub account_id: &'a str,
1419    pub region: EcrImageRegion<'a>,
1420    /// The repository name, which may carry `/`; never a tag or digest.
1421    pub repository: &'a str,
1422}
1423
1424impl EcrImageRepository<'_> {
1425    /// The repository's ARN, with `region` standing in for a region the host leaves to the
1426    /// deployment. The partition is the deployment's: a GovCloud host ends `.amazonaws.com` too.
1427    pub fn arn(&self, partition: &str, region: &str) -> String {
1428        let region = match self.region {
1429            EcrImageRegion::Literal(region) => region,
1430            EcrImageRegion::Deployment => region,
1431        };
1432        format!(
1433            "arn:{partition}:ecr:{region}:{}:repository/{}",
1434            self.account_id, self.repository
1435        )
1436    }
1437}
1438
1439/// Reads `privateBaseImage` as the one repository a build role may pull from.
1440///
1441/// The name is interpolated into an IAM ARN, so it is held to ECR's own repository grammar: that
1442/// refuses `*` and `?`, which would widen the grant, and `$` and braces, which a CloudFormation
1443/// `Sub` or a Terraform template would read as an expression.
1444pub fn parse_ecr_image_repository(
1445    image: &str,
1446) -> std::result::Result<EcrImageRepository<'_>, String> {
1447    let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1448    let (host, path) = image
1449        .split_once('/')
1450        .ok_or_else(|| refuse("names no repository"))?;
1451    let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1452        refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1453    })?;
1454    // `.com.cn` first: a China host ends with the shorter suffix too.
1455    let region = rest
1456        .strip_suffix(".amazonaws.com.cn")
1457        .or_else(|| rest.strip_suffix(".amazonaws.com"))
1458        .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1459    if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1460        return Err(refuse("names no 12-digit account in its registry host"));
1461    }
1462    let region = if region == BUNDLE_REGION_TOKEN {
1463        EcrImageRegion::Deployment
1464    } else if !region.is_empty()
1465        && region
1466            .bytes()
1467            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1468    {
1469        EcrImageRegion::Literal(region)
1470    } else {
1471        return Err(refuse(&format!(
1472            "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1473        )));
1474    };
1475
1476    let repository = match path.split_once('@') {
1477        Some((repository, _digest)) => repository,
1478        None => match path.rsplit_once('/') {
1479            Some((parent, last)) => match last.split_once(':') {
1480                Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1481                None => path,
1482            },
1483            None => path.split_once(':').map_or(path, |(name, _tag)| name),
1484        },
1485    };
1486    let valid_segment = |segment: &str| {
1487        !segment.is_empty()
1488            && segment.bytes().all(|b| {
1489                b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1490            })
1491    };
1492    if !repository.split('/').all(valid_segment) {
1493        return Err(refuse(
1494            "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1495        ));
1496    }
1497    Ok(EcrImageRepository {
1498        account_id,
1499        region,
1500        repository,
1501    })
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506    use super::*;
1507
1508    #[test]
1509    fn private_database_setup_accepts_the_default_network() {
1510        for lifecycle in [
1511            crate::ResourceLifecycle::Frozen,
1512            crate::ResourceLifecycle::Live,
1513        ] {
1514            let stack = crate::Stack::new("database".to_string())
1515                .add(
1516                    crate::Postgres::new("metadata".to_string()).build(),
1517                    lifecycle,
1518                )
1519                .build();
1520            assert!(!restricts_network_mode(&stack, false));
1521            assert!(!restricts_network_mode(&stack, true));
1522        }
1523        assert!(!restricts_network_mode(
1524            &crate::Stack::new("empty".to_string()).build(),
1525            false,
1526        ));
1527    }
1528
1529    /// The vectors' `repositoryKey`: registry host and repository, never the tag or digest, so a
1530    /// new tag keeps the key. A reference the parser refuses stays whole.
1531    fn repository_key(image: &str) -> String {
1532        match parse_ecr_image_repository(image) {
1533            Ok(parsed) => {
1534                let host = image.split_once('/').map_or(image, |(host, _)| host);
1535                format!("{host}/{}", parsed.repository)
1536            }
1537            Err(_) => image.to_string(),
1538        }
1539    }
1540
1541    #[test]
1542    fn a_private_base_image_names_one_repository() {
1543        let vectors: serde_json::Value = serde_json::from_str(include_str!(
1544            "../../tests/fixtures/ecr-image-repository-parity.json"
1545        ))
1546        .expect("the ECR repository vectors must be JSON");
1547        let field = |case: &serde_json::Value, name: &str| {
1548            case[name]
1549                .as_str()
1550                .unwrap_or_else(|| panic!("vector must carry {name}: {case}"))
1551                .to_string()
1552        };
1553
1554        for case in vectors["accepted"].as_array().expect("accepted vectors") {
1555            let image = field(case, "image");
1556            let region = field(case, "region");
1557            let region = if region == BUNDLE_REGION_TOKEN {
1558                EcrImageRegion::Deployment
1559            } else {
1560                EcrImageRegion::Literal(&region)
1561            };
1562            assert_eq!(
1563                parse_ecr_image_repository(&image),
1564                Ok(EcrImageRepository {
1565                    account_id: &field(case, "accountId"),
1566                    region,
1567                    repository: &field(case, "repository"),
1568                }),
1569                "{image}"
1570            );
1571            assert_eq!(
1572                repository_key(&image),
1573                field(case, "repositoryKey"),
1574                "{image}"
1575            );
1576        }
1577
1578        for case in vectors["refused"].as_array().expect("refused vectors") {
1579            let image = field(case, "image");
1580            assert!(
1581                parse_ecr_image_repository(&image).is_err(),
1582                "{image} must be refused"
1583            );
1584            assert_eq!(
1585                repository_key(&image),
1586                field(case, "repositoryKey"),
1587                "{image}"
1588            );
1589        }
1590    }
1591
1592    #[test]
1593    fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1594        let regional =
1595            parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1596                .expect("parses");
1597        let pinned =
1598            parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1599                .expect("parses");
1600        assert_eq!(
1601            regional.arn("aws-us-gov", "us-gov-west-1"),
1602            "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1603        );
1604        assert_eq!(
1605            pinned.arn("aws", "us-east-1"),
1606            "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1607        );
1608    }
1609
1610    /// A wildcard reaching the grant would widen it past the bundle, and it widens the Frozen
1611    /// object grant as readily as the Live prefix — both interpolate the path into the ARN.
1612    #[test]
1613    fn a_uri_carrying_an_iam_wildcard_is_refused() {
1614        for uri in [
1615            "s3://acme/team-*/v1/bundle.zip",
1616            "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1617            "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1618        ] {
1619            let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1620            assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1621        }
1622
1623        parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1624            .expect("an ordinary key still parses");
1625    }
1626
1627    /// The rule both package formats grant by, pinned here rather than in either. The
1628    /// near-misses the cases separate: the key's first segment grants objects a rebuild never
1629    /// reads, and the object's own directory pins the version segment that moves.
1630    #[test]
1631    fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1632        assert_eq!(
1633            stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1634            Some("sandbox-bundle")
1635        );
1636        assert_eq!(
1637            stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1638            Some("artifacts/team-a/sandbox"),
1639            "a deeper key narrows the prefix, it never widens to the first segment"
1640        );
1641
1642        // Nothing sits above the version segment, so no prefix a moved bundle stays inside
1643        // exists. Emitting the object grant instead installs a role that denies the next rebuild.
1644        assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1645        assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1646    }
1647
1648    fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1649        Sandbox::new("agent-sbx".to_string())
1650            .code(SandboxCode::Image {
1651                image: "ubuntu".to_string(),
1652            })
1653            .limits(SandboxLimits {
1654                cpu: "1".to_string(),
1655                memory: "2Gi".to_string(),
1656                disk: "20Gi".to_string(),
1657                max_processes: None,
1658            })
1659            .egress(egress)
1660            .lifecycle(SandboxLifecyclePolicy {
1661                max_lifetime_seconds: None,
1662                idle_pause_seconds: None,
1663            })
1664            .preview_ports(preview_ports)
1665            .build()
1666    }
1667
1668    /// A URI with no token must come back whole, because every bundle configured today has none
1669    /// and emitting one differently would change every existing customer's template.
1670    #[test]
1671    fn a_uri_without_a_token_is_carried_whole() {
1672        assert_eq!(
1673            parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1674            Ok(BundleUri::Literal(
1675                "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1676            ))
1677        );
1678    }
1679
1680    /// The split has to rejoin to the original with the region in place, or an emitter builds a
1681    /// URI that is subtly not the one the vendor configured.
1682    #[test]
1683    fn a_regional_uri_splits_either_side_of_the_token() {
1684        let BundleUri::Regional { before, after } =
1685            parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1686                .expect("the token is accepted in the bucket")
1687        else {
1688            panic!("a bucket-position token must split");
1689        };
1690
1691        assert_eq!(before, "s3://acme-artifacts-");
1692        assert_eq!(after, "/agents/bundle.zip");
1693        assert_eq!(
1694            format!("{before}us-east-2{after}"),
1695            "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1696            "the halves must rejoin to the URI the vendor meant"
1697        );
1698    }
1699
1700    /// Each of these reaches S3 verbatim and dies ~160s into an image build if it is not refused
1701    /// here, which is the whole reason this runs at plan time.
1702    #[test]
1703    fn a_token_this_build_cannot_resolve_is_refused() {
1704        for uri in [
1705            "s3://acme-artifacts-{regio}/bundle.zip",
1706            "s3://acme-artifacts/{region}/bundle.zip",
1707            "s3://acme-artifacts-{region}-{region}/bundle.zip",
1708            "s3://acme-artifacts/bundle-{version}.zip",
1709            "s3://acme}-artifacts-{region}/bundle.zip",
1710            "s3://acme{-artifacts-{region}/bundle.zip",
1711        ] {
1712            assert!(
1713                parse_bundle_uri(uri).is_err(),
1714                "'{uri}' must be refused before it can reach an image build"
1715            );
1716        }
1717    }
1718
1719    #[test]
1720    fn resource_type_is_stable() {
1721        assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1722    }
1723
1724    #[test]
1725    fn capability_sets_are_per_platform() {
1726        let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1727        assert!(
1728            gcp.reconnect,
1729            "generation from the container boot id makes a sandbox reachable across processes"
1730        );
1731        assert!(!gcp.preview);
1732        assert!(gcp.enforced_limits);
1733
1734        let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1735        assert!(azure.files, "every backend moves files");
1736        assert!(gcp.files);
1737        // Azure is the only backend whose egress policy matches on host pattern, and the only
1738        // one where `deny` and a hostname list are the same object.
1739        assert!(azure.domain_egress_rules);
1740        assert!(azure.egress_deny);
1741        // The data plane honours a continuous cpu/memory/disk surface and refuses anything
1742        // outside `n×250m`, with the rule in the message.
1743        assert!(azure.enforced_limits);
1744        assert!(azure.pause_resume);
1745        // Both stay false for reasons that are not "unbuilt": a snapshot id has nothing to
1746        // consume it on any backend, and an Azure port's auth is anonymous or a human allowlist,
1747        // neither of which is a port-scoped credential.
1748        assert!(!azure.snapshot);
1749        assert!(!azure.preview);
1750
1751        let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1752        assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1753        assert!(aws.pause_resume);
1754
1755        let k8s =
1756            SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1757        assert!(
1758            !k8s.preview,
1759            "the sandbox-scoped ingress gateway does not exist yet"
1760        );
1761    }
1762
1763    /// Whether the process supervising a command is a separate identity from the command.
1764    ///
1765    /// Values are measured, not inferred. AWS: the agent runs as uid 0 with
1766    /// `CapEff: 00000000a80425fb` and `setuid`s the command to uid 60000, so the two differ.
1767    /// Kubernetes: the sandbox pod pins `run_as_user: 65534` on both pod and container with
1768    /// `capabilities.drop: [ALL]` and `allow_privilege_escalation: false`, so no uid split is
1769    /// possible (`kubernetes_spec.rs`). Local: `docker exec` runs as the workload uid while the
1770    /// manager supervises from the host. Azure and Agent Platform have no in-sandbox supervisor.
1771    #[test]
1772    fn supervisor_isolation_is_per_platform() {
1773        let value = |platform| {
1774            SandboxCapabilities::for_platform(platform)
1775                .expect("supported")
1776                .supervisor_isolation
1777        };
1778
1779        assert!(
1780            value(Platform::Aws),
1781            "root agent setuids the command to 60000"
1782        );
1783        assert!(
1784            value(Platform::Local),
1785            "the supervisor is on the host, outside the container"
1786        );
1787        assert!(
1788            !value(Platform::Kubernetes),
1789            "a single pinned uid cannot be split"
1790        );
1791        assert!(!value(Platform::Azure), "no Alien process runs the command");
1792        assert!(
1793            !value(Platform::Gcp),
1794            "no separate supervisor identity runs the command"
1795        );
1796    }
1797
1798    /// The point of the field: AWS and GCP report the *same* `supervisor_pid_namespace` (neither
1799    /// has `CAP_SYS_ADMIN`), so that axis alone reads them as equivalent. They are not — AWS
1800    /// separates the command's identity from the supervisor's and Agent Platform does not.
1801    #[test]
1802    fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1803        let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1804        let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1805
1806        assert_eq!(
1807            aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1808            "the older axis cannot tell them apart"
1809        );
1810        assert!(
1811            aws.supervisor_isolation,
1812            "AWS setuids the command off the supervisor"
1813        );
1814        assert!(
1815            !gcp.supervisor_isolation,
1816            "the command runs under no separate supervisor identity"
1817        );
1818    }
1819
1820    /// The Agent Platform row, each value against the behaviour it was measured from. `reconnect`
1821    /// is the tripwire: it is `true` only because `generation` is derived from the container boot
1822    /// id read through the agent's health op, so a caller detects a replaced container instead of
1823    /// reconnecting to a blank one. It is also the body of the `Platform::Gcp` arm, asserted below.
1824    #[test]
1825    fn gcp_agent_platform_row_matches_measured_backend() {
1826        let row = SandboxCapabilities::gcp_agent_platform();
1827
1828        assert!(row.files, "agent file ops move over the sandbox envelope");
1829        assert!(
1830            row.reconnect,
1831            "generation is derived from the container boot id, so a sandbox is reachable across \
1832             processes"
1833        );
1834        assert!(
1835            !row.preview,
1836            "the only ingress is :execute; no port-scoped capability"
1837        );
1838        assert!(
1839            !row.pause_resume,
1840            ":resume can return a fresh container, so a pause keeps no state"
1841        );
1842        assert!(
1843            !row.snapshot,
1844            "the create path never sends a snapshot, so none is reachable through the trait"
1845        );
1846        assert!(
1847            !row.domain_egress_rules,
1848            "VPC and DNS peering is not a hostname allowlist"
1849        );
1850        assert!(
1851            row.egress_deny,
1852            "a declared deny blocks both egress and DNS"
1853        );
1854        assert!(
1855            row.enforced_limits,
1856            "ceilings are enforced, by terminating the sandbox on breach"
1857        );
1858        assert!(!row.process_limit, "no process-count ceiling is observed");
1859        assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1860        assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1861        assert!(
1862            !row.supervisor_isolation,
1863            "the command is not run under a separate supervisor identity"
1864        );
1865
1866        // Agent Platform is the registered GCP backend, so the arm returns exactly this row.
1867        let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1868        assert_eq!(
1869            live, row,
1870            "the Platform::Gcp arm is the Agent Platform capability row"
1871        );
1872    }
1873
1874    #[test]
1875    fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1876        let error = SandboxCapabilities::for_platform(Platform::Machines)
1877            .expect_err("Machines has no sandbox backend");
1878        assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1879    }
1880
1881    #[test]
1882    fn unsupported_capability_names_platform_and_capability() {
1883        let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1884        let error = capabilities
1885            .require(SandboxCapability::Preview, Platform::Gcp)
1886            .expect_err("GCP has no preview");
1887
1888        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1889        let rendered = error.to_string();
1890        assert!(
1891            rendered.contains("preview"),
1892            "names the capability: {rendered}"
1893        );
1894        assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1895    }
1896
1897    /// Azure matches on hostname; AWS and Kubernetes match CIDRs, and Local and GCP have a
1898    /// switch rather than a filter. Accepting a hostname list on those four would leave a stack
1899    /// reading as restricted while the sandbox reaches the whole internet.
1900    #[test]
1901    fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1902        let sandbox = sandbox_with(
1903            SandboxEgress::AllowDomains {
1904                domains: vec!["example.com".to_string()],
1905            },
1906            vec![],
1907        );
1908
1909        for platform in [
1910            Platform::Aws,
1911            Platform::Gcp,
1912            Platform::Kubernetes,
1913            Platform::Local,
1914        ] {
1915            let error = sandbox
1916                .validate_for_platform(platform)
1917                .expect_err("only Azure expresses a hostname allowlist");
1918            assert_eq!(
1919                error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1920                "on {platform:?}"
1921            );
1922        }
1923
1924        assert!(
1925            SandboxCapabilities::for_platform(Platform::Azure)
1926                .expect("supported")
1927                .domain_egress_rules,
1928            "Azure's egress policy matches on host pattern"
1929        );
1930    }
1931
1932    /// `deny` is the declaration that carries a security promise, so a backend that cannot keep
1933    /// it has to refuse rather than accept it and run the code with open egress.
1934    #[test]
1935    fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1936        let sandbox = sandbox_with(SandboxEgress::None, vec![]);
1937
1938        // GCP is asserted at the capability rather than through validation: this sandbox declares
1939        // ceilings GCP cannot enforce, so it is refused for a reason unrelated to egress.
1940        assert!(
1941            SandboxCapabilities::for_platform(Platform::Gcp)
1942                .expect("supported")
1943                .egress_deny
1944        );
1945
1946        for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1947            sandbox
1948                .validate_for_platform(platform)
1949                .expect("deny is enforced here");
1950        }
1951
1952        // Declares no ceilings, which Azure refuses for its own reason, so this isolates egress.
1953        let egress_only = Sandbox::new("sbx".to_string())
1954            .code(SandboxCode::Image {
1955                image: "alpine".to_string(),
1956            })
1957            .egress(SandboxEgress::None)
1958            .lifecycle(SandboxLifecyclePolicy {
1959                max_lifetime_seconds: None,
1960                idle_pause_seconds: None,
1961            })
1962            .build();
1963
1964        egress_only
1965            .validate_for_platform(Platform::Azure)
1966            .expect("Azure creates the sandbox under a Deny policy with full inspection");
1967    }
1968
1969    /// A sandbox naming no ceilings is valid on every platform and still resolves to a concrete
1970    /// set. The rule Azure applies to ceilings that *are* declared is pinned separately, by
1971    /// `azure_sizes_follow_the_rule_the_data_plane_states`.
1972    #[test]
1973    fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1974        let undeclared = Sandbox::new("sbx".to_string())
1975            .code(SandboxCode::Image {
1976                image: "alpine".to_string(),
1977            })
1978            .egress(SandboxEgress::None)
1979            .lifecycle(SandboxLifecyclePolicy {
1980                max_lifetime_seconds: None,
1981                idle_pause_seconds: None,
1982            })
1983            .build();
1984
1985        undeclared
1986            .validate_for_platform(Platform::Azure)
1987            .expect("a sandbox naming no ceilings takes the platform's own");
1988
1989        // A backend still gets a concrete set, so nothing downstream has to invent one.
1990        assert_eq!(undeclared.resolved_limits().cpu, "1");
1991    }
1992
1993    /// Pins Azure's own sizing rule: `250m`, `1500m` and `4000m` are valid, `32000m` and `333m`
1994    /// are refused. Checked at plan time so a bad value is a declaration to fix, not a package
1995    /// that renders without error and dies at the first sandbox.
1996    #[test]
1997    fn azure_sizes_follow_the_rule_the_data_plane_states() {
1998        let sized = |cpu: &str, memory: &str, disk: &str| {
1999            let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2000            let limits = sandbox
2001                .limits
2002                .as_mut()
2003                .expect("the fixture declares limits");
2004            limits.cpu = cpu.to_string();
2005            limits.memory = memory.to_string();
2006            limits.disk = disk.to_string();
2007            sandbox.validate_for_platform(Platform::Azure)
2008        };
2009
2010        sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
2011        sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
2012        sized("16000m", "32Gi", "320Gi").expect("the top of the range");
2013
2014        // Same off-step case `azure_sandbox_limits` checks the multiple for, not just the range.
2015        let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
2016        assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
2017        assert!(off_step.to_string().contains("cpu"), "{off_step}");
2018
2019        let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
2020        assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
2021
2022        // Both ceilings are derived from the cpu, so the same memory passes at one size and fails
2023        // at another - which is what makes them worth checking rather than bounding absolutely.
2024        sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
2025        let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
2026        assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
2027        assert!(over_memory.to_string().contains("memory"), "{over_memory}");
2028
2029        let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
2030        assert!(over_disk.to_string().contains("disk"), "{over_disk}");
2031    }
2032
2033    #[test]
2034    fn preview_ports_require_the_preview_capability() {
2035        let sandbox = sandbox_with(SandboxEgress::None, vec![8080]);
2036
2037        sandbox
2038            .validate_for_platform(Platform::Aws)
2039            .expect("AWS mints a port-scoped JWE");
2040
2041        let error = sandbox
2042            .validate_for_platform(Platform::Kubernetes)
2043            .expect_err("Kubernetes preview is deferred");
2044        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2045    }
2046
2047    /// A grant nothing reads is the silent no-op the capability contract exists to prevent, and
2048    /// here it is worse than useless: the reader would take it for a base image that needs
2049    /// authenticating while the platform pulls `code.image` itself.
2050    #[test]
2051    fn a_private_base_image_is_refused_off_aws() {
2052        let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2053        sandbox.code = SandboxCode::Image {
2054            image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
2055        };
2056        sandbox.private_base_image =
2057            Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
2058
2059        sandbox
2060            .validate_for_platform(Platform::Aws)
2061            .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
2062
2063        for platform in [
2064            Platform::Gcp,
2065            Platform::Azure,
2066            Platform::Kubernetes,
2067            Platform::Local,
2068        ] {
2069            let error = sandbox
2070                .validate_for_platform(platform)
2071                .expect_err("a backend that builds no image must refuse a base image for one");
2072            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2073            assert!(
2074                error.to_string().contains("privateBaseImage"),
2075                "the refusal must name the field the user declared: {error}"
2076            );
2077        }
2078    }
2079
2080    #[test]
2081    fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
2082        let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
2083        sandbox
2084            .validate_for_platform(Platform::Gcp)
2085            .expect("Agent Platform enforces declared ceilings, by terminating on breach");
2086    }
2087
2088    #[test]
2089    fn invalid_quantities_are_rejected_with_the_offending_field() {
2090        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2091        sandbox
2092            .limits
2093            .as_mut()
2094            .expect("the fixture declares limits")
2095            .memory = "2Gb".to_string();
2096
2097        let error = sandbox
2098            .validate_for_platform(Platform::Aws)
2099            .expect_err("Gb is not a valid suffix");
2100        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2101        assert!(error.to_string().contains("memory"));
2102
2103        sandbox
2104            .limits
2105            .as_mut()
2106            .expect("the fixture declares limits")
2107            .memory = "2Gi".to_string();
2108        sandbox
2109            .limits
2110            .as_mut()
2111            .expect("the fixture declares limits")
2112            .cpu = "0".to_string();
2113        let error = sandbox
2114            .validate_for_platform(Platform::Aws)
2115            .expect_err("zero cpu is not a ceiling");
2116        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2117    }
2118
2119    #[test]
2120    fn zero_max_processes_is_rejected() {
2121        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2122        sandbox
2123            .limits
2124            .as_mut()
2125            .expect("the fixture declares limits")
2126            .max_processes = Some(0);
2127
2128        let error = sandbox
2129            .validate_for_platform(Platform::Local)
2130            .expect_err("a sandbox must be able to run at least one process");
2131        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2132        assert!(error.to_string().contains("maxProcesses"));
2133    }
2134
2135    /// A process ceiling needs a container runtime. Kubernetes sets one per node rather than per
2136    /// pod, and neither MicroVMs nor Azure sandboxes expose one, so accepting the declaration
2137    /// anywhere else would mean carrying a bound nothing applies.
2138    #[test]
2139    fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
2140        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2141        sandbox
2142            .limits
2143            .as_mut()
2144            .expect("the fixture declares limits")
2145            .max_processes = Some(256);
2146
2147        sandbox
2148            .validate_for_platform(Platform::Local)
2149            .expect("Docker takes a pids limit");
2150
2151        for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
2152            let error = sandbox
2153                .validate_for_platform(platform)
2154                .expect_err("a process ceiling nothing applies must be refused");
2155            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2156        }
2157    }
2158
2159    /// Lambda rejects a run outside 1–28,800 rather than clamping it, so a value beyond that
2160    /// would pass planning, render into the package, and fail at the first sandbox. Kubernetes
2161    /// takes the same field with no such bound, so the check is AWS's alone. The lifetime is a
2162    /// lifecycle field, so a sandbox that declares no limits is held to it too.
2163    #[test]
2164    fn a_lifetime_aws_would_reject_is_refused_while_planning() {
2165        let with_limits = sandbox_with(SandboxEgress::None, vec![]);
2166        let mut without_limits = with_limits.clone();
2167        without_limits.limits = None;
2168
2169        for (declared, mut sandbox) in [("with", with_limits), ("without", without_limits)] {
2170            for seconds in [0, 28_801, 30_000, 100_000] {
2171                sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
2172                let error = sandbox
2173                    .validate_for_platform(Platform::Aws)
2174                    .expect_err("a lifetime outside what AWS runs is refused");
2175                assert_eq!(
2176                    error.code, "SANDBOX_LIMIT_INVALID",
2177                    "{seconds}s {declared} limits"
2178                );
2179
2180                // Kubernetes has no such ceiling, so the same declaration is fine there.
2181                sandbox
2182                    .validate_for_platform(Platform::Kubernetes)
2183                    .expect("the kubelet takes any activeDeadlineSeconds");
2184            }
2185
2186            sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
2187            sandbox
2188                .validate_for_platform(Platform::Aws)
2189                .expect("the ceiling itself is allowed");
2190        }
2191    }
2192
2193    /// An image Azure can take neither as a catalog name nor as a registry image is refused while
2194    /// planning, not at the first sandbox.
2195    #[test]
2196    fn an_image_azure_cannot_pull_is_refused_while_planning() {
2197        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2198        // Azure enforces no declared ceiling, so a sandbox carrying limits is refused before the
2199        // image is ever read.
2200        sandbox.limits = None;
2201
2202        // A digest too short to be one, blanks, a space and a query string.
2203        for image in ["ubuntu@sha256:abc", "", "   ", "ubuntu latest", "ubuntu?x"] {
2204            sandbox.code = SandboxCode::Image {
2205                image: image.to_string(),
2206            };
2207            let error = sandbox
2208                .validate_for_platform(Platform::Azure)
2209                .expect_err("an image Azure has nowhere to put is refused");
2210            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
2211        }
2212
2213        for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
2214            sandbox.code = SandboxCode::Image {
2215                image: image.to_string(),
2216            };
2217            sandbox
2218                .validate_for_platform(Platform::Azure)
2219                .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
2220        }
2221
2222        for image in [
2223            "ubuntu:24.04",
2224            "ghcr.io/myorg/sandbox:latest",
2225            "docker.io/library/python:3.14-slim",
2226            "localhost:5000/team/agent@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d",
2227        ] {
2228            sandbox.code = SandboxCode::Image {
2229                image: image.to_string(),
2230            };
2231            sandbox
2232                .validate_for_platform(Platform::Azure)
2233                .unwrap_or_else(|error| panic!("'{image}' is a registry image: {error}"));
2234        }
2235
2236        // Surrounding space is trimmed rather than carried into the create body.
2237        sandbox.code = SandboxCode::Image {
2238            image: " ubuntu ".to_string(),
2239        };
2240        assert_eq!(
2241            sandbox
2242                .azure_image()
2243                .expect("a padded name is still a name"),
2244            AzureSandboxImage::Catalog("ubuntu")
2245        );
2246    }
2247
2248    /// Every value the catalog allowlist `[A-Za-z0-9._-]+` accepts stays a catalog name, so a
2249    /// declaration that planned under it keeps its meaning. Exhaustive to three characters, then a
2250    /// fixed-seed sample of longer ones, some with surrounding space.
2251    #[test]
2252    fn every_value_the_catalog_allowlist_accepted_is_still_a_catalog_name() {
2253        const ALPHABET: &[u8] =
2254            b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
2255
2256        let assert_catalog = |value: &str| {
2257            assert_eq!(
2258                classify_azure_sandbox_image(value),
2259                Some(AzureSandboxImage::Catalog(value.trim())),
2260                "'{value}'"
2261            );
2262        };
2263
2264        for a in ALPHABET {
2265            assert_catalog(&String::from_utf8(vec![*a]).unwrap());
2266            for b in ALPHABET {
2267                assert_catalog(&String::from_utf8(vec![*a, *b]).unwrap());
2268                for c in ALPHABET {
2269                    assert_catalog(&String::from_utf8(vec![*a, *b, *c]).unwrap());
2270                }
2271            }
2272        }
2273
2274        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2275        let mut next = || {
2276            state ^= state << 13;
2277            state ^= state >> 7;
2278            state ^= state << 17;
2279            state
2280        };
2281        for _ in 0..20_000 {
2282            let len = 4 + (next() % 60) as usize;
2283            let mut value: String = (0..len)
2284                .map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize] as char)
2285                .collect();
2286            if next() % 4 == 0 {
2287                value = format!("  {value}\t");
2288            }
2289            assert_catalog(&value);
2290        }
2291    }
2292
2293    /// The label is how the provider finds the image the controller built, so both must derive
2294    /// the same one; it also has to fit a label value, which a raw reference does not.
2295    #[test]
2296    fn the_disk_image_label_is_stable_and_label_safe() {
2297        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
2298        assert_eq!(
2299            label,
2300            azure_disk_image_label(" docker.io/library/python:3.14-slim ")
2301        );
2302        assert_ne!(
2303            label,
2304            azure_disk_image_label("docker.io/library/python:3.13-slim")
2305        );
2306        assert_eq!(label.len(), 32);
2307        assert!(label.chars().all(|c| c.is_ascii_hexdigit()), "{label}");
2308    }
2309
2310    /// A deadline is accepted only where the platform itself terminates on it — the kubelet's
2311    /// `activeDeadlineSeconds` and Lambda's `maximumDurationInSeconds`. Everywhere else it would
2312    /// need a reaper that does not exist, so it is refused rather than accepted and dropped.
2313    #[test]
2314    fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2315        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2316        sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2317
2318        sandbox
2319            .validate_for_platform(Platform::Kubernetes)
2320            .expect("the kubelet enforces activeDeadlineSeconds");
2321        sandbox
2322            .validate_for_platform(Platform::Aws)
2323            .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2324
2325        for platform in [Platform::Azure, Platform::Local] {
2326            let error = sandbox
2327                .validate_for_platform(platform)
2328                .expect_err("a deadline nothing applies must be refused");
2329            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2330        }
2331    }
2332
2333    /// A MicroVM bursts to four times its baseline with no way to opt out, so a ceiling is kept
2334    /// by choosing the size whose *peak* fits inside it. Sizing by baseline would hand back a
2335    /// sandbox that can reach four times what the customer declared.
2336    #[test]
2337    fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2338        let sandbox = sandbox_with(SandboxEgress::None, vec![]);
2339        let tier = sandbox
2340            .microvm_tier()
2341            .expect("2Gi/1cpu/20Gi is satisfiable");
2342
2343        assert_eq!(
2344            tier.peak_memory_mib, 2048,
2345            "the peak is the declared ceiling"
2346        );
2347        assert_eq!(
2348            tier.baseline_memory_mib, 512,
2349            "which is a quarter of it as the baseline"
2350        );
2351        assert!(tier.max_disk_mib <= 20 * 1024);
2352    }
2353
2354    /// AWS allocates one vCPU per 2GB, so a cpu ceiling below what the memory ceiling implies
2355    /// cannot be honoured together with it. Letting cpu choose the size instead would hand back a
2356    /// machine four times smaller than the memory asked for, with nothing to indicate it.
2357    #[test]
2358    fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2359        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2360        {
2361            let limits = sandbox
2362                .limits
2363                .as_mut()
2364                .expect("the fixture declares limits");
2365            limits.cpu = "1".to_string();
2366            limits.memory = "8Gi".to_string();
2367        }
2368
2369        let error = sandbox
2370            .microvm_tier()
2371            .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2372        assert!(
2373            error.to_string().contains("4 vCPU"),
2374            "the refusal must say what the memory ceiling implies: {error}"
2375        );
2376
2377        sandbox
2378            .limits
2379            .as_mut()
2380            .expect("the fixture declares limits")
2381            .cpu = "4".to_string();
2382        let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2383        assert_eq!(tier.peak_memory_mib, 8192);
2384    }
2385
2386    /// Below AWS's smallest peak there is no size that holds the ceiling, and rounding up to the
2387    /// nearest one would silently exceed it.
2388    #[test]
2389    fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2390        let mut sandbox = sandbox_with(SandboxEgress::None, vec![]);
2391        sandbox
2392            .limits
2393            .as_mut()
2394            .expect("the fixture declares limits")
2395            .memory = "1Gi".to_string();
2396
2397        let error = sandbox
2398            .validate_for_platform(Platform::Aws)
2399            .expect_err("no MicroVM size peaks at or below 1Gi");
2400        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2401        assert!(
2402            error.to_string().contains("2Gi"),
2403            "the refusal must say what the smallest holdable ceiling is: {error}"
2404        );
2405    }
2406
2407    /// Source is refused at plan time on every platform whose sandbox image `alien build` does
2408    /// not build (the reason is at the check in `validate_for_platform`).
2409    #[test]
2410    fn source_code_is_refused_where_no_backend_builds_it() {
2411        let sandbox = Sandbox::new("agent".to_string())
2412            .code(SandboxCode::Source {
2413                src: "./sandbox".to_string(),
2414                toolchain: ToolchainConfig::Docker {
2415                    dockerfile: None,
2416                    build_args: None,
2417                    target: None,
2418                },
2419            })
2420            .egress(SandboxEgress::None)
2421            .lifecycle(SandboxLifecyclePolicy {
2422                max_lifetime_seconds: None,
2423                idle_pause_seconds: None,
2424            })
2425            .build();
2426
2427        sandbox
2428            .validate_for_platform(Platform::Aws)
2429            .expect("an AWS sandbox base image is built by `alien build`");
2430        sandbox
2431            .validate_for_platform(Platform::Azure)
2432            .expect("an Azure sandbox base image is built by `alien build`");
2433        sandbox
2434            .validate_for_platform(Platform::Gcp)
2435            .expect("a GCP sandbox base image is built by `alien build`");
2436
2437        for platform in [Platform::Kubernetes, Platform::Local] {
2438            let error = sandbox
2439                .validate_for_platform(platform)
2440                .expect_err("no backend builds a sandbox image from source here");
2441            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2442            assert!(
2443                error.to_string().contains("code.image"),
2444                "the refusal must say what to write instead: {error}"
2445            );
2446            assert!(
2447                error.to_string().contains(&platform.to_string()),
2448                "the refusal must name the platform that cannot build it: {error}"
2449            );
2450        }
2451    }
2452
2453    /// `validate_quantity` accepts nine suffixes. Reading only `Gi` and `Mi` would size a
2454    /// declared `4G` as though it were `4Gi`, which for a ceiling means exceeding it.
2455    #[test]
2456    fn every_accepted_unit_converts_rather_than_falling_back() {
2457        assert_eq!(quantity_mib("2Gi"), Some(2048));
2458        assert_eq!(quantity_mib("512Mi"), Some(512));
2459        assert_eq!(quantity_mib("4G"), Some(3814));
2460        assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2461        assert_eq!(millicores("1"), Some(1000));
2462        assert_eq!(millicores("500m"), Some(500));
2463    }
2464
2465    #[test]
2466    fn privileged_supervisor_fixes_identity_and_requires_an_enforceable_backend() {
2467        let mut sandbox = sandbox_with(
2468            SandboxEgress::AllowDomains {
2469                domains: vec!["example.com".to_string()],
2470            },
2471            vec![],
2472        );
2473        sandbox.privileged_supervisor = Some(SandboxPrivilegedSupervisor { command_uid: 60001 });
2474        sandbox
2475            .validate_for_platform(Platform::Aws)
2476            .expect("AWS can enforce the policy in the agent");
2477        assert_eq!(sandbox.cloud_egress(), &SandboxEgress::Allow);
2478        assert_eq!(
2479            sandbox.supervisor_environment()["ALIEN_SANDBOX_EXPECTED_EXEC_UID"],
2480            "60001"
2481        );
2482        for platform in [
2483            Platform::Local,
2484            Platform::Kubernetes,
2485            Platform::Azure,
2486            Platform::Gcp,
2487        ] {
2488            let error = sandbox
2489                .validate_for_platform(platform)
2490                .expect_err("cannot grant a privilege boundary the backend lacks");
2491            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2492            assert!(error.message.contains("privilegedSupervisor"));
2493        }
2494        for uid in [0, u32::MAX] {
2495            sandbox.privileged_supervisor.as_mut().unwrap().command_uid = uid;
2496            let error = sandbox
2497                .validate_for_platform(Platform::Aws)
2498                .expect_err("invalid uid must fail at plan time");
2499            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2500        }
2501    }
2502
2503    #[test]
2504    fn unknown_fields_are_rejected() {
2505        let json = r#"{
2506            "id": "sbx",
2507            "code": {"type": "image", "image": "ubuntu:24.04"},
2508            "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2509            "egress": {"mode": "deny"},
2510            "lifecycle": {},
2511            "unexpected": true
2512        }"#;
2513
2514        serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2515    }
2516
2517    #[test]
2518    fn serialization_roundtrips() {
2519        let sandbox = sandbox_with(
2520            SandboxEgress::AllowDomains {
2521                domains: vec!["example.com".to_string()],
2522            },
2523            vec![8080, 9090],
2524        );
2525
2526        let json = serde_json::to_string(&sandbox).expect("serializes");
2527        let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2528        assert_eq!(sandbox, restored);
2529    }
2530
2531    #[test]
2532    fn id_is_immutable_across_updates() {
2533        let original = sandbox_with(SandboxEgress::None, vec![]);
2534        let renamed = Sandbox::new("other".to_string())
2535            .code(SandboxCode::Image {
2536                image: "ubuntu".to_string(),
2537            })
2538            .limits(
2539                original
2540                    .limits
2541                    .clone()
2542                    .expect("the fixture declares limits"),
2543            )
2544            .egress(SandboxEgress::None)
2545            .lifecycle(SandboxLifecyclePolicy {
2546                max_lifetime_seconds: None,
2547                idle_pause_seconds: None,
2548            })
2549            .build();
2550
2551        original
2552            .validate_update(&original.clone())
2553            .expect("an unchanged config is a valid update");
2554        original
2555            .validate_update(&renamed)
2556            .expect_err("renaming a sandbox is not an update");
2557    }
2558
2559    /// Azure declares an idle-pause policy but not a wall-clock ceiling.
2560    ///
2561    /// The two travel together in `SandboxLifecyclePolicy` and are gated separately on purpose:
2562    /// Azure pauses on idle and has no maximum lifetime, so accepting one and refusing the
2563    /// other is the honest split rather than an inconsistency.
2564    #[test]
2565    fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2566        let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2567            Sandbox::new("sbx".to_string())
2568                .code(SandboxCode::Image {
2569                    image: "ubuntu".to_string(),
2570                })
2571                .egress(SandboxEgress::Allow)
2572                .lifecycle(lifecycle)
2573                .build()
2574                .validate_for_platform(Platform::Azure)
2575        };
2576
2577        with_policy(SandboxLifecyclePolicy {
2578            max_lifetime_seconds: None,
2579            idle_pause_seconds: Some(900),
2580        })
2581        .expect("Azure pauses a sandbox on idle");
2582
2583        let error = with_policy(SandboxLifecyclePolicy {
2584            max_lifetime_seconds: Some(3600),
2585            idle_pause_seconds: None,
2586        })
2587        .expect_err("Azure has no wall-clock ceiling to enforce one with");
2588        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2589        assert!(
2590            error.message.contains("sandboxLifetime"),
2591            "names the capability: {}",
2592            error.message
2593        );
2594    }
2595
2596    /// An allowlist naming nothing is a deny-all wearing an allowlist's label.
2597    ///
2598    /// It renders as a `Deny` default with no rules — the shape the Azure provider adds a
2599    /// catch-all to avoid — and a reader scanning the declaration sees "allowDomains" and reads
2600    /// the opposite of what it does.
2601    #[test]
2602    fn an_allowlist_with_no_domains_is_refused() {
2603        let declared = |domains: Vec<String>| {
2604            Sandbox::new("sbx".to_string())
2605                .code(SandboxCode::Image {
2606                    image: "ubuntu".to_string(),
2607                })
2608                .egress(SandboxEgress::AllowDomains { domains })
2609                .lifecycle(SandboxLifecyclePolicy {
2610                    max_lifetime_seconds: None,
2611                    idle_pause_seconds: None,
2612                })
2613                .build()
2614                .validate_for_platform(Platform::Azure)
2615        };
2616
2617        let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2618        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2619
2620        declared(vec!["api.example.com".to_string()])
2621            .expect("a named domain is what an allowlist is for");
2622    }
2623
2624    /// The two expressible modes map to the boolean; a host list maps to nothing so the caller has
2625    /// to refuse rather than silently pick a side.
2626    #[test]
2627    fn internet_access_switch_maps_only_the_two_expressible_modes() {
2628        assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2629        assert_eq!(SandboxEgress::None.internet_access_switch(), Some(false));
2630        assert_eq!(
2631            SandboxEgress::AllowDomains {
2632                domains: vec!["api.example.com".to_string()]
2633            }
2634            .internet_access_switch(),
2635            None,
2636            "a host list has no boolean and must not be approximated"
2637        );
2638    }
2639
2640    /// A sandbox resource as a stack state recorded it before `session` became `lifecycle` and
2641    /// `idleSuspendSeconds` became `idlePauseSeconds`. Such a state is still what a deployment
2642    /// that old holds, and reading it is the first step of updating or deleting that deployment.
2643    #[test]
2644    fn stack_state_written_before_the_lifecycle_rename_still_reads() {
2645        let stored: crate::StackResourceState = serde_json::from_value(serde_json::json!({
2646            "type": "sandbox",
2647            "config": {
2648                "id": "sandbox",
2649                "code": {
2650                    "type": "image",
2651                    "image": "s3://example-bundles/analysis/v1/bundle.zip"
2652                },
2653                "type": "sandbox",
2654                "egress": { "mode": "allow" },
2655                "session": { "maxLifetimeSeconds": 28800, "idleSuspendSeconds": 600 }
2656            },
2657            "status": "running",
2658            "outputs": {
2659                "type": "sandbox",
2660                "identifier": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox",
2661                "parentName": "arn:aws:lambda:us-east-2:123456789012:microvm-image:example-sandbox"
2662            },
2663            "lifecycle": "frozen",
2664            "dependencies": [
2665                { "id": "management", "type": "remote-stack-management" },
2666                { "id": "access", "type": "resource-access" }
2667            ],
2668            "controllerPlatform": "aws"
2669        }))
2670        .expect("a stack state written before the rename must still deserialize");
2671
2672        let expected = Sandbox::new("sandbox".to_string())
2673            .code(SandboxCode::Image {
2674                image: "s3://example-bundles/analysis/v1/bundle.zip".to_string(),
2675            })
2676            .egress(SandboxEgress::Allow)
2677            .lifecycle(SandboxLifecyclePolicy {
2678                max_lifetime_seconds: Some(28_800),
2679                idle_pause_seconds: Some(600),
2680            })
2681            .build();
2682        assert_eq!(stored.config.downcast_ref::<Sandbox>(), Some(&expected));
2683
2684        let rewritten = serde_json::to_value(&stored.config).expect("the sandbox serializes");
2685        assert_eq!(
2686            rewritten["lifecycle"],
2687            serde_json::json!({ "maxLifetimeSeconds": 28800, "idlePauseSeconds": 600 }),
2688            "the next write stores the current names"
2689        );
2690        assert!(rewritten.get("session").is_none());
2691    }
2692}