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