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