Skip to main content

alien_core/resources/
sandbox.rs

1//! Sandbox resource for running untrusted code in an isolated environment.
2//!
3//! A Sandbox is a session-oriented resource: the declaration provisions a durable parent, and
4//! the application creates and destroys individual sessions 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 std::any::Any;
18use std::fmt::Debug;
19
20/// Specifies where the sandbox's root filesystem comes from.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase", tag = "type")]
24pub enum SandboxCode {
25    /// A prebuilt container image used as the sandbox root filesystem.
26    #[serde(rename_all = "camelCase")]
27    Image {
28        /// Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`)
29        image: String,
30    },
31    /// Source built into a sandbox image at deploy time.
32    #[serde(rename_all = "camelCase")]
33    Source {
34        /// The source directory to build from
35        src: String,
36        /// Toolchain configuration with type-safe options
37        toolchain: ToolchainConfig,
38    },
39}
40
41/// Hard ceilings enforced on a sandbox session.
42///
43/// These are limits, not scheduling requests. Untrusted code does not respect a hint, so every
44/// field is enforced by the platform and a platform that cannot enforce one is rejected at plan
45/// time rather than silently ignoring it.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48#[serde(rename_all = "camelCase", deny_unknown_fields)]
49pub struct SandboxLimits {
50    /// CPU ceiling in cores or millicores (e.g. `"1"`, `"500m"`)
51    pub cpu: String,
52    /// Memory ceiling (e.g. `"2Gi"`, `"512Mi"`)
53    pub memory: String,
54    /// Disk ceiling (e.g. `"20Gi"`)
55    pub disk: String,
56    /// Maximum number of processes, which bounds fork bombs.
57    ///
58    /// Optional because only a container runtime has the primitive: Kubernetes sets a pid ceiling
59    /// per node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring
60    /// it on a platform that cannot apply it is refused at plan time.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub max_processes: Option<u32>,
63}
64
65/// One of the five sizes a Lambda MicroVM can be built at.
66///
67/// AWS has no ceiling knob: `minimumMemoryInMiB` sets a *baseline* and a running MicroVM bursts
68/// vertically to four times it with no way to opt out. A declared ceiling is therefore honoured by
69/// picking the tier whose **peak** stays inside it, not the tier whose baseline matches it.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct MicrovmTier {
72    /// What `minimumMemoryInMiB` is set to.
73    pub baseline_memory_mib: i64,
74    /// The most memory the MicroVM can reach, in MiB.
75    pub peak_memory_mib: i64,
76    /// The most vCPU the MicroVM can reach.
77    pub peak_vcpu: u32,
78    /// The most disk the MicroVM can use, in MiB.
79    pub max_disk_mib: i64,
80}
81
82/// The published sizes, smallest first. Baseline memory to vCPU is 2 GB per vCPU, peak is four
83/// times baseline, and disk is fixed per tier rather than independently selectable.
84/// Longest life AWS will run a MicroVM for, from `RunMicrovm`'s `maximumDurationInSeconds`.
85const AWS_MAX_SESSION_LIFETIME_SECONDS: u32 = 28_800;
86
87const MICROVM_TIERS: &[MicrovmTier] = &[
88    MicrovmTier {
89        baseline_memory_mib: 512,
90        peak_memory_mib: 2048,
91        peak_vcpu: 1,
92        max_disk_mib: 8192,
93    },
94    MicrovmTier {
95        baseline_memory_mib: 1024,
96        peak_memory_mib: 4096,
97        peak_vcpu: 2,
98        max_disk_mib: 8192,
99    },
100    MicrovmTier {
101        baseline_memory_mib: 2048,
102        peak_memory_mib: 8192,
103        peak_vcpu: 4,
104        max_disk_mib: 8192,
105    },
106    MicrovmTier {
107        baseline_memory_mib: 4096,
108        peak_memory_mib: 16384,
109        peak_vcpu: 8,
110        max_disk_mib: 16384,
111    },
112    MicrovmTier {
113        baseline_memory_mib: 8192,
114        peak_memory_mib: 32768,
115        peak_vcpu: 16,
116        max_disk_mib: 32768,
117    },
118];
119
120/// Outbound network policy for a sandbox.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123#[serde(rename_all = "camelCase", tag = "mode")]
124pub enum SandboxEgress {
125    /// No outbound network access.
126    ///
127    /// Routed traffic only. Link-local is not outbound and no backend's egress control reaches
128    /// it, so this is not a boundary against instance metadata.
129    Deny,
130    /// Unrestricted outbound access to the public internet, and none to private ranges or the
131    /// deployment's own network.
132    ///
133    /// Link-local carries the same exception as `Deny`.
134    Allow,
135    /// Outbound access only to the listed hostnames. No backend expresses this yet.
136    #[serde(rename_all = "camelCase")]
137    AllowDomains {
138        /// Hostnames the sandbox may reach
139        domains: Vec<String>,
140    },
141}
142
143/// How long a session may live and when it is suspended.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct SandboxSessionPolicy {
148    /// Wall-clock ceiling on a single session, after which the platform terminates it.
149    ///
150    /// Optional because not every backend has the primitive: Kubernetes has
151    /// `activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local
152    /// expose one, so declaring a ceiling there is refused at plan time rather than accepted and
153    /// never applied. AWS caps it at 8 hours.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub max_lifetime_seconds: Option<u32>,
156    /// Idle period after which the session is suspended, where the platform supports it
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub idle_suspend_seconds: Option<u32>,
159}
160
161/// What a platform's sandbox backend can actually do.
162///
163/// Published so portable code can branch before calling rather than discovering a gap through
164/// an error. Every field here corresponds to a capability that at least one platform lacks;
165/// create, exec and terminate are the guaranteed floor and are therefore not listed.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct SandboxCapabilities {
170    /// Files can be moved in and out of a session
171    ///
172    /// Every backend but Azure, whose data plane exposes exec and lifecycle and no transfer.
173    pub files: bool,
174    /// A later call can reach a session created by an earlier one
175    pub reconnect: bool,
176    /// An authenticated, port-scoped capability to reach a service inside the sandbox
177    pub preview: bool,
178    /// Session state can be suspended and resumed
179    pub suspend_resume: bool,
180    /// A session's full state can be captured and used to create another
181    pub snapshot: bool,
182    /// Egress can be restricted to a hostname allowlist
183    pub domain_egress_rules: bool,
184    /// Whether a declared `deny` is actually enforced, rather than accepted and dropped
185    pub egress_deny: bool,
186    /// The platform enforces the declared cpu, memory and disk ceilings
187    pub enforced_limits: bool,
188    /// The platform can cap how many processes a session runs
189    pub process_limit: bool,
190    /// The platform terminates a session at a declared wall-clock deadline
191    pub session_lifetime: bool,
192    /// A command runs in its own PID namespace and cannot see or signal the agent's processes.
193    ///
194    /// Only where an agent runs as root. Creating the namespace needs `CAP_SYS_ADMIN`, and the
195    /// Kubernetes sandbox pod drops every capability — which is also what denies `ptrace` by
196    /// construction, so granting it there would remove a lock to add one.
197    pub supervisor_pid_namespace: bool,
198}
199
200impl SandboxCapabilities {
201    /// Returns what the given platform's sandbox backend supports.
202    ///
203    /// Errors for platforms with no sandbox backend, rather than returning an all-false set —
204    /// "every capability is missing" and "this platform has no sandboxes" are different
205    /// conditions and an application should not have to tell them apart by inspection.
206    pub fn for_platform(platform: Platform) -> Result<Self> {
207        match platform {
208            Platform::Aws => Ok(Self {
209                files: true,
210                reconnect: true,
211                preview: true,
212                suspend_resume: true,
213                snapshot: false,
214                domain_egress_rules: false,
215                egress_deny: true,
216                enforced_limits: true,
217                // Nothing in the API bounds process count.
218                process_limit: false,
219                // `maximumDurationInSeconds` on `RunMicrovm`, which Lambda enforces by
220                // terminating the MicroVM. Capped at 8 hours by the service.
221                session_lifetime: true,
222                // Measured, not assumed: the agent inside a Lambda MicroVM runs as uid 0 with
223                // `CapEff: 00000000a80425fb`, the standard container default set, which excludes
224                // `CAP_SYS_ADMIN`. It can drop privilege (`CAP_SETUID`/`CAP_SETGID` are held) and
225                // it cannot create a namespace. No backend offers this today.
226                supervisor_pid_namespace: false,
227            }),
228            // Azure the platform has all three — a per-port URL closed to anonymous traffic, a
229            // 0.54s resume, and a full-VM snapshot — and the binding provider implements none of
230            // them. The capability set describes what a caller can reach, not what the cloud
231            // could do, so these stay false until the provider catches up.
232            Platform::Azure => Ok(Self {
233                files: false,
234                reconnect: true,
235                preview: false,
236                suspend_resume: false,
237                snapshot: false,
238                domain_egress_rules: false,
239                egress_deny: false,
240                enforced_limits: false,
241                process_limit: false,
242                session_lifetime: false,
243                // No Alien process inside an Azure sandbox, so there is no supervisor to isolate.
244                supervisor_pid_namespace: false,
245            }),
246            // A Cloud Run sandbox id is scoped to one instance, and session affinity does not
247            // hold one across turns. That is the absence of a reconnect guarantee, not a
248            // degraded one.
249            Platform::Gcp => Ok(Self {
250                files: true,
251                reconnect: false,
252                preview: false,
253                suspend_resume: false,
254                snapshot: false,
255                domain_egress_rules: false,
256                egress_deny: true,
257                enforced_limits: false,
258                process_limit: false,
259                session_lifetime: false,
260                // A Cloud Run sandbox is a subprocess of the workload; nothing of ours is inside.
261                supervisor_pid_namespace: false,
262            }),
263            // Preview needs a gateway that validates a session-and-port capability, and that
264            // gateway does not exist yet.
265            Platform::Kubernetes => Ok(Self {
266                files: true,
267                reconnect: true,
268                preview: false,
269                suspend_resume: false,
270                snapshot: false,
271                domain_egress_rules: false,
272                egress_deny: true,
273                enforced_limits: true,
274                // A pid ceiling is a kubelet setting per node, not a pod field.
275                process_limit: false,
276                // `activeDeadlineSeconds` on the pod, which the kubelet enforces.
277                session_lifetime: true,
278                // The pod drops every capability, including the `CAP_SYS_ADMIN` the agent would
279                // need to unshare. That is also what denies `ptrace`, so this stays false rather
280                // than the pod being weakened to make it true.
281                supervisor_pid_namespace: false,
282            }),
283            Platform::Local => Ok(Self {
284                files: true,
285                reconnect: true,
286                preview: true,
287                suspend_resume: false,
288                snapshot: false,
289                domain_egress_rules: false,
290                egress_deny: true,
291                enforced_limits: true,
292                // Docker's `--pids-limit`.
293                process_limit: true,
294                session_lifetime: false,
295                // Local has no in-sandbox agent: the manager drives Docker from outside, so
296                // there is no supervisor sharing the sandbox to isolate from.
297                supervisor_pid_namespace: false,
298            }),
299            Platform::Machines | Platform::Test => {
300                Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
301                    platform: platform.to_string(),
302                }))
303            }
304        }
305    }
306
307    /// Returns a typed error if the named capability is absent on this platform.
308    pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
309        let available = match capability {
310            SandboxCapability::Files => self.files,
311            SandboxCapability::Reconnect => self.reconnect,
312            SandboxCapability::Preview => self.preview,
313            SandboxCapability::SuspendResume => self.suspend_resume,
314            SandboxCapability::Snapshot => self.snapshot,
315            SandboxCapability::DomainEgressRules => self.domain_egress_rules,
316            SandboxCapability::EgressDeny => self.egress_deny,
317            SandboxCapability::EnforcedLimits => self.enforced_limits,
318            SandboxCapability::ProcessLimit => self.process_limit,
319            SandboxCapability::SessionLifetime => self.session_lifetime,
320            SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
321        };
322
323        if available {
324            return Ok(());
325        }
326
327        Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
328            capability: capability.as_str().to_string(),
329            platform: platform.to_string(),
330        }))
331    }
332}
333
334/// Names a single sandbox capability, so an unsupported call can report which one it needed.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
337#[serde(rename_all = "camelCase")]
338pub enum SandboxCapability {
339    /// Moving files in and out of a session
340    Files,
341    /// Reaching a session created by an earlier call
342    Reconnect,
343    /// An authenticated, port-scoped ingress capability
344    Preview,
345    /// Suspending and resuming session state
346    SuspendResume,
347    /// Capturing full session state
348    Snapshot,
349    /// Restricting egress to a hostname allowlist
350    DomainEgressRules,
351    /// Refusing outbound access when a sandbox declares none
352    EgressDeny,
353    /// Platform-enforced resource ceilings
354    EnforcedLimits,
355    /// A ceiling on the number of processes a session may run
356    ProcessLimit,
357    /// A wall-clock ceiling on a session, applied by the platform rather than by a caller
358    SessionLifetime,
359    /// A command runs in its own PID namespace, isolated from the agent supervising it
360    SupervisorPidNamespace,
361}
362
363impl SandboxCapability {
364    /// Returns the stable identifier used in errors and capability queries.
365    pub fn as_str(&self) -> &'static str {
366        match self {
367            Self::Files => "files",
368            Self::Reconnect => "reconnect",
369            Self::Preview => "preview",
370            Self::SuspendResume => "suspendResume",
371            Self::Snapshot => "snapshot",
372            Self::DomainEgressRules => "domainEgressRules",
373            Self::EgressDeny => "egressDeny",
374            Self::EnforcedLimits => "enforcedLimits",
375            Self::ProcessLimit => "processLimit",
376            Self::SessionLifetime => "sessionLifetime",
377            Self::SupervisorPidNamespace => "supervisorPidNamespace",
378        }
379    }
380}
381
382/// An isolated environment for running untrusted code, created per session at runtime.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
384#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
385#[serde(rename_all = "camelCase", deny_unknown_fields)]
386#[builder(start_fn = new)]
387pub struct Sandbox {
388    /// Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and
389    /// underscores ([A-Za-z0-9-_]). Maximum 64 characters.
390    #[builder(start_fn)]
391    pub id: String,
392    /// Where the sandbox's root filesystem comes from
393    pub code: SandboxCode,
394    /// Enforced resource ceilings.
395    ///
396    /// Optional because not every platform can enforce them, and a declaration that names none
397    /// takes the platform's own defaults. Naming them on a platform that cannot enforce them is
398    /// rejected at plan time rather than silently ignored.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub limits: Option<SandboxLimits>,
401    /// Outbound network policy
402    pub egress: SandboxEgress,
403    /// Session lifetime and idle behaviour
404    pub session: SandboxSessionPolicy,
405    /// Ports eligible for a preview capability. A port not listed here can never be exposed,
406    /// so an application cannot widen its own ingress at runtime.
407    #[builder(default)]
408    #[serde(default, skip_serializing_if = "Vec::is_empty")]
409    pub preview_ports: Vec<u16>,
410}
411
412/// Whether the artifact being rendered restricts which network modes it accepts.
413///
414/// A sandbox is not emitted on a Kubernetes target, so nothing there routes egress through a
415/// connector and the default network stays a working answer. Every site that withholds the mode,
416/// explains the restriction, or renders a branch for it has to ask this one question — asking the
417/// stack directly is how they came to disagree.
418pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
419    !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
420}
421
422/// Whether any sandbox in the stack forces setup to name subnets.
423///
424/// A restricted sandbox routes session egress through a VPC connector, and neither generator can
425/// enumerate the account default VPC's subnets, so that mode leaves the connector without any and
426/// it fails at create. Callers that render an artifact want [`restricts_network_mode`] instead:
427/// this one answers for the declaration, which on a Kubernetes target is not what gets emitted.
428pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
429    stack.resources().any(|(_resource_id, resource)| {
430        resource
431            .config
432            .downcast_ref::<Sandbox>()
433            .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
434    })
435}
436
437impl Sandbox {
438    /// The resource type identifier for Sandbox
439    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
440
441    /// Returns the sandbox's unique identifier.
442    pub fn id(&self) -> &str {
443        &self.id
444    }
445
446    /// The declared ceilings, or the defaults a platform applies when none were named.
447    ///
448    /// Backends want a concrete set: a sandbox with no declared ceilings still runs inside
449    /// whatever the platform gives it, and a backend that had to branch on `None` would end up
450    /// inventing its own default anyway.
451    pub fn resolved_limits(&self) -> SandboxLimits {
452        self.limits.clone().unwrap_or_else(default_limits)
453    }
454
455    /// Validates the declaration against what the target platform can enforce.
456    ///
457    /// Runs at plan time so an unenforceable limit or an unsupported egress mode fails before
458    /// anything is provisioned, rather than at the first exec.
459    pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
460        let capabilities = SandboxCapabilities::for_platform(platform)?;
461
462        // No backend builds a sandbox image from source. Kubernetes turned this into an empty
463        // image string and a pod that could never schedule, which is the silent no-op the
464        // capability contract forbids — the failure has to land here instead.
465        if let SandboxCode::Source { .. } = &self.code {
466            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
467                resource_id: self.id.clone(),
468                field: "code".to_string(),
469                value: "source".to_string(),
470                reason: "no sandbox backend builds an image from source yet; give code.image a \
471                         prebuilt reference"
472                    .to_string(),
473            }));
474        }
475
476        let Some(limits) = self.limits.as_ref() else {
477            // Nothing declared, so nothing to enforce and nothing to reject.
478            return self.validate_capabilities(&capabilities, platform);
479        };
480
481        validate_quantity(&self.id, "cpu", &limits.cpu)?;
482        validate_quantity(&self.id, "memory", &limits.memory)?;
483        validate_quantity(&self.id, "disk", &limits.disk)?;
484
485        if let Some(max_processes) = limits.max_processes {
486            if max_processes == 0 {
487                return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
488                    resource_id: self.id.clone(),
489                    field: "maxProcesses".to_string(),
490                    value: "0".to_string(),
491                    reason: "a sandbox that may run no processes cannot run code".to_string(),
492                }));
493            }
494            capabilities.require(SandboxCapability::ProcessLimit, platform)?;
495        }
496
497        // Declaring limits a platform ignores is worse than not declaring them: the stack reads
498        // as bounded while the sandbox is not.
499        capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
500
501        if platform == Platform::Aws {
502            // Refused here rather than at emit so a customer sees it while planning, and so both
503            // package formats inherit the same answer.
504            self.microvm_tier()?;
505
506            // The ceiling is Lambda's, and it rejects the run rather than clamping — so a value
507            // outside it would pass planning, render into the package, and fail at the first
508            // session. Kubernetes takes the same field with no such bound, which is why this
509            // sits under the AWS gate rather than on the type.
510            if let Some(seconds) = self.session.max_lifetime_seconds {
511                if !(1..=AWS_MAX_SESSION_LIFETIME_SECONDS).contains(&seconds) {
512                    return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
513                        resource_id: self.id.clone(),
514                        field: "maxLifetimeSeconds".to_string(),
515                        value: seconds.to_string(),
516                        reason: format!(
517                            "AWS runs a MicroVM for between 1 and \
518                             {AWS_MAX_SESSION_LIFETIME_SECONDS} seconds"
519                        ),
520                    }));
521                }
522            }
523        }
524
525        self.validate_capabilities(&capabilities, platform)
526    }
527
528    /// The MicroVM size that keeps every declared ceiling, or why none does.
529    ///
530    /// AWS sizes are discrete and a running MicroVM bursts to four times its baseline, so the
531    /// only tier that honours a ceiling is one whose peak fits inside it. A declaration no tier
532    /// satisfies is refused: shipping the nearest size would give the customer a sandbox that
533    /// exceeds the bound they wrote down.
534    pub fn microvm_tier(&self) -> Result<MicrovmTier> {
535        let Some(limits) = self.limits.as_ref() else {
536            // Nothing declared: AWS's own default baseline, which is also `default_limits`.
537            return Ok(MICROVM_TIERS[2]);
538        };
539
540        let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
541            AlienError::new(ErrorData::SandboxLimitInvalid {
542                resource_id: self.id.clone(),
543                field: "memory".to_string(),
544                value: limits.memory.clone(),
545                reason: "AWS sizes a MicroVM in whole MiB".to_string(),
546            })
547        })?;
548        let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
549            AlienError::new(ErrorData::SandboxLimitInvalid {
550                resource_id: self.id.clone(),
551                field: "disk".to_string(),
552                value: limits.disk.clone(),
553                reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
554            })
555        })?;
556        let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
557            AlienError::new(ErrorData::SandboxLimitInvalid {
558                resource_id: self.id.clone(),
559                field: "cpu".to_string(),
560                value: limits.cpu.clone(),
561                reason: "expected cores or millicores".to_string(),
562            })
563        })?;
564
565        // Memory and disk choose the size; cpu is then checked rather than used to choose.
566        // AWS couples cpu to memory at 2 GB per vCPU, so letting a low cpu ceiling select the
567        // size too would quietly hand back a machine four times smaller than the memory ceiling
568        // asked for, with nothing to indicate it.
569        let sized = |tier: &&MicrovmTier| {
570            tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
571        };
572
573        let tier = MICROVM_TIERS
574            .iter()
575            .rev()
576            .find(sized)
577            .copied()
578            .ok_or_else(|| {
579                AlienError::new(ErrorData::SandboxLimitInvalid {
580                    resource_id: self.id.clone(),
581                    field: "memory".to_string(),
582                    value: limits.memory.clone(),
583                    reason: format!(
584                        "a Lambda MicroVM bursts to four times its baseline, so the smallest \
585                         ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
586                         disk fit no size",
587                        limits.memory, limits.disk
588                    ),
589                })
590            })?;
591
592        let required_millicores = i64::from(tier.peak_vcpu) * 1000;
593        if cpu_millicores < required_millicores {
594            return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
595                resource_id: self.id.clone(),
596                field: "cpu".to_string(),
597                value: limits.cpu.clone(),
598                reason: format!(
599                    "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
600                     reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
601                    limits.memory, tier.peak_vcpu, tier.peak_vcpu
602                ),
603            }));
604        }
605
606        Ok(tier)
607    }
608
609    /// The capability checks that do not depend on declared limits.
610    fn validate_capabilities(
611        &self,
612        capabilities: &SandboxCapabilities,
613        platform: Platform,
614    ) -> Result<()> {
615        if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
616            capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
617        }
618
619        // `allow` asks for no restriction, so a backend that ignores it fails loudly on the first
620        // blocked connection. `deny` asks for one, and a backend that ignores it puts untrusted
621        // code on the internet with nothing to notice — so only this direction is gated.
622        if matches!(self.egress, SandboxEgress::Deny) {
623            capabilities.require(SandboxCapability::EgressDeny, platform)?;
624        }
625
626        if !self.preview_ports.is_empty() {
627            capabilities.require(SandboxCapability::Preview, platform)?;
628        }
629
630        if self.session.idle_suspend_seconds.is_some() {
631            capabilities.require(SandboxCapability::SuspendResume, platform)?;
632        }
633
634        if self.session.max_lifetime_seconds.is_some() {
635            capabilities.require(SandboxCapability::SessionLifetime, platform)?;
636        }
637
638        Ok(())
639    }
640}
641
642/// Ceilings applied when a declaration names none.
643///
644/// Modest on purpose: an undeclared sandbox is one whose author did not think about sizing, and
645/// the safe reading of that is a small box rather than a generous one.
646fn default_limits() -> SandboxLimits {
647    SandboxLimits {
648        cpu: "1".to_string(),
649        memory: "2Gi".to_string(),
650        disk: "8Gi".to_string(),
651        max_processes: None,
652    }
653}
654
655/// Validates a Kubernetes-style resource quantity such as `500m`, `2Gi` or `1`.
656fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
657    let invalid = |reason: &str| {
658        AlienError::new(ErrorData::SandboxLimitInvalid {
659            resource_id: resource_id.to_string(),
660            field: field.to_string(),
661            value: value.to_string(),
662            reason: reason.to_string(),
663        })
664    };
665
666    let digits_end = value
667        .find(|c: char| !c.is_ascii_digit() && c != '.')
668        .unwrap_or(value.len());
669    let (number, suffix) = value.split_at(digits_end);
670
671    let parsed: f64 = number
672        .parse()
673        .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
674
675    if parsed <= 0.0 {
676        return Err(invalid("must be greater than zero"));
677    }
678
679    const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
680    if !SUFFIXES.contains(&suffix) {
681        return Err(invalid(
682            "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
683        ));
684    }
685
686    Ok(())
687}
688
689/// Splits a quantity into its number and unit suffix.
690fn split_quantity(value: &str) -> Option<(f64, &str)> {
691    let trimmed = value.trim();
692    let digits_end = trimmed
693        .find(|c: char| !c.is_ascii_digit() && c != '.')
694        .unwrap_or(trimmed.len());
695    let (number, suffix) = trimmed.split_at(digits_end);
696    number.parse().ok().map(|number| (number, suffix))
697}
698
699/// A memory or disk quantity in whole MiB, rounded down.
700///
701/// Every suffix `validate_quantity` accepts is handled here. Reading only `Gi` and `Mi` and
702/// falling back for the rest would turn a declared `4G` into a different size than the customer
703/// asked for, which for a ceiling means a sandbox larger than its bound.
704pub fn quantity_mib(value: &str) -> Option<i64> {
705    let (number, suffix) = split_quantity(value)?;
706    let bytes = match suffix {
707        "" => number,
708        "k" => number * 1e3,
709        "M" => number * 1e6,
710        "G" => number * 1e9,
711        "T" => number * 1e12,
712        "Ki" => number * 1024.0,
713        "Mi" => number * 1024.0 * 1024.0,
714        "Gi" => number * 1024.0 * 1024.0 * 1024.0,
715        "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
716        // `m` is a millicore suffix; memory has no use for it.
717        _ => return None,
718    };
719    Some((bytes / (1024.0 * 1024.0)) as i64)
720}
721
722/// A CPU quantity in millicores.
723pub fn millicores(value: &str) -> Option<i64> {
724    let (number, suffix) = split_quantity(value)?;
725    match suffix {
726        "" => Some((number * 1000.0) as i64),
727        "m" => Some(number as i64),
728        _ => None,
729    }
730}
731
732/// Outputs generated by a successfully provisioned Sandbox parent.
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
735#[serde(rename_all = "camelCase")]
736pub struct SandboxOutputs {
737    /// Name of the durable parent that sessions are created inside
738    pub parent_name: String,
739    /// Platform-specific identifier for the parent (image ARN, sandbox group id, namespace)
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub identifier: Option<String>,
742    /// Data-plane endpoint sessions are created through, where the platform has one
743    #[serde(skip_serializing_if = "Option::is_none")]
744    pub endpoint: Option<String>,
745}
746
747impl ResourceOutputsDefinition for SandboxOutputs {
748    fn get_resource_type(&self) -> ResourceType {
749        Sandbox::RESOURCE_TYPE
750    }
751
752    fn as_any(&self) -> &dyn Any {
753        self
754    }
755
756    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
757        Box::new(self.clone())
758    }
759
760    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
761        other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
762    }
763
764    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
765        serde_json::to_value(self)
766    }
767}
768
769impl ResourceDefinition for Sandbox {
770    fn get_resource_type(&self) -> ResourceType {
771        Self::RESOURCE_TYPE
772    }
773
774    fn id(&self) -> &str {
775        &self.id
776    }
777
778    fn get_dependencies(&self) -> Vec<ResourceRef> {
779        Vec::new()
780    }
781
782    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
783        let new_sandbox = new_config
784            .as_any()
785            .downcast_ref::<Sandbox>()
786            .ok_or_else(|| {
787                AlienError::new(ErrorData::UnexpectedResourceType {
788                    resource_id: self.id.clone(),
789                    expected: Self::RESOURCE_TYPE,
790                    actual: new_config.get_resource_type(),
791                })
792            })?;
793
794        if self.id != new_sandbox.id {
795            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
796                resource_id: self.id.clone(),
797                reason: "the 'id' field is immutable".to_string(),
798            }));
799        }
800
801        Ok(())
802    }
803
804    fn as_any(&self) -> &dyn Any {
805        self
806    }
807
808    fn as_any_mut(&mut self) -> &mut dyn Any {
809        self
810    }
811
812    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
813        Box::new(self.clone())
814    }
815
816    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
817        other.as_any().downcast_ref::<Sandbox>() == Some(self)
818    }
819
820    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
821        serde_json::to_value(self)
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
830        Sandbox::new("agent-sbx".to_string())
831            .code(SandboxCode::Image {
832                image: "ubuntu:24.04".to_string(),
833            })
834            .limits(SandboxLimits {
835                cpu: "1".to_string(),
836                memory: "2Gi".to_string(),
837                disk: "20Gi".to_string(),
838                max_processes: None,
839            })
840            .egress(egress)
841            .session(SandboxSessionPolicy {
842                max_lifetime_seconds: None,
843                idle_suspend_seconds: None,
844            })
845            .preview_ports(preview_ports)
846            .build()
847    }
848
849    #[test]
850    fn resource_type_is_stable() {
851        assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
852    }
853
854    #[test]
855    fn capability_sets_are_per_platform() {
856        let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
857        assert!(
858            !gcp.reconnect,
859            "a GCP session id is scoped to one instance, so reconnect is absent"
860        );
861        assert!(!gcp.preview);
862        assert!(!gcp.enforced_limits);
863
864        let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
865        assert!(!azure.files, "the Azure data plane has no file transfer");
866        assert!(gcp.files, "every other backend moves files");
867        // The Azure data plane takes neither an egress policy nor a ceiling, so a declaration of
868        // either is refused rather than accepted and dropped.
869        assert!(!azure.domain_egress_rules);
870        assert!(!azure.egress_deny);
871        assert!(!azure.enforced_limits);
872        // Azure the cloud has snapshot, preview and resume; the binding provider returns
873        // unsupported for all three. What a caller can reach is what the set describes.
874        assert!(!azure.snapshot);
875        assert!(!azure.preview);
876        assert!(!azure.suspend_resume);
877
878        let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
879        assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
880        assert!(aws.suspend_resume);
881
882        let k8s =
883            SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
884        assert!(
885            !k8s.preview,
886            "the session-scoped ingress gateway does not exist yet"
887        );
888    }
889
890    #[test]
891    fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
892        let error = SandboxCapabilities::for_platform(Platform::Machines)
893            .expect_err("Machines has no sandbox backend");
894        assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
895    }
896
897    #[test]
898    fn unsupported_capability_names_platform_and_capability() {
899        let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
900        let error = capabilities
901            .require(SandboxCapability::Preview, Platform::Gcp)
902            .expect_err("GCP has no preview");
903
904        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
905        let rendered = error.to_string();
906        assert!(
907            rendered.contains("preview"),
908            "names the capability: {rendered}"
909        );
910        assert!(rendered.contains("gcp"), "names the platform: {rendered}");
911    }
912
913    /// No backend expresses a hostname allowlist: AWS and Kubernetes match CIDRs, and the Azure
914    /// data plane takes no egress policy at all. Accepting one anywhere would leave a stack
915    /// reading as restricted while the sandbox reaches the whole internet.
916    #[test]
917    fn a_hostname_allowlist_is_refused_on_every_backend() {
918        let sandbox = sandbox_with(
919            SandboxEgress::AllowDomains {
920                domains: vec!["example.com".to_string()],
921            },
922            vec![],
923        );
924
925        for platform in [
926            Platform::Aws,
927            Platform::Azure,
928            Platform::Gcp,
929            Platform::Kubernetes,
930            Platform::Local,
931        ] {
932            let error = sandbox
933                .validate_for_platform(platform)
934                .expect_err("no backend expresses a hostname allowlist");
935            assert_eq!(
936                error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
937                "on {platform:?}"
938            );
939        }
940    }
941
942    /// `deny` is the declaration that carries a security promise, so a backend that cannot keep
943    /// it has to refuse rather than accept it and run the code with open egress.
944    #[test]
945    fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
946        let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
947
948        // GCP is asserted at the capability rather than through validation: this sandbox declares
949        // ceilings GCP cannot enforce, so it is refused for a reason unrelated to egress.
950        assert!(
951            SandboxCapabilities::for_platform(Platform::Gcp)
952                .expect("supported")
953                .egress_deny
954        );
955
956        for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
957            sandbox
958                .validate_for_platform(platform)
959                .expect("deny is enforced here");
960        }
961
962        // Declares no ceilings, so the only thing left for Azure to refuse is the egress mode.
963        let egress_only = Sandbox::new("sbx".to_string())
964            .code(SandboxCode::Image {
965                image: "alpine:3.20".to_string(),
966            })
967            .egress(SandboxEgress::Deny)
968            .session(SandboxSessionPolicy {
969                max_lifetime_seconds: None,
970                idle_suspend_seconds: None,
971            })
972            .build();
973
974        let error = egress_only
975            .validate_for_platform(Platform::Azure)
976            .expect_err("the Azure data plane takes no egress policy, so deny cannot be kept");
977        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
978        assert!(
979            error.message.contains("egressDeny"),
980            "names the capability: {}",
981            error.message
982        );
983    }
984
985    /// Ceilings are rejected per-platform where unsupported — rejected when *declared*. With
986    /// limits mandatory that would read as "GCP sandboxes cannot exist", contradicting the
987    /// create, exec, files and terminate GCP does support.
988    #[test]
989    fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() {
990        let declared = sandbox_with(SandboxEgress::Deny, Vec::new());
991        declared
992            .validate_for_platform(Platform::Gcp)
993            .expect_err("declaring ceilings GCP cannot enforce is rejected");
994
995        let undeclared = Sandbox::new("sbx".to_string())
996            .code(SandboxCode::Image {
997                image: "alpine:3.20".to_string(),
998            })
999            .egress(SandboxEgress::Deny)
1000            .session(SandboxSessionPolicy {
1001                max_lifetime_seconds: None,
1002                idle_suspend_seconds: None,
1003            })
1004            .build();
1005
1006        undeclared
1007            .validate_for_platform(Platform::Gcp)
1008            .expect("a sandbox naming no ceilings takes the platform's own");
1009
1010        // A backend still gets a concrete set, so nothing downstream has to invent one.
1011        assert_eq!(undeclared.resolved_limits().cpu, "1");
1012    }
1013
1014    #[test]
1015    fn preview_ports_require_the_preview_capability() {
1016        let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1017
1018        sandbox
1019            .validate_for_platform(Platform::Aws)
1020            .expect("AWS mints a port-scoped JWE");
1021
1022        let error = sandbox
1023            .validate_for_platform(Platform::Kubernetes)
1024            .expect_err("Kubernetes preview is deferred");
1025        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1026    }
1027
1028    #[test]
1029    fn gcp_rejects_a_sandbox_declaring_enforced_limits() {
1030        let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1031        let error = sandbox
1032            .validate_for_platform(Platform::Gcp)
1033            .expect_err("GCP cannot enforce ceilings on a subprocess sandbox");
1034        assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1035    }
1036
1037    #[test]
1038    fn invalid_quantities_are_rejected_with_the_offending_field() {
1039        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1040        sandbox
1041            .limits
1042            .as_mut()
1043            .expect("the fixture declares limits")
1044            .memory = "2Gb".to_string();
1045
1046        let error = sandbox
1047            .validate_for_platform(Platform::Aws)
1048            .expect_err("Gb is not a valid suffix");
1049        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1050        assert!(error.to_string().contains("memory"));
1051
1052        sandbox
1053            .limits
1054            .as_mut()
1055            .expect("the fixture declares limits")
1056            .memory = "2Gi".to_string();
1057        sandbox
1058            .limits
1059            .as_mut()
1060            .expect("the fixture declares limits")
1061            .cpu = "0".to_string();
1062        let error = sandbox
1063            .validate_for_platform(Platform::Aws)
1064            .expect_err("zero cpu is not a ceiling");
1065        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1066    }
1067
1068    #[test]
1069    fn zero_max_processes_is_rejected() {
1070        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1071        sandbox
1072            .limits
1073            .as_mut()
1074            .expect("the fixture declares limits")
1075            .max_processes = Some(0);
1076
1077        let error = sandbox
1078            .validate_for_platform(Platform::Local)
1079            .expect_err("a sandbox must be able to run at least one process");
1080        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1081        assert!(error.to_string().contains("maxProcesses"));
1082    }
1083
1084    /// A process ceiling needs a container runtime. Kubernetes sets one per node rather than per
1085    /// pod, and neither MicroVMs nor Azure sandboxes expose one, so accepting the declaration
1086    /// anywhere else would mean carrying a bound nothing applies.
1087    #[test]
1088    fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1089        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1090        sandbox
1091            .limits
1092            .as_mut()
1093            .expect("the fixture declares limits")
1094            .max_processes = Some(256);
1095
1096        sandbox
1097            .validate_for_platform(Platform::Local)
1098            .expect("Docker takes a pids limit");
1099
1100        for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1101            let error = sandbox
1102                .validate_for_platform(platform)
1103                .expect_err("a process ceiling nothing applies must be refused");
1104            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1105        }
1106    }
1107
1108    /// Lambda rejects a run outside 1–28,800 rather than clamping it, so a value beyond that
1109    /// would pass planning, render into the package, and fail at the first session. Kubernetes
1110    /// takes the same field with no such bound, so the check is AWS's alone.
1111    #[test]
1112    fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1113        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1114
1115        for seconds in [0, 28_801, 100_000] {
1116            sandbox.session.max_lifetime_seconds = Some(seconds);
1117            let error = sandbox
1118                .validate_for_platform(Platform::Aws)
1119                .expect_err("a lifetime outside what AWS runs is refused");
1120            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1121
1122            // Kubernetes has no such ceiling, so the same declaration is fine there.
1123            sandbox
1124                .validate_for_platform(Platform::Kubernetes)
1125                .expect("the kubelet takes any activeDeadlineSeconds");
1126        }
1127
1128        sandbox.session.max_lifetime_seconds = Some(28_800);
1129        sandbox
1130            .validate_for_platform(Platform::Aws)
1131            .expect("the ceiling itself is allowed");
1132    }
1133
1134    /// A deadline is accepted only where the platform itself terminates on it — the kubelet's
1135    /// `activeDeadlineSeconds` and Lambda's `maximumDurationInSeconds`. Everywhere else it would
1136    /// need a reaper that does not exist, so it is refused rather than accepted and dropped.
1137    #[test]
1138    fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1139        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1140        sandbox.session.max_lifetime_seconds = Some(3600);
1141
1142        sandbox
1143            .validate_for_platform(Platform::Kubernetes)
1144            .expect("the kubelet enforces activeDeadlineSeconds");
1145        sandbox
1146            .validate_for_platform(Platform::Aws)
1147            .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1148
1149        for platform in [Platform::Azure, Platform::Local] {
1150            let error = sandbox
1151                .validate_for_platform(platform)
1152                .expect_err("a deadline nothing applies must be refused");
1153            assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1154        }
1155    }
1156
1157    /// A MicroVM bursts to four times its baseline with no way to opt out, so a ceiling is kept
1158    /// by choosing the size whose *peak* fits inside it. Sizing by baseline would hand back a
1159    /// sandbox that can reach four times what the customer declared.
1160    #[test]
1161    fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1162        let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1163        let tier = sandbox
1164            .microvm_tier()
1165            .expect("2Gi/1cpu/20Gi is satisfiable");
1166
1167        assert_eq!(
1168            tier.peak_memory_mib, 2048,
1169            "the peak is the declared ceiling"
1170        );
1171        assert_eq!(
1172            tier.baseline_memory_mib, 512,
1173            "which is a quarter of it as the baseline"
1174        );
1175        assert!(tier.max_disk_mib <= 20 * 1024);
1176    }
1177
1178    /// AWS allocates one vCPU per 2GB, so a cpu ceiling below what the memory ceiling implies
1179    /// cannot be honoured together with it. Letting cpu choose the size instead would hand back a
1180    /// machine four times smaller than the memory asked for, with nothing to indicate it.
1181    #[test]
1182    fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1183        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1184        {
1185            let limits = sandbox
1186                .limits
1187                .as_mut()
1188                .expect("the fixture declares limits");
1189            limits.cpu = "1".to_string();
1190            limits.memory = "8Gi".to_string();
1191        }
1192
1193        let error = sandbox
1194            .microvm_tier()
1195            .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1196        assert!(
1197            error.to_string().contains("4 vCPU"),
1198            "the refusal must say what the memory ceiling implies: {error}"
1199        );
1200
1201        sandbox
1202            .limits
1203            .as_mut()
1204            .expect("the fixture declares limits")
1205            .cpu = "4".to_string();
1206        let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1207        assert_eq!(tier.peak_memory_mib, 8192);
1208    }
1209
1210    /// Below AWS's smallest peak there is no size that holds the ceiling, and rounding up to the
1211    /// nearest one would silently exceed it.
1212    #[test]
1213    fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1214        let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1215        sandbox
1216            .limits
1217            .as_mut()
1218            .expect("the fixture declares limits")
1219            .memory = "1Gi".to_string();
1220
1221        let error = sandbox
1222            .validate_for_platform(Platform::Aws)
1223            .expect_err("no MicroVM size peaks at or below 1Gi");
1224        assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1225        assert!(
1226            error.to_string().contains("2Gi"),
1227            "the refusal must say what the smallest holdable ceiling is: {error}"
1228        );
1229    }
1230
1231    /// `Source` is a public part of the type that no backend builds. Kubernetes used to turn it
1232    /// into an empty image string, producing a pod that could never schedule — the refusal has to
1233    /// happen at plan time and on every platform, not in one emitter.
1234    #[test]
1235    fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1236        let sandbox = Sandbox::new("agent".to_string())
1237            .code(SandboxCode::Source {
1238                src: "./sandbox".to_string(),
1239                toolchain: ToolchainConfig::Docker {
1240                    dockerfile: None,
1241                    build_args: None,
1242                    target: None,
1243                },
1244            })
1245            .egress(SandboxEgress::Deny)
1246            .session(SandboxSessionPolicy {
1247                max_lifetime_seconds: None,
1248                idle_suspend_seconds: None,
1249            })
1250            .build();
1251
1252        for platform in [
1253            Platform::Aws,
1254            Platform::Azure,
1255            Platform::Gcp,
1256            Platform::Kubernetes,
1257            Platform::Local,
1258        ] {
1259            let error = sandbox
1260                .validate_for_platform(platform)
1261                .expect_err("no backend builds a sandbox image from source");
1262            assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1263            assert!(
1264                error.to_string().contains("code.image"),
1265                "the refusal must say what to write instead: {error}"
1266            );
1267        }
1268    }
1269
1270    /// `validate_quantity` accepts nine suffixes. Reading only `Gi` and `Mi` would size a
1271    /// declared `4G` as though it were `4Gi`, which for a ceiling means exceeding it.
1272    #[test]
1273    fn every_accepted_unit_converts_rather_than_falling_back() {
1274        assert_eq!(quantity_mib("2Gi"), Some(2048));
1275        assert_eq!(quantity_mib("512Mi"), Some(512));
1276        assert_eq!(quantity_mib("4G"), Some(3814));
1277        assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1278        assert_eq!(millicores("1"), Some(1000));
1279        assert_eq!(millicores("500m"), Some(500));
1280    }
1281
1282    #[test]
1283    fn unknown_fields_are_rejected() {
1284        let json = r#"{
1285            "id": "sbx",
1286            "code": {"type": "image", "image": "ubuntu:24.04"},
1287            "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1288            "egress": {"mode": "deny"},
1289            "session": {},
1290            "unexpected": true
1291        }"#;
1292
1293        serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1294    }
1295
1296    #[test]
1297    fn serialization_roundtrips() {
1298        let sandbox = sandbox_with(
1299            SandboxEgress::AllowDomains {
1300                domains: vec!["example.com".to_string()],
1301            },
1302            vec![8080, 9090],
1303        );
1304
1305        let json = serde_json::to_string(&sandbox).expect("serializes");
1306        let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1307        assert_eq!(sandbox, restored);
1308    }
1309
1310    #[test]
1311    fn id_is_immutable_across_updates() {
1312        let original = sandbox_with(SandboxEgress::Deny, vec![]);
1313        let renamed = Sandbox::new("other".to_string())
1314            .code(SandboxCode::Image {
1315                image: "ubuntu:24.04".to_string(),
1316            })
1317            .limits(
1318                original
1319                    .limits
1320                    .clone()
1321                    .expect("the fixture declares limits"),
1322            )
1323            .egress(SandboxEgress::Deny)
1324            .session(SandboxSessionPolicy {
1325                max_lifetime_seconds: None,
1326                idle_suspend_seconds: None,
1327            })
1328            .build();
1329
1330        original
1331            .validate_update(&original.clone())
1332            .expect("an unchanged config is a valid update");
1333        original
1334            .validate_update(&renamed)
1335            .expect_err("renaming a sandbox is not an update");
1336    }
1337}