Skip to main content

alien_core/resources/
sandbox.rs

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