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