Skip to main content

alien_bindings/providers/sandbox/
azure.rs

1//! Azure sandbox provider.
2//!
3//! The one backend with no Alien agent inside the sandbox: the ADC data plane implements exec,
4//! files and lifecycle natively, so this provider is a translation layer rather than a transport
5//! for a protocol. Verified against a stock `ubuntu` catalog disk containing no Alien code.
6
7use std::collections::BTreeMap;
8
9use async_trait::async_trait;
10use futures::stream::{self, BoxStream};
11
12use crate::error::{ErrorData, Result};
13use crate::providers::sandbox::{guard_for, Bounded, TimeoutReport};
14use crate::traits::{
15    Binding, CommandOutput, CreateSandboxRequest, JobPoll, JobStart, PreviewCapability,
16    ResolvedSandbox, RunCommandRequest, Sandbox, SandboxInstance, SandboxState,
17};
18use alien_azure_clients::azure::sandbox_data_plane::{
19    CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi,
20};
21use alien_client_core::ErrorData as ClientErrorData;
22use alien_core::{
23    azure_disk_image_label, classify_azure_sandbox_image, AzureSandboxImage, SandboxCapabilities,
24    SandboxCapability, SandboxEgress, AZURE_DISK_IMAGE_LABEL,
25};
26use alien_error::{AlienError, ContextError};
27use tracing::warn;
28
29/// A Sandbox backed by the Azure ADC data plane.
30#[derive(Debug)]
31pub struct AzureSandbox {
32    client: std::sync::Arc<dyn SandboxDataPlaneApi>,
33    sandbox_group: String,
34    /// Catalog name or registry image every sandbox is created from, from the declaration.
35    disk_image: String,
36    /// Id of the disk image built from a registry `disk_image`, found by label. A miss is not
37    /// cached (the controller may still be building it), and an id the data plane no longer has
38    /// is dropped so the next lookup finds its replacement.
39    disk_image_id: std::sync::Mutex<Option<String>>,
40    /// Outbound policy every sandbox is created with, from the declaration.
41    egress: SandboxEgress,
42    /// Idle seconds after which a sandbox suspends itself, if the declaration asked for one.
43    idle_pause_seconds: Option<u32>,
44    /// Sandbox ceilings, in the data plane's own units. Disk is optional because the data plane
45    /// derives one from the cpu when it is not sent, and that default is better than a guess.
46    cpu: String,
47    memory: String,
48    disk: Option<String>,
49}
50
51impl AzureSandbox {
52    /// Builds a provider bound to one sandbox group.
53    pub fn new(
54        client: std::sync::Arc<dyn SandboxDataPlaneApi>,
55        sandbox_group: String,
56        disk_image: String,
57        egress: SandboxEgress,
58        idle_pause_seconds: Option<u32>,
59        cpu: String,
60        memory: String,
61        disk: Option<String>,
62    ) -> Self {
63        Self {
64            client,
65            sandbox_group,
66            disk_image,
67            disk_image_id: std::sync::Mutex::new(None),
68            egress,
69            idle_pause_seconds,
70            cpu,
71            memory,
72            disk,
73        }
74    }
75
76    /// The built disk image a sandbox starts from, or `None` to send the value as a catalog name,
77    /// which is also what an unclassifiable hand-written binding gets: the data plane judges it.
78    async fn disk_image_id(&self) -> Result<Option<String>> {
79        let Some(AzureSandboxImage::Registry(reference)) =
80            classify_azure_sandbox_image(&self.disk_image)
81        else {
82            return Ok(None);
83        };
84        if let Some(id) = self.cached_disk_image_id() {
85            return Ok(Some(id));
86        }
87        let label = azure_disk_image_label(reference);
88        let images = self
89            .client
90            .list_disk_images(&self.sandbox_group)
91            .await
92            .map_err(|error| {
93                // A read before any create: a transport failure mints nothing, so it is never
94                // the create's unknown outcome.
95                if is_refusal(&error) {
96                    Self::failed(CREATE, error)
97                } else {
98                    error.context(ErrorData::SandboxUnreachable {
99                        operation: CREATE.to_string(),
100                        reason: format!(
101                            "could not list the disk images of sandbox group '{}'",
102                            self.sandbox_group
103                        ),
104                    })
105                }
106            })?;
107        let ours: Vec<_> = images
108            .into_iter()
109            .filter(|image| image.labels.get(AZURE_DISK_IMAGE_LABEL) == Some(&label))
110            .collect();
111        let Some(id) = ours
112            .iter()
113            .find(|image| image.state() == Some("Ready"))
114            .map(|image| image.id.clone())
115        else {
116            // Only a changed declaration or a retry of the failed resource builds again, so a
117            // caller retrying on its own would wait forever.
118            if !ours.is_empty() && ours.iter().all(|image| image.state() == Some("Failed")) {
119                return Err(AlienError::new(ErrorData::SandboxCommandFailed {
120                    failure: "diskImageFailed".to_string(),
121                    reason: format!(
122                        "the disk image built from '{reference}' in sandbox group '{}' failed; \
123                         the sandbox resource reports why",
124                        self.sandbox_group
125                    ),
126                }));
127            }
128            return Err(self.image_not_ready(reference));
129        };
130        *self.disk_image_id.lock().expect("disk image id lock") = Some(id.clone());
131        Ok(Some(id))
132    }
133
134    fn image_not_ready(&self, reference: &str) -> AlienError<ErrorData> {
135        AlienError::new(ErrorData::SandboxUnreachable {
136            operation: CREATE.to_string(),
137            reason: format!(
138                "no Ready disk image built from '{reference}' is in sandbox group '{}'",
139                self.sandbox_group
140            ),
141        })
142    }
143
144    fn cached_disk_image_id(&self) -> Option<String> {
145        self.disk_image_id
146            .lock()
147            .expect("disk image id lock")
148            .clone()
149    }
150
151    /// Starts a sandbox from the declared image. A cached disk image the data plane refuses as
152    /// gone (the controller retired it) is dropped and looked up once more: that refusal comes
153    /// back synchronously, so no sandbox was minted and the resend cannot duplicate one.
154    async fn create_from_image(
155        &self,
156        request: CreateSandbox,
157    ) -> Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox> {
158        let first = self
159            .client
160            .create_sandbox(&self.sandbox_group, request.clone())
161            .await;
162        match first {
163            Err(error) if request.disk_image_id.is_some() && disk_image_gone(&error) => {
164                *self.disk_image_id.lock().expect("disk image id lock") = None;
165                let id = self.disk_image_id().await?;
166                match self
167                    .client
168                    .create_sandbox(
169                        &self.sandbox_group,
170                        CreateSandbox {
171                            disk_image_id: id,
172                            ..request
173                        },
174                    )
175                    .await
176                {
177                    // The list still named a deleted image. Nothing was minted, and the next
178                    // create looks again, so this is the same wait as an image not built yet.
179                    Err(error) if disk_image_gone(&error) => {
180                        *self.disk_image_id.lock().expect("disk image id lock") = None;
181                        Err(error.context(ErrorData::SandboxUnreachable {
182                            operation: CREATE.to_string(),
183                            reason: format!(
184                                "the disk image of sandbox group '{}' was replaced; no \
185                                 replacement is listed yet",
186                                self.sandbox_group
187                            ),
188                        }))
189                    }
190                    other => other.map_err(|error| Self::failed(CREATE, error)),
191                }
192            }
193            other => other.map_err(|error| Self::failed(CREATE, error)),
194        }
195    }
196
197    /// The image sandboxes are created from. Exists so a test can prove the declaration
198    /// reached the provider — the failure it guards is silent, so nothing else would show it.
199    #[cfg(test)]
200    pub(crate) fn disk_image(&self) -> &str {
201        &self.disk_image
202    }
203
204    /// A sandbox id that stays one path segment.
205    ///
206    /// The id is interpolated into the data-plane URL, and `Url::parse` resolves `..` — so an id
207    /// carrying one addresses a different sandbox group, which a stack-scoped management identity
208    /// can reach. Azure mints ids itself; this bounds the ones a caller hands back.
209    fn checked_sandbox_id(operation: &str, sandbox_id: &str) -> Result<()> {
210        let usable = !sandbox_id.is_empty()
211            && sandbox_id.len() <= MAX_SANDBOX_ID
212            && sandbox_id
213                .chars()
214                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
215
216        if usable {
217            return Ok(());
218        }
219
220        Err(AlienError::new(ErrorData::InvalidInput {
221            operation_context: operation.to_string(),
222            details: format!(
223                "sandbox id '{sandbox_id}' must hold only letters, digits, '-' and '_', at most \
224                 {MAX_SANDBOX_ID} characters"
225            ),
226            field_name: Some("sandboxId".to_string()),
227        }))
228    }
229
230    /// A refusal that says why, because "not supported" tells a caller nothing about whether to
231    /// change the declaration, use another verb, or stop asking.
232    fn unsupported(&self, capability: &str, reason: &str) -> AlienError<ErrorData> {
233        AlienError::new(ErrorData::OperationNotSupported {
234            operation: capability.to_string(),
235            reason: reason.to_string(),
236        })
237    }
238
239    /// Sorts a data-plane failure into the two buckets every other backend uses.
240    ///
241    /// A refusal is a request the data plane understood and rejected, so repeating it repeats the
242    /// refusal. Anything else left the outcome unknown: for the idempotent file operations that is
243    /// worth another attempt, but `run_command` may already have started the command and must not
244    /// carry the retry signal. `reason` says only what this binding knows: `is_refusal` classifies
245    /// to public variants, and `into_external` passes a public error's source chain through
246    /// untouched, so the chain hides nothing a `reason` would have exposed.
247    fn failed(operation: &str, error: AlienError<ClientErrorData>) -> AlienError<ErrorData> {
248        if is_refusal(&error) {
249            return error.context(ErrorData::SandboxCommandFailed {
250                failure: "dataPlaneRefused".to_string(),
251                reason: format!("{operation} was refused; the cause carries which side refused"),
252            });
253        }
254
255        if operation == RUN_COMMAND || operation == CREATE {
256            return error.context(ErrorData::SandboxOutcomeUnknown {
257                operation: operation.to_string(),
258                reason: "the Azure sandbox data plane did not complete the call".to_string(),
259            });
260        }
261
262        error.context(ErrorData::SandboxUnreachable {
263            operation: operation.to_string(),
264            reason: "the Azure sandbox data plane did not complete the call".to_string(),
265        })
266    }
267}
268
269impl Binding for AzureSandbox {}
270
271/// The data plane answers a create naming a deleted disk image with a 400 `DiskImageNotFound`.
272fn disk_image_gone(error: &AlienError<ClientErrorData>) -> bool {
273    match &error.error {
274        Some(ClientErrorData::InvalidInput { message, .. }) => {
275            message.contains("DiskImageNotFound")
276        }
277        Some(ClientErrorData::RemoteResourceNotFound { .. }) => true,
278        _ => false,
279    }
280}
281
282/// Refused rather than dropped: the create body has nowhere to put a tenant key or a lifetime, so
283/// accepting either would report success while putting a caller's tenants in one shared sandbox,
284/// or leaving it running past the deadline it asked for. Azure does carry sandbox-level `env`.
285fn refuse_unsupported_create_fields(request: &CreateSandboxRequest, operation: &str) -> Result<()> {
286    if request.tenant_key.is_some() {
287        return Err(AlienError::new(ErrorData::OperationNotSupported {
288            operation: operation.to_string(),
289            reason: "Azure sandboxes take no tenantKey; create one sandbox per tenant instead"
290                .to_string(),
291        }));
292    }
293    if request.timeout_ms.is_some() {
294        return Err(AlienError::new(ErrorData::OperationNotSupported {
295            operation: SandboxCapability::SandboxLifetime.as_str().to_string(),
296            reason: "Azure sandboxes have auto-suspend and auto-delete but no wall-clock \
297                     ceiling; terminate the sandbox when the caller is done with it"
298                .to_string(),
299        }));
300    }
301    Ok(())
302}
303
304#[async_trait]
305impl Sandbox for AzureSandbox {
306    /// The platform's row, unnarrowed. `domainEgressRules` says what Azure can do, not what this
307    /// sandbox declared — narrowing it (as AWS does for `preview`) would read `false` on an
308    /// `allow` sandbox as "can't do this at all".
309    fn capabilities(&self) -> SandboxCapabilities {
310        SandboxCapabilities::azure()
311    }
312
313    async fn create(&self, request: CreateSandboxRequest) -> Result<SandboxInstance> {
314        checked_sandbox_env(CREATE, &request.env)?;
315        refuse_unsupported_create_fields(&request, CREATE)?;
316
317        let asked = egress_policy(&self.egress);
318        let disk_image_id = self.disk_image_id().await?;
319        let sandbox = self
320            .create_from_image(CreateSandbox {
321                disk_image: self.disk_image.clone(),
322                disk_image_id,
323                cpu: self.cpu.clone(),
324                memory: self.memory.clone(),
325                disk: self.disk.clone(),
326                environment: request.env,
327                egress: asked.clone(),
328                idle_pause_seconds: self.idle_pause_seconds,
329            })
330            .await?;
331
332        // The caller's requested id is not authoritative: Azure allocates the id, and returning
333        // the requested one would hand back a handle that addresses nothing. Checked because
334        // every later verb addresses the sandbox by it, and one this client cannot send is one
335        // nothing can reach or reap.
336        let _ = request.sandbox_id;
337        if Self::checked_sandbox_id(CREATE, &sandbox.id).is_err() {
338            let unreadable = AlienError::new(ErrorData::UnexpectedResponseFormat {
339                provider: "azure".to_string(),
340                binding_name: CREATE.to_string(),
341                field: "id".to_string(),
342                response_json: format!("{:?}", sandbox.id),
343            });
344
345            // Reaped unless the id is itself what makes the delete unsafe: a path separator or an
346            // escape would send that delete into another group. Everything else this check
347            // refuses — an over-long id, an unusual character — is still safe to address once,
348            // and refusing to reap it leaves a running sandbox no id-holder can find.
349            // An allowlist, because the hazard is anything the URL parser reads differently:
350            // `abc?x` starts a query string, so the delete would land on the sandbox named `abc`.
351            let addressable = !sandbox.id.is_empty()
352                && sandbox
353                    .id
354                    .chars()
355                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
356
357            return Err(if !addressable {
358                warn!(
359                    sandbox = %sandbox.id,
360                    "the data plane minted an id this client will not send; the sandbox is \
361                     running and cannot be deleted through this binding"
362                );
363                unreadable
364            } else {
365                self.discard(&sandbox.id, unreadable).await
366            });
367        }
368
369        // Everything past this point owns a sandbox the caller has no id for, so every failure
370        // deletes it. Azure allocates the id, so the one in this response was minted by this call.
371        match self.settle(&sandbox).await {
372            Ok(sandbox) => Ok(sandbox),
373            Err(error) => Err(self.discard(&sandbox.id, error).await),
374        }
375    }
376
377    async fn get(&self, sandbox_id: &str) -> Result<Option<SandboxInstance>> {
378        Self::checked_sandbox_id("sandbox.get", sandbox_id)?;
379        // A 404 is "gone", which is a valid answer. Anything else is a real failure and must not
380        // be flattened into None, or a throttle would read as an expired sandbox.
381        let Some(sandbox) = self.read_sandbox("sandbox.get", sandbox_id).await? else {
382            return Ok(None);
383        };
384
385        let state = sandbox_state("sandbox.get", sandbox.state.as_deref())?;
386
387        // This is the path a reconnect takes: a sandbox outlives the declaration it was created
388        // under, so a caller holding its id would otherwise be handed whatever containment it was
389        // built with. Only the two ends of the lifecycle carry no policy, and that is not a
390        // mismatch.
391        self.judge_if_judgeable(&sandbox)?;
392
393        Ok(Some(SandboxInstance {
394            sandbox_id: sandbox.id,
395            state,
396            generation: 1,
397        }))
398    }
399
400    async fn get_or_create(&self, request: CreateSandboxRequest) -> Result<ResolvedSandbox> {
401        if let Some(id) = request.sandbox_id.as_deref() {
402            // `create` returns a sandbox that can take work, and reaching one someone else
403            // started has to mean the same thing — so the same gate every other verb uses: bring
404            // it up, judge it there, and refuse it if it does not match.
405            match self.reconnect(id).await {
406                Ok(sandbox) => return Ok(ResolvedSandbox::found(sandbox)),
407                // The two ways an id can fail to serve — gone, or running a policy the
408                // declaration no longer matches — mean the same thing to a caller asking for a
409                // sandbox, and are answered the same way: a fresh one. A sandbox refused for its
410                // policy is left as it was found — asleep again if this call woke it — because it
411                // may be another revision's, and this caller is served by the replacement rather
412                // than by taking theirs.
413                //
414                // Narrow on purpose: a readiness timeout says the data plane is slow, and
415                // answering that by creating a second sandbox makes it slower.
416                Err(error)
417                    if error.code == "SANDBOX_NOT_AS_DECLARED"
418                        || matches!(
419                            &error.error,
420                            Some(ErrorData::SandboxCommandFailed { failure, .. })
421                                if failure == "sandboxGone" || failure == "sandboxTerminated"
422                        ) => {}
423                Err(error) => return Err(error),
424            }
425        }
426
427        self.create(request).await.map(ResolvedSandbox::created)
428    }
429
430    async fn list(&self) -> Result<Vec<SandboxInstance>> {
431        Err(self.unsupported(
432            "sandbox.list",
433            "enumerating sandboxes is a control-plane read the data-plane role does not carry; \
434             reach a known sandbox with get",
435        ))
436    }
437
438    async fn run_command(
439        &self,
440        sandbox_id: &str,
441        request: RunCommandRequest,
442    ) -> Result<BoxStream<'static, Result<CommandOutput>>> {
443        Self::checked_sandbox_id(RUN_COMMAND, sandbox_id)?;
444        if request.timeout.is_zero() {
445            return Err(AlienError::new(ErrorData::OperationNotSupported {
446                operation: "sandbox.runCommand".to_string(),
447                reason: "a command must carry a non-zero timeout".to_string(),
448            }));
449        }
450
451        // The only verb that starts untrusted code, so it is the one that re-reads the policy: a
452        // sandbox id outlives a declaration change, and nothing else stands between an id a
453        // caller kept and the egress it was built with. One extra read against a data plane the
454        // command itself is about to cross.
455        self.judged_sandbox(RUN_COMMAND, sandbox_id).await?;
456
457        // The timeout bounds the untrusted code, not the caller's patience. Read out of the
458        // preview SDK rather than assumed: `executeShellCommand` sends `command` and an optional
459        // `workingDirectory` and nothing else, so there is no server-side timeout to ask for. The
460        // timeout is enforced inside the sandbox instead — the wrapper kills the command at it,
461        // so the sandbox survives and the call lands right after, the same shape the
462        // agent-supervised backends give. The client-side guard is the backstop for a data plane
463        // that never answers at all; there the only lever left is ending the sandbox, and that
464        // call returns once the sandbox is confirmed gone rather than claim containment early.
465        // The data plane's exec takes a command and a working directory and nothing else, so a
466        // per-command variable travels through `env` in the argv — which keeps it off the shell
467        // that bounds the command. Names are checked so `env` will take them as variables.
468        if request.command.is_empty() {
469            return Err(AlienError::new(ErrorData::InvalidInput {
470                operation_context: RUN_COMMAND.to_string(),
471                details: "a command must name a program to run".to_string(),
472                field_name: Some("command".to_string()),
473            }));
474        }
475
476        for name in request.env.keys() {
477            checked_env_name(RUN_COMMAND, name)?;
478        }
479        // `env` takes operands as assignments until one is not, so a program whose own name
480        // carries `=` would be read as a variable and the next argument run in its place. Only
481        // the program: `env` stops reading assignments at it, so an `=` in a later argument is
482        // the command's own business.
483        if !request.env.is_empty() && request.command.contains('=') {
484            let program = &request.command;
485            return Err(AlienError::new(ErrorData::InvalidInput {
486                operation_context: RUN_COMMAND.to_string(),
487                details: format!(
488                    "command '{program}' cannot carry '=' in its name while the call also \
489                     declares environment variables"
490                ),
491                field_name: Some("command".to_string()),
492            }));
493        }
494        let shell = bounded_shell(&request.argv(), &request.env, request.timeout);
495
496        let result = self.execute_within(sandbox_id, &shell, &request).await?;
497        // The sandbox's own report, removed from what the caller sees.
498        let (timeout_exceeded, stderr) = match TimeoutReport::read(result.exit_code, &result.stderr)
499        {
500            Bounded::Ran { killed, stderr } => (killed, stderr),
501            Bounded::NotRun { reason } => {
502                return Err(AlienError::new(ErrorData::SandboxCommandFailed {
503                    failure: "commandNotBounded".to_string(),
504                    reason,
505                }))
506            }
507        };
508
509        // The data plane returns a completed result, not a stream, so the frames are
510        // reconstructed in order. Streaming is unverified on Azure, and pretending otherwise
511        // here would be inventing a guarantee.
512        let mut frames: Vec<Result<CommandOutput>> = Vec::new();
513        if !result.stdout.is_empty() {
514            frames.push(Ok(CommandOutput::Stdout {
515                seq: 0,
516                data: result.stdout.into_bytes(),
517            }));
518        }
519        if !stderr.is_empty() {
520            frames.push(Ok(CommandOutput::Stderr {
521                seq: frames.len() as u64,
522                data: stderr.into_bytes(),
523            }));
524        }
525
526        if timeout_exceeded {
527            // The output is kept and the terminal item says why it ends, as the agent-backed
528            // providers do; the sandbox is untouched.
529            frames.push(Err(AlienError::new(ErrorData::SandboxCommandFailed {
530                failure: "timeoutExceeded".to_string(),
531                reason: format!(
532                    "the command exceeded its {}ms timeout and was killed; the sandbox is still usable",
533                    request.timeout.as_millis()
534                ),
535            })));
536        } else {
537            match result.exit_code {
538                Some(code) => frames.push(Ok(CommandOutput::Exit {
539                    code,
540                    truncated: false,
541                })),
542                // Azure reported no exit code, so the command's outcome was never established.
543                // Any invented code is indistinguishable from one the command really exited with.
544                None => frames.push(Err(AlienError::new(ErrorData::SandboxOutcomeUnknown {
545                    operation: RUN_COMMAND.to_string(),
546                    reason: "the data plane returned no exit code for the command".to_string(),
547                }))),
548            }
549        }
550
551        Ok(Box::pin(stream::iter(frames)))
552    }
553
554    /// Ungated on purpose: reading existing content adds nothing to a sandbox, so it cannot
555    /// turn a stale sandbox into a way to run something under egress the declaration has since
556    /// removed.
557    async fn read_file(&self, sandbox_id: &str, path: &str) -> Result<Vec<u8>> {
558        Self::checked_sandbox_id("sandbox.readFile", sandbox_id)?;
559        let path = &checked_path("sandbox.readFile", path)?;
560
561        self.client
562            .read_file(&self.sandbox_group, sandbox_id, path)
563            .await
564            .map_err(|error| Self::failed("sandbox.readFile", error))
565    }
566
567    async fn write_files(&self, sandbox_id: &str, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
568        Self::checked_sandbox_id("sandbox.writeFiles", sandbox_id)?;
569        // Checked before anything is written, and before anything is read: partial application is
570        // the contract for a data plane that refuses midway, not for a path this process could
571        // have rejected without a round trip.
572        let files = files
573            .into_iter()
574            .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents)))
575            .collect::<Result<Vec<_>>>()?;
576
577        // The one file operation that moves the caller's own content in. A write-then-run against
578        // an id kept across a tightened declaration would land the payload in a sandbox with the
579        // egress the declaration just removed, and the refusal would arrive a beat later.
580        self.judged_sandbox("sandbox.writeFiles", sandbox_id)
581            .await?;
582
583        // One request per path, stopping at the first failure: the same partial application every
584        // other backend performs, so a caller sees one contract rather than five.
585        for (path, contents) in files {
586            self.client
587                .write_file(&self.sandbox_group, sandbox_id, &path, contents)
588                .await
589                .map_err(|error| Self::failed("sandbox.writeFiles", error))?;
590        }
591
592        Ok(())
593    }
594
595    async fn preview(&self, _sandbox_id: &str, _port: u16) -> Result<PreviewCapability> {
596        Err(self.unsupported(
597            "sandbox.preview",
598            "an Azure sandbox port is either published to the internet or gated on an interactive \
599             Entra login; neither is a port-scoped credential with an expiry",
600        ))
601    }
602
603    async fn pause(&self, sandbox_id: &str) -> Result<()> {
604        Self::checked_sandbox_id("sandbox.pause", sandbox_id)?;
605        const OPERATION: &str = "sandbox.pause";
606        // Accepted, not completed — the same contract the AWS backend follows. `get` reports
607        // `Suspended` from the moment the stop is under way, so it answers "cannot take work",
608        // not "has stopped"; only `terminate` confirms a sandbox is actually gone.
609        let Err(error) = self
610            .client
611            .stop_sandbox(&self.sandbox_group, sandbox_id)
612            .await
613        else {
614            return Ok(());
615        };
616
617        // A lost or transient response leaves the outcome unknown. Read the record: a sandbox
618        // that is gone or already suspended means the stop took effect, so report success rather
619        // than a failure a retry would only see refused. A still-running one means it did not land.
620        match self.read_sandbox(OPERATION, sandbox_id).await? {
621            None => Ok(()),
622            Some(found) => match sandbox_state(OPERATION, found.state.as_deref())? {
623                SandboxState::Paused => Ok(()),
624                _ => Err(Self::failed(OPERATION, error)),
625            },
626        }
627    }
628
629    async fn resume(&self, sandbox_id: &str) -> Result<()> {
630        Self::checked_sandbox_id("sandbox.resume", sandbox_id)?;
631        const OPERATION: &str = "sandbox.resume";
632
633        let Some(found) = self.read_sandbox(OPERATION, sandbox_id).await? else {
634            return Err(AlienError::new(ErrorData::SandboxCommandFailed {
635                failure: "sandboxGone".to_string(),
636                reason: format!("{OPERATION}: sandbox '{sandbox_id}' does not exist"),
637            }));
638        };
639
640        // Refused from the record already in hand where that record answers it, so a sandbox
641        // whose stored policy is plainly wrong is never put back on the network for a boot.
642        self.judge_if_judgeable(&found)?;
643
644        // Judged again after the wake: the stopped record is not the one the work runs under, and
645        // a policy set on the group can change while a sandbox sleeps.
646        let mut resumed_here = false;
647        let woken = self
648            .await_running(OPERATION, sandbox_id, &mut resumed_here)
649            .await;
650
651        let refusal = match woken {
652            Err(error) => error,
653            Ok(running) => match self.policy_must_hold(&running) {
654                Ok(()) => return Ok(()),
655                Err(error) => error,
656            },
657        };
658        Err(self.put_back(sandbox_id, resumed_here, refusal).await)
659    }
660
661    async fn snapshot(&self, _sandbox_id: &str) -> Result<String> {
662        Err(self.unsupported(
663            "sandbox.snapshot",
664            "this client sends no snapshot request, and nothing owns the artifact once taken",
665        ))
666    }
667
668    async fn start_job(&self, _sandbox_id: &str, _request: RunCommandRequest) -> Result<JobStart> {
669        Err(self.unsupported("sandbox.jobStart", NO_JOB_HOST))
670    }
671
672    async fn poll_job(
673        &self,
674        _sandbox_id: &str,
675        _job_id: &str,
676        _since_seq: Option<u64>,
677    ) -> Result<JobPoll> {
678        Err(self.unsupported("sandbox.jobPoll", NO_JOB_HOST))
679    }
680
681    async fn cancel_job(&self, _sandbox_id: &str, _job_id: &str) -> Result<()> {
682        Err(self.unsupported("sandbox.jobCancel", NO_JOB_HOST))
683    }
684
685    async fn terminate(&self, sandbox_id: &str) -> Result<()> {
686        Self::checked_sandbox_id("sandbox.terminate", sandbox_id)?;
687        self.accept_delete(sandbox_id).await?;
688
689        // The delete is accepted, not completed: the client's own contract is "returns before it
690        // is gone; confirm by polling to 404". Returning here would report containment while the
691        // code is still running, which is the whole point of terminate.
692        // The client rather than `get`: teardown needs the 404 and nothing else, and reading a
693        // state it cannot parse would abort the poll for a sandbox that is already going away —
694        // replacing a `timeoutExceeded` finding with a deserialization error on the one path
695        // where untrusted code is known to be running past its deadline.
696        for _ in 0..TERMINATE_POLL_ATTEMPTS {
697            // A read that fails is not a sandbox that is gone, and it is not a reason to stop
698            // looking either: the attempt budget decides, so one throttled response cannot end
699            // the poll that turns an accepted delete into a confirmed one.
700            if let Err(error) = self
701                .client
702                .get_sandbox(&self.sandbox_group, sandbox_id)
703                .await
704            {
705                if is_not_found(&error) {
706                    return Ok(());
707                }
708                warn!(sandbox = %sandbox_id, %error, "could not confirm a sandbox is gone");
709            }
710            tokio::time::sleep(TERMINATE_POLL_INTERVAL).await;
711        }
712
713        Err(AlienError::new(ErrorData::SandboxUnreachable {
714            operation: "sandbox.terminate".to_string(),
715            reason: format!(
716                "deletion of '{sandbox_id}' was accepted but the sandbox was still present after {}s; it may still be running",
717                TERMINATE_POLL_ATTEMPTS * TERMINATE_POLL_INTERVAL.as_secs() as u32
718            ),
719        }))
720    }
721
722    fn as_any(&self) -> &dyn std::any::Any {
723        self
724    }
725}
726
727impl AzureSandbox {
728    /// Brings a sandbox the caller named back into service, or says why it cannot be.
729    ///
730    /// The one path that replaces rather than only refusing: `get_or_create` asked for a usable
731    /// sandbox, so an id that cannot serve becomes a fresh sandbox rather than an error the
732    /// caller has no way to act on. Only a `Failed` sandbox is deleted here — one refused for its
733    /// policy is left alone, because the group is shared and it may be in use.
734    async fn reconnect(&self, sandbox_id: &str) -> Result<SandboxInstance> {
735        let gone = || {
736            AlienError::new(ErrorData::SandboxCommandFailed {
737                failure: "sandboxGone".to_string(),
738                reason: format!("{GET_OR_CREATE}: sandbox '{sandbox_id}' cannot take work"),
739            })
740        };
741
742        let found = match self.read_sandbox(GET_OR_CREATE, sandbox_id).await? {
743            // A failed sandbox is not going away on its own, and the caller asked for a sandbox
744            // rather than for this one, so it is reaped rather than left beside its replacement.
745            Some(sandbox) if sandbox.state.as_deref() == Some("Failed") => {
746                return Err(self.discard(sandbox_id, gone()).await)
747            }
748            Some(sandbox) if sandbox.state.as_deref() != Some("Deleting") => sandbox,
749            _ => return Err(gone()),
750        };
751
752        // Judged asleep first: waking one that already fails puts its workload back on the network
753        // for a boot. Refused rather than deleted, here and after the wake: the policy mismatch
754        // may belong to another revision, mid-command in the shared group.
755        self.judge_if_judgeable(&found)?;
756
757        // Judged again once it is up: only the woken record covers a sandbox that was still coming
758        // up, or a policy set on the group while it slept.
759        let mut resumed_here = false;
760        let running = match self
761            .await_running(GET_OR_CREATE, sandbox_id, &mut resumed_here)
762            .await
763        {
764            Ok(running) => running,
765            Err(error) => return Err(self.put_back(sandbox_id, resumed_here, error).await),
766        };
767        if let Err(error) = self.policy_must_hold(&running) {
768            return Err(self.put_back(sandbox_id, resumed_here, error).await);
769        }
770
771        Ok(SandboxInstance {
772            sandbox_id: running.id,
773            state: SandboxState::Running,
774            generation: 1,
775        })
776    }
777
778    /// Reads a sandbox that is fit to be used, refusing one that is not.
779    ///
780    /// Refuses rather than repairs: a sandbox this binding did not create and the caller did not
781    /// ask to replace is not this call's to destroy. Two revisions of a stack share a sandbox
782    /// group, so a tightened one reaping a sandbox the other is mid-command on would be an
783    /// outage caused by a read.
784    ///
785    /// Requires the sandbox to be running, because that is the only state carrying a policy
786    /// worth judging — and waking one to write into it would undo the idle suspend the
787    /// declaration asked for.
788    async fn judged_sandbox(&self, operation: &str, sandbox_id: &str) -> Result<()> {
789        let refuse = |failure: &str, why: &str| {
790            Err(AlienError::new(ErrorData::SandboxCommandFailed {
791                failure: failure.to_string(),
792                reason: format!("{operation}: sandbox '{sandbox_id}' {why}"),
793            }))
794        };
795
796        let Some(sandbox) = self.read_sandbox(operation, sandbox_id).await? else {
797            return refuse("sandboxGone", "does not exist");
798        };
799
800        match sandbox.state.as_deref() {
801            Some("Running") => {}
802            Some("Creating" | "Resuming") => {
803                return refuse("sandboxNotReady", "is still starting; wait for it to run")
804            }
805            Some("Deleting") => return refuse("sandboxGone", "is being deleted"),
806            Some("Failed") => return refuse("sandboxGone", "has failed"),
807            Some("Stopping") => return refuse("sandboxPaused", "is stopping; wait for it"),
808            Some("Stopped" | "Suspended" | "Idle") => {
809                return refuse("sandboxPaused", "is suspended; resume it first")
810            }
811            // Unreadable rather than suspended, which would send a caller to `resume` for an
812            // answer it cannot give. The refusal below is reached only if the two state lists
813            // drift apart, and refusing is the safe side of that.
814            other => {
815                sandbox_state(operation, other)?;
816                return refuse("sandboxNotReady", "is in a state this client cannot read");
817            }
818        }
819
820        self.policy_must_hold(&sandbox)
821    }
822
823    /// Reads a sandbox, or `None` when it is gone, without judging its policy.
824    async fn read_sandbox(
825        &self,
826        operation: &str,
827        sandbox_id: &str,
828    ) -> Result<Option<alien_azure_clients::azure::sandbox_data_plane::Sandbox>> {
829        match self
830            .client
831            .get_sandbox(&self.sandbox_group, sandbox_id)
832            .await
833        {
834            Ok(sandbox) => Ok(Some(sandbox)),
835            Err(error) if is_not_found(&error) => Ok(None),
836            Err(error) => Err(Self::failed(operation, error)),
837        }
838    }
839
840    /// Wakes a sandbox without judging it, for the wait that has nothing to judge yet.
841    async fn resume_unchecked(&self, sandbox_id: &str) -> Result<()> {
842        self.client
843            .resume_sandbox(&self.sandbox_group, sandbox_id)
844            .await
845            .map_err(|error| Self::failed("sandbox.resume", error))
846    }
847
848    /// Refuses a sandbox that is not running the policy the declaration asked for.
849    ///
850    /// The effective policy can change under a live sandbox — a group-scoped policy is set
851    /// somewhere this binding never writes — so every path that hands one back checks, not just
852    /// the one that created it.
853    fn policy_must_hold(
854        &self,
855        sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
856    ) -> Result<()> {
857        let Some(asked) = egress_policy(&self.egress) else {
858            return Ok(());
859        };
860        if policy_holds(&asked, sandbox.egress_policy.as_ref()) {
861            return Ok(());
862        }
863
864        Err(AlienError::new(ErrorData::SandboxNotAsDeclared {
865            sandbox_id: sandbox.id.clone(),
866            restriction: "egress policy".to_string(),
867            reason: format!(
868                "it is running {} where the declaration asks for {}",
869                describe(sandbox.egress_policy.as_ref()),
870                describe(Some(&asked))
871            ),
872        }))
873    }
874
875    /// Turns a freshly created record into a usable sandbox, or says why it is not one.
876    ///
877    /// Every check that can fail after the sandbox exists lives here, so `create` has one place
878    /// to delete from rather than a delete beside each `?`.
879    async fn settle(
880        &self,
881        sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
882    ) -> Result<SandboxInstance> {
883        // The running sandbox is what gets judged, not the accept: a create response sent while
884        // the sandbox is still coming up need not carry the policy yet, and reading its absence
885        // as "the restriction did not take" would delete every sandbox that answered early.
886        let mut resumed_here = false;
887        let running = self
888            .await_running(CREATE, &sandbox.id, &mut resumed_here)
889            .await?;
890
891        // A restriction that did not take effect is worse than one that was never asked for: the
892        // caller believes the sandbox is contained.
893        self.policy_must_hold(&running)?;
894
895        Ok(SandboxInstance {
896            sandbox_id: running.id,
897            state: SandboxState::Running,
898            generation: 1,
899        })
900    }
901
902    /// Waits for a sandbox to be able to take work.
903    ///
904    /// The operation is the caller's, not this function's: a reconnect that waits is still a
905    /// reconnect, and reporting it as a create would mark a repeatable read unrepeatable.
906    ///
907    /// A suspended sandbox is resumed rather than waited on — on the create path an idle policy
908    /// can stop a sandbox before its first command, and on the reconnect path a stopped sandbox
909    /// is the ordinary resting state. Nothing else brings one up, so waiting alone would spend
910    /// the whole deadline and then delete it.
911    async fn await_running(
912        &self,
913        operation: &str,
914        sandbox_id: &str,
915        resumed_here: &mut bool,
916    ) -> Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox> {
917        let deadline = std::time::Instant::now() + SANDBOX_READY_TIMEOUT;
918        let mut refusal: Option<String> = None;
919
920        loop {
921            let Some(sandbox) = self.read_sandbox(operation, sandbox_id).await? else {
922                return Err(AlienError::new(ErrorData::SandboxCommandFailed {
923                    failure: "sandboxGone".to_string(),
924                    reason: format!("{operation}: sandbox '{sandbox_id}' disappeared while it was being waited for"),
925                }));
926            };
927
928            // The raw state, because the four the trait publishes cannot separate a sandbox on
929            // its way up from one on its way down, and this loop needs that difference.
930            match sandbox.state.as_deref() {
931                Some("Running") => return Ok(sandbox),
932                Some("Creating" | "Resuming") => {}
933                // Still going down. Resume is refused in this state — the SDK's own resumable
934                // set excludes it — so the wait is for `Stopped`, not for the call to work.
935                Some("Stopping") => {}
936                // Re-issued on every poll, because the attempt most likely to be refused is the
937                // first one: remembering only that an attempt was made would spend the whole
938                // budget watching a sandbox nothing is bringing up.
939                Some("Stopped" | "Suspended" | "Idle") => {
940                    match self.resume_unchecked(sandbox_id).await {
941                        Ok(()) => {
942                            refusal = None;
943                            *resumed_here = true;
944                        }
945                        Err(error) => {
946                            let failure = match &error.error {
947                                Some(ErrorData::SandboxCommandFailed { failure, .. }) => {
948                                    failure.clone()
949                                }
950                                _ => error.code.clone(),
951                            };
952                            // A refusal is the one answer that proves the sandbox did not wake.
953                            // Anything else — a 5xx, a timeout, a dropped connection — leaves the
954                            // outcome unknown, and an unknown wake is one this call owns.
955                            if failure != "dataPlaneRefused" {
956                                *resumed_here = true;
957                            }
958                            warn!(sandbox = %sandbox_id, %error, "resume was refused; still waiting");
959                            refusal = Some(failure);
960                        }
961                    }
962                }
963                // A terminated sandbox never becomes runnable, and folding it into the timeout
964                // would report it a minute late as a slow boot.
965                other => {
966                    let state = sandbox_state(operation, other)?;
967                    return Err(AlienError::new(ErrorData::SandboxCommandFailed {
968                        failure: "sandboxTerminated".to_string(),
969                        reason: format!(
970                            "sandbox '{sandbox_id}' reached {state:?} and will not run again"
971                        ),
972                    }));
973                }
974            }
975
976            if std::time::Instant::now() >= deadline {
977                // The last refusal, because "not running after 120s" sends a reader looking for a
978                // slow data plane when the answer is that every resume was rejected.
979                return Err(AlienError::new(ErrorData::SandboxCommandFailed {
980                    failure: "sandboxNotReady".to_string(),
981                    reason: match refusal {
982                        Some(code) => format!(
983                            "sandbox '{sandbox_id}' was still not running after {}s; the last \
984                             resume was refused with {code}",
985                            SANDBOX_READY_TIMEOUT.as_secs()
986                        ),
987                        None => format!(
988                            "sandbox '{sandbox_id}' was still not running after {}s",
989                            SANDBOX_READY_TIMEOUT.as_secs()
990                        ),
991                    },
992                }));
993            }
994            tokio::time::sleep(SANDBOX_READY_INTERVAL).await;
995        }
996    }
997
998    /// Whether a record carries a policy this client can hold it to.
999    ///
1000    /// A running sandbox always reports its effective policy, so an absent one there is a
1001    /// mismatch. Off that state the data plane's behaviour is unverified, and reading absence as
1002    /// a mismatch would refuse every idle-suspended sandbox; the read taken after the wake is
1003    /// authoritative either way.
1004    fn judgeable(sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox) -> bool {
1005        match sandbox.state.as_deref() {
1006            Some("Running") => true,
1007            Some("Stopping" | "Stopped" | "Suspended" | "Idle") => sandbox.egress_policy.is_some(),
1008            // The two ends of the lifecycle and anything unread: one has no policy yet, the other
1009            // has dropped it, and a state this client cannot name is refused before it gets here.
1010            _ => false,
1011        }
1012    }
1013
1014    fn judge_if_judgeable(
1015        &self,
1016        sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
1017    ) -> Result<()> {
1018        if Self::judgeable(sandbox) {
1019            self.policy_must_hold(sandbox)?;
1020        }
1021        Ok(())
1022    }
1023
1024    /// Re-suspends a sandbox this call woke, keeping the reason it is being refused.
1025    ///
1026    /// Only a sandbox this call woke: another revision of the same stack shares the sandbox
1027    /// group, and stopping one that was already up ends a command that revision is mid-way
1028    /// through. A stop that fails is named rather than logged — a sandbox this call put back on
1029    /// the network under a policy the declaration does not allow is not "nothing happened".
1030    async fn put_back(
1031        &self,
1032        sandbox_id: &str,
1033        resumed_here: bool,
1034        reason: AlienError<ErrorData>,
1035    ) -> AlienError<ErrorData> {
1036        if !resumed_here {
1037            return reason;
1038        }
1039        let Err(failed) = self
1040            .client
1041            .stop_sandbox(&self.sandbox_group, sandbox_id)
1042            .await
1043        else {
1044            return reason;
1045        };
1046        // A sandbox that is already gone is the state this was trying to reach, and reporting it
1047        // as left awake sends an operator looking for a sandbox that does not exist.
1048        if is_not_found(&failed) {
1049            return reason;
1050        }
1051
1052        warn!(sandbox = %sandbox_id, error = %failed, "could not re-suspend a sandbox this call woke");
1053        reason.context(ErrorData::SandboxCommandFailed {
1054            failure: "sandboxLeftAwake".to_string(),
1055            reason: format!(
1056                "sandbox '{sandbox_id}' was woken by this call, could not be handed back, and \
1057                 could not be put to sleep again"
1058            ),
1059        })
1060    }
1061
1062    /// Deletes a sandbox the caller will never receive, keeping the reason it is being discarded.
1063    ///
1064    /// The delete's own failure must not replace that reason — it is the finding that matters —
1065    /// but it must not vanish either: the sandbox id is in the error, and a failed delete leaves
1066    /// a sandbox only that id can find.
1067    async fn discard(
1068        &self,
1069        sandbox_id: &str,
1070        reason: AlienError<ErrorData>,
1071    ) -> AlienError<ErrorData> {
1072        let Err(error) = self.accept_delete(sandbox_id).await else {
1073            return reason;
1074        };
1075
1076        warn!(
1077            sandbox = %sandbox_id,
1078            %error,
1079            "could not delete a sandbox that was never handed to its caller"
1080        );
1081        // Names the leak rather than the reason for it: a timeout and a policy mismatch both
1082        // reach here, and reporting either as the other sends the reader somewhere false. The
1083        // original reason stays on the chain. The clause is fixed text, because the delete's own
1084        // error is the cloud client's and this variant is externally visible.
1085        reason.context(ErrorData::SandboxCommandFailed {
1086            failure: "sandboxLeftBehind".to_string(),
1087            reason: format!(
1088                "sandbox '{sandbox_id}' was not handed to its caller and could not be deleted, \
1089                 so it is still running"
1090            ),
1091        })
1092    }
1093
1094    /// Runs one shell string under the client-side guard, which is the timeout plus the grace
1095    /// the in-sandbox `timeout` needs to report back. See `run_command` for why the timeout is
1096    /// enforced inside the sandbox.
1097    ///
1098    /// Reached only by a sandbox that could not run `timeout`, so it is the one path where
1099    /// untrusted code is known to be overrunning: the sandbox is ended and the call returns once
1100    /// that is confirmed, because `timeoutExceeded` has to mean the command stopped rather than
1101    /// that a stop was asked for.
1102    async fn execute_within(
1103        &self,
1104        sandbox_id: &str,
1105        command: &str,
1106        request: &RunCommandRequest,
1107    ) -> Result<alien_azure_clients::azure::sandbox_data_plane::ExecResult> {
1108        match tokio::time::timeout(
1109            guard_for(request.timeout)?,
1110            self.client.execute_shell_command(
1111                &self.sandbox_group,
1112                sandbox_id,
1113                command,
1114                request.cwd.clone(),
1115            ),
1116        )
1117        .await
1118        {
1119            Ok(inner) => inner.map_err(|error| Self::failed(RUN_COMMAND, error)),
1120            Err(_) => Err(match self.terminate(sandbox_id).await {
1121                Ok(()) => AlienError::new(ErrorData::SandboxCommandFailed {
1122                    failure: "timeoutExceeded".to_string(),
1123                    reason: format!(
1124                        "the command exceeded its {}ms timeout and the sandbox could not end it, so the sandbox was terminated",
1125                        request.timeout.as_millis()
1126                    ),
1127                }),
1128                // A terminate that itself fails leaves the command even more likely to be
1129                // running, so the outcome is unreported rather than established. Returning the
1130                // terminate's own error would mark this retryable, and a retry would start the
1131                // command a second time beside the one still going.
1132                Err(error) => error.context(ErrorData::SandboxOutcomeUnknown {
1133                    operation: "sandbox.runCommand".to_string(),
1134                    reason: format!(
1135                        "the command exceeded its {}ms timeout and the sandbox could not end it",
1136                        request.timeout.as_millis()
1137                    ),
1138                }),
1139            }),
1140        }
1141    }
1142
1143    /// Asks Azure to delete the sandbox and returns once the request is accepted.
1144    ///
1145    /// An already-gone sandbox is the desired end state. Every other failure leaves the sandbox
1146    /// running, and reporting success there tells the caller untrusted code has stopped when it
1147    /// has not.
1148    async fn accept_delete(&self, sandbox_id: &str) -> Result<()> {
1149        match self
1150            .client
1151            .delete_sandbox(&self.sandbox_group, sandbox_id)
1152            .await
1153        {
1154            Ok(_) => Ok(()),
1155            Err(error) if is_not_found(&error) => Ok(()),
1156            Err(error) => Err(Self::failed("sandbox.terminate", error)),
1157        }
1158    }
1159}
1160
1161/// How long termination waits for Azure to actually remove a sandbox.
1162///
1163/// Azure accepts a delete and completes it asynchronously, so "gone" is only observable by
1164/// polling. Bounded rather than open-ended: a caller waiting forever is its own outage, and an
1165/// unconfirmed deletion is reported as unconfirmed rather than silently treated as done.
1166const TERMINATE_POLL_ATTEMPTS: u32 = 15;
1167const TERMINATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
1168
1169/// The command, bounded inside the sandbox.
1170///
1171/// The data plane takes one shell string, so the command is passed to `sh` as arguments rather
1172/// than pasted into the program text: `"$@"` cannot re-parse what it holds, so an argument
1173/// carrying a space or an operator stays one argument.
1174fn bounded_shell(
1175    command: &[String],
1176    env: &BTreeMap<String, String>,
1177    timeout: std::time::Duration,
1178) -> String {
1179    let escape = |value: &str| value.replace('\'', "'\\''");
1180
1181    // Through `env`, so the variables reach the caller's command and not the wrapper that bounds
1182    // it: an assignment in front of the wrapper would put a caller-chosen `PATH` on the shell
1183    // that resolves `setsid`, `sleep` and `kill`, and the deadline is only as real as those.
1184    let mut argv = Vec::with_capacity(command.len() + env.len() + 1);
1185    if !env.is_empty() {
1186        argv.push("env".to_string());
1187        argv.extend(env.iter().map(|(name, value)| format!("{name}={value}")));
1188    }
1189    argv.extend(command.iter().cloned());
1190
1191    let arguments = argv
1192        .iter()
1193        .map(|argument| format!(" '{}'", escape(argument)))
1194        .collect::<String>();
1195    format!(
1196        "sh -c '{}' sh{arguments}",
1197        escape(&TimeoutReport::bounded_program(timeout))
1198    )
1199}
1200
1201/// Refuses an environment a sandbox must not carry.
1202///
1203/// The wrapper that holds a command to its deadline runs inside the sandbox and inherits its
1204/// environment, so a name that changes how a shell resolves, splits, or loads hands the command a
1205/// deadline it can forge. `PATH` chooses which `od` draws the nonce; `IFS` changes how the wrapper
1206/// reads its own pids; every `LD_*` runs attacker code inside `od` itself; `SHELLOPTS` turns on
1207/// tracing in a `sh` that is really bash. Refused as families where they are one, because a list
1208/// of names is a list of the ones somebody remembered — and `TimeoutReport::read` finds its
1209/// announcement by shape for the same reason, so a name missed here is noise rather than failure.
1210/// The same names per command are safe — those travel through `env` and reach only the command.
1211fn checked_sandbox_env(operation: &str, env: &BTreeMap<String, String>) -> Result<()> {
1212    for name in env.keys() {
1213        checked_env_name(operation, name)?;
1214        if matches!(name.as_str(), "PATH" | "IFS" | "SHELLOPTS" | "BASHOPTS")
1215            || name.starts_with("LD_")
1216        {
1217            return Err(AlienError::new(ErrorData::InvalidInput {
1218                operation_context: operation.to_string(),
1219                details: format!(
1220                    "'{name}' cannot be set for the whole sandbox, because the wrapper that holds \
1221                     a command to its deadline inherits it; declare it on the command instead"
1222                ),
1223                field_name: Some("env".to_string()),
1224            }));
1225        }
1226    }
1227    Ok(())
1228}
1229
1230/// Refuses a variable name `env` would not take as one.
1231///
1232/// Kept even though the whole `NAME=value` pair is one quoted argument: a name outside this set
1233/// either fails the exec or silently becomes something else, and the other backends bound it the
1234/// same way.
1235fn checked_env_name(operation: &str, name: &str) -> Result<()> {
1236    let usable = !name.is_empty()
1237        && !name.starts_with(|c: char| c.is_ascii_digit())
1238        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
1239    if usable {
1240        return Ok(());
1241    }
1242    Err(AlienError::new(ErrorData::InvalidInput {
1243        operation_context: operation.to_string(),
1244        details: format!(
1245            "environment variable name '{name}' is not a shell name: letters, digits and \
1246             underscores only, and not starting with a digit"
1247        ),
1248        field_name: Some("env".to_string()),
1249    }))
1250}
1251
1252/// Refuses a caller's path before it reaches the data plane, and returns what to send.
1253///
1254/// This refuses traversal syntax; it establishes no root. Whether the data plane bounds a path is
1255/// undocumented and unmeasured, so no rule here can promise confinement — what it promises is
1256/// that a path cannot name a parent. A leading slash is trimmed rather than refused because it
1257/// means "under the sandbox's own root" on every other backend, and refusing it would make the
1258/// one shape portable code writes the one shape this backend rejects.
1259fn checked_path(operation: &str, path: &str) -> Result<String> {
1260    let refused = |details: &str| {
1261        Err(AlienError::new(ErrorData::InvalidInput {
1262            operation_context: operation.to_string(),
1263            details: format!("path '{path}' {details}"),
1264            field_name: Some("path".to_string()),
1265        }))
1266    };
1267
1268    // Checked before anything is trimmed, which would make "a/b/" and the file "a/b" the same
1269    // request.
1270    if path.ends_with('/') {
1271        return refused("must not end in '/'");
1272    }
1273    // A leading slash means "under the sandbox's own root" on every other backend, so it means
1274    // that here too: the alternative is that the one path shape portable code writes is the one
1275    // shape the newest `files` backend refuses.
1276    let relative = path.trim_start_matches('/');
1277    if relative.is_empty() {
1278        return refused("is empty");
1279    }
1280    if relative.contains('\0') {
1281        return refused("contains a null byte");
1282    }
1283    if relative
1284        .split('/')
1285        .any(|part| part == ".." || part.is_empty())
1286    {
1287        return refused("must not traverse");
1288    }
1289
1290    Ok(relative.to_string())
1291}
1292
1293/// The policy a declared mode is created with.
1294///
1295/// `Full` inspection is what makes a `Deny` default mean no outbound access: under `Partial`,
1296/// `Legacy` and `None`, non-HTTP traffic is allowed through whatever the default action says, so
1297/// the sandbox would carry a `deny` label and a live network. `allow` sends no policy at all —
1298/// the data plane's default is already open, and `Full` there would block the non-HTTP traffic
1299/// `allow` promises.
1300fn egress_policy(egress: &SandboxEgress) -> Option<EgressPolicy> {
1301    let bounded = |host_rules| {
1302        Some(EgressPolicy {
1303            default_action: DENY.to_string(),
1304            unmodelled: Default::default(),
1305            rules: Vec::new(),
1306            host_rules,
1307            traffic_inspection: Some(FULL_INSPECTION.to_string()),
1308        })
1309    };
1310
1311    match egress {
1312        SandboxEgress::Allow => None,
1313        // Written as a rule as well as a default, because Microsoft documents `Partial`
1314        // inspection as evaluating only traffic a rule matches and never states that `Full`
1315        // differs. A policy holding no rule at all is the one shape where "deny" could mean
1316        // nothing, and this is one rule to be out of it.
1317        SandboxEgress::Deny => bounded(vec![EgressHostRule {
1318            pattern: EVERY_HOST.to_string(),
1319            action: DENY.to_string(),
1320        }]),
1321        SandboxEgress::AllowDomains { domains } => bounded(
1322            domains
1323                .iter()
1324                .map(|domain| EgressHostRule {
1325                    pattern: domain.clone(),
1326                    action: ALLOW.to_string(),
1327                })
1328                .collect(),
1329        ),
1330    }
1331}
1332
1333/// Whether the sandbox is running the policy it was created with.
1334///
1335/// Not equality — the data plane may return the policy normalised, and failing every create over a
1336/// reordered list would push whoever hits it into removing the check. Not a subset either, which
1337/// is the same mistake pointing outward: a permission the sandbox holds and the declaration never
1338/// asked for is exactly what this is looking for. So both directions, on the two things that can
1339/// permit traffic: nothing may allow a host the declaration did not name, in either list.
1340///
1341/// A group-scoped policy can add an entry nobody sent here, which is why the rules list is read at
1342/// all — it is never written.
1343fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool {
1344    let Some(effective) = effective else {
1345        return false;
1346    };
1347
1348    let asked_for = |host: &str| {
1349        asked
1350            .host_rules
1351            .iter()
1352            .any(|rule| rule.action.eq_ignore_ascii_case(ALLOW) && rule.pattern == host)
1353    };
1354
1355    effective.default_action.eq_ignore_ascii_case(&asked.default_action)
1356        && effective
1357            .traffic_inspection
1358            .as_deref()
1359            .is_some_and(|mode| mode.eq_ignore_ascii_case(FULL_INSPECTION))
1360        && asked.host_rules.iter().all(|asked_rule| {
1361            effective.host_rules.iter().any(|rule| {
1362                rule.pattern == asked_rule.pattern
1363                    && rule.action.eq_ignore_ascii_case(&asked_rule.action)
1364            })
1365        })
1366        // A whitelist, not a blacklist: an action this client does not recognise is one it cannot
1367        // weigh, and `Transform` and `Rewrite` reach a host by rewriting the request rather than
1368        // by naming it. Only a plain deny, or an allow the declaration asked for, passes.
1369        && effective.host_rules.iter().all(|rule| {
1370            rule.action.eq_ignore_ascii_case(DENY)
1371                || (rule.action.eq_ignore_ascii_case(ALLOW) && asked_for(&rule.pattern))
1372        })
1373        // This client never writes `rules`, so anything here came from elsewhere — a group-scoped
1374        // policy, or an API that moved — and only an outright deny is readable as harmless.
1375        && effective.rules.iter().all(|rule| {
1376            rule.action
1377                .as_ref()
1378                .is_some_and(|action| action.action_type.eq_ignore_ascii_case(DENY))
1379        })
1380        // A field this client cannot read is a permission it cannot rule out.
1381        && effective.unmodelled.is_empty()
1382}
1383
1384/// The effective policy, short enough to read in an error.
1385fn describe(effective: Option<&EgressPolicy>) -> String {
1386    match effective {
1387        None => "no policy at all".to_string(),
1388        Some(policy) if !policy.unmodelled.is_empty() => format!(
1389            "a policy carrying {}, which this client cannot weigh",
1390            policy
1391                .unmodelled
1392                .keys()
1393                .map(String::as_str)
1394                .collect::<Vec<_>>()
1395                .join(", ")
1396        ),
1397        Some(policy) => format!(
1398            "default action '{}' under {} inspection, {} host rules and {} match rules",
1399            policy.default_action,
1400            policy.traffic_inspection.as_deref().unwrap_or("unstated"),
1401            policy.host_rules.len(),
1402            policy.rules.len()
1403        ),
1404    }
1405}
1406
1407/// The data plane's own lifecycle vocabulary, in ours.
1408///
1409/// An unrecognised state is an error rather than a default, because every default here is a lie
1410/// a caller acts on: `Running` sends commands to a sandbox that cannot answer them, and anything
1411/// else hides one that can.
1412fn sandbox_state(operation: &str, state: Option<&str>) -> Result<SandboxState> {
1413    match state {
1414        Some("Running") => Ok(SandboxState::Running),
1415        Some("Creating" | "Resuming") => Ok(SandboxState::Starting),
1416        // `Idle` is where the SDK contradicts itself: it declares `Idle` as a reason a sandbox
1417        // stopped, and then waits for a *state* of `Idle` after a stop. Accepted as suspended
1418        // either way — the alternative is that the state auto-suspend produces is the one state
1419        // this refuses to read.
1420        // A sandbox on its way down is not one to send work to, and the four states the trait
1421        // publishes have no word for "stopping" — so it reads as unusable. Anything that has to
1422        // tell "going down" from "already down" reads the raw state instead.
1423        Some("Stopping" | "Stopped" | "Suspended" | "Idle") => Ok(SandboxState::Paused),
1424        Some("Deleting" | "Failed") => Ok(SandboxState::Terminated),
1425        other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
1426            provider: "azure".to_string(),
1427            binding_name: operation.to_string(),
1428            field: "state".to_string(),
1429            response_json: other
1430                .map_or_else(|| "absent".to_string(), |state| format!("\"{state}\"")),
1431        })),
1432    }
1433}
1434
1435/// The data plane's own words for the two actions and the one inspection mode that blocks
1436/// non-HTTP traffic.
1437const DENY: &str = "Deny";
1438const ALLOW: &str = "Allow";
1439const FULL_INSPECTION: &str = "Full";
1440
1441/// The host pattern that matches everything, so `deny` is a rule rather than only a default.
1442const EVERY_HOST: &str = "*";
1443
1444/// Longest sandbox id this client will put in a data-plane URL.
1445///
1446/// A bound on what a caller hands back rather than on what Azure mints: the ids seen in practice
1447/// are far shorter, and the point is that an id reaching the URL is one this client chose to send.
1448const MAX_SANDBOX_ID: usize = 63;
1449
1450/// The two operations a repeat could perform twice.
1451///
1452/// `create` is a PUT to a collection with a server-minted id, so a second attempt makes a second
1453/// sandbox — and with no enumeration verb, the first one has no id-holder and nothing to reap it.
1454const RUN_COMMAND: &str = "sandbox.runCommand";
1455const CREATE: &str = "sandbox.create";
1456const GET_OR_CREATE: &str = "sandbox.getOrCreate";
1457
1458const NO_JOB_HOST: &str = "Azure sandboxes run no in-guest agent to own a job between calls";
1459
1460/// How long a sandbox has to become able to take work, and how often that is checked.
1461const SANDBOX_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1462const SANDBOX_READY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
1463
1464/// Whether the data plane understood the request and rejected it.
1465///
1466/// Reads the classified variant the client attaches rather than the status on its source: the
1467/// wrapper is what survives `create_azure_http_error_with_context`, and it already carries the
1468/// 4xx-versus-everything-else split this needs.
1469fn is_refusal(error: &AlienError<ClientErrorData>) -> bool {
1470    // `RemoteResourceConflict` is deliberately absent: the client also uses it for the 400s Azure
1471    // marks as propagation delays, and calling those refusals would tell a caller never to retry
1472    // the one failure Azure says to retry.
1473    matches!(
1474        &error.error,
1475        Some(
1476            ClientErrorData::RemoteResourceNotFound { .. }
1477                | ClientErrorData::RemoteAccessDenied { .. }
1478                | ClientErrorData::InvalidInput { .. }
1479        )
1480    ) || matches!(
1481        &error.error,
1482        Some(ClientErrorData::HttpResponseError { http_status, .. }) if (400..500).contains(http_status)
1483    )
1484}
1485
1486/// Whether an Azure data-plane failure means the sandbox is already gone.
1487///
1488/// Reads the status the client carries rather than the rendered message: `AlienError`'s `Display`
1489/// walks the whole source chain and the data plane puts the response body in it, so a path or a
1490/// trace id containing "404" would otherwise turn a throttle into "gone".
1491fn is_not_found(error: &AlienError<ClientErrorData>) -> bool {
1492    // Both variants, because the client wraps: `create_azure_http_error_with_context` builds the
1493    // `HttpResponseError` carrying the status and then returns
1494    // `http_error.context(RemoteResourceNotFound)` for a 404, so the outer variant is the
1495    // classified one and the status only survives on the source.
1496    matches!(
1497        &error.error,
1498        Some(ClientErrorData::RemoteResourceNotFound { .. })
1499    ) || matches!(
1500        &error.error,
1501        Some(ClientErrorData::HttpResponseError { http_status, .. }) if *http_status == 404
1502    )
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508    use alien_azure_clients::azure::sandbox_data_plane::ExecResult;
1509    use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi;
1510    use alien_azure_clients::azure::sandbox_data_plane::{DiskImage, DiskImageStatus};
1511    use alien_azure_clients::azure::sandbox_data_plane::{
1512        EgressRule, EgressRuleAction, EgressRuleMatch,
1513    };
1514    use alien_core::Platform;
1515    use futures::StreamExt;
1516
1517    fn http_error(status: u16, body: &str) -> AlienError<ClientErrorData> {
1518        AlienError::new(ClientErrorData::HttpResponseError {
1519            message: "Azure ADC sandbox.get failed".to_string(),
1520            url: "https://example.invalid/sandboxes/s1".to_string(),
1521            http_status: status,
1522            http_request_text: None,
1523            http_response_text: Some(body.to_string()),
1524        })
1525    }
1526
1527    /// Answers the readiness read every create makes, with the policy the sandbox came up under.
1528    fn settles_running(client: &mut MockSandboxDataPlaneApi, egress: Option<EgressPolicy>) {
1529        client
1530            .expect_get_sandbox()
1531            .returning(move |_, id| Ok(running(id, egress.clone())));
1532    }
1533
1534    fn sandbox_with(client: MockSandboxDataPlaneApi) -> AzureSandbox {
1535        AzureSandbox::new(
1536            std::sync::Arc::new(client),
1537            "grp".to_string(),
1538            "ubuntu".to_string(),
1539            SandboxEgress::Allow,
1540            None,
1541            "1000m".to_string(),
1542            "2048Mi".to_string(),
1543            None,
1544        )
1545    }
1546
1547    /// The declared image has to reach the create call, not a default chosen here.
1548    ///
1549    /// Asserted on the argument the client receives, because the failure this pins is silent:
1550    /// a sandbox started from the wrong image returns a healthy sandbox and only diverges once
1551    /// the caller's code is missing from it.
1552    #[tokio::test]
1553    async fn the_declared_image_reaches_the_create_call() {
1554        let mut client = MockSandboxDataPlaneApi::new();
1555        client
1556            .expect_create_sandbox()
1557            .withf(|_, request| request.disk_image == "my-toolchain")
1558            .times(1)
1559            .returning(|_, _| {
1560                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1561                    id: "s1".to_string(),
1562                    egress_policy: None,
1563                    state: Some("Running".to_string()),
1564                })
1565            });
1566        settles_running(&mut client, None);
1567
1568        let sandbox = AzureSandbox::new(
1569            std::sync::Arc::new(client),
1570            "grp".to_string(),
1571            "my-toolchain".to_string(),
1572            SandboxEgress::Allow,
1573            None,
1574            "1000m".to_string(),
1575            "2048Mi".to_string(),
1576            None,
1577        );
1578
1579        sandbox
1580            .create(CreateSandboxRequest::default())
1581            .await
1582            .expect("create succeeds");
1583    }
1584
1585    fn disk_image(id: &str, label: &str, state: &str) -> DiskImage {
1586        DiskImage {
1587            id: id.to_string(),
1588            labels: BTreeMap::from([(AZURE_DISK_IMAGE_LABEL.to_string(), label.to_string())]),
1589            status: Some(DiskImageStatus {
1590                state: Some(state.to_string()),
1591                error_message: None,
1592            }),
1593        }
1594    }
1595
1596    fn registry_sandbox(client: MockSandboxDataPlaneApi) -> AzureSandbox {
1597        AzureSandbox::new(
1598            std::sync::Arc::new(client),
1599            "grp".to_string(),
1600            "docker.io/library/python:3.14-slim".to_string(),
1601            SandboxEgress::Allow,
1602            None,
1603            "1000m".to_string(),
1604            "2048Mi".to_string(),
1605            None,
1606        )
1607    }
1608
1609    /// A registry image starts sandboxes from the Ready disk image built from it, found by its
1610    /// label once: a second create does not list again. Another reference's image, and one still
1611    /// building, are passed over.
1612    #[tokio::test]
1613    async fn a_registry_image_starts_from_the_disk_image_built_from_it() {
1614        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1615        let mut client = MockSandboxDataPlaneApi::new();
1616        let listed = label.clone();
1617        client
1618            .expect_list_disk_images()
1619            .times(1)
1620            .returning(move |_| {
1621                Ok(vec![
1622                    disk_image(
1623                        "other",
1624                        &azure_disk_image_label("docker.io/library/node:22"),
1625                        "Ready",
1626                    ),
1627                    disk_image("building", &listed, "Building"),
1628                    disk_image("built", &listed, "Ready"),
1629                ])
1630            });
1631        client
1632            .expect_create_sandbox()
1633            .withf(|_, request| request.disk_image_id.as_deref() == Some("built"))
1634            .times(2)
1635            .returning(|_, _| {
1636                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1637                    id: "s1".to_string(),
1638                    egress_policy: None,
1639                    state: Some("Running".to_string()),
1640                })
1641            });
1642        settles_running(&mut client, None);
1643
1644        let sandbox = registry_sandbox(client);
1645        for _ in 0..2 {
1646            sandbox
1647                .create(CreateSandboxRequest::default())
1648                .await
1649                .expect("create succeeds");
1650        }
1651    }
1652
1653    /// A cached image the controller has since retired is refused as `DiskImageNotFound`. The
1654    /// provider drops it, looks up the label once more, and starts from the replacement.
1655    #[tokio::test]
1656    async fn a_retired_cached_image_is_looked_up_again() {
1657        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1658        let mut client = MockSandboxDataPlaneApi::new();
1659        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1660        let counter = seen.clone();
1661        client
1662            .expect_list_disk_images()
1663            .times(2)
1664            .returning(move |_| {
1665                let id = if counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
1666                    "old"
1667                } else {
1668                    "new"
1669                };
1670                Ok(vec![disk_image(id, &label, "Ready")])
1671            });
1672        client
1673            .expect_create_sandbox()
1674            .withf(|_, request| request.disk_image_id.as_deref() == Some("old"))
1675            .times(1)
1676            .returning(|_, _| {
1677                Err(AlienError::new(ClientErrorData::InvalidInput {
1678                    message: r#"Bad request for Resource 'grp': {"title":"DiskImageNotFound","status":400}"#
1679                        .to_string(),
1680                    field_name: None,
1681                }))
1682            });
1683        client
1684            .expect_create_sandbox()
1685            .withf(|_, request| request.disk_image_id.as_deref() == Some("new"))
1686            .times(1)
1687            .returning(|_, _| {
1688                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1689                    id: "s1".to_string(),
1690                    egress_policy: None,
1691                    state: Some("Running".to_string()),
1692                })
1693            });
1694        settles_running(&mut client, None);
1695
1696        registry_sandbox(client)
1697            .create(CreateSandboxRequest::default())
1698            .await
1699            .expect("the replacement image serves");
1700        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
1701    }
1702
1703    /// A cached image retired while its replacement still builds fails as retryable: a later
1704    /// create succeeds, so the refusal of the stale id must not tell the caller to stop.
1705    #[tokio::test]
1706    async fn a_retired_image_whose_replacement_still_builds_is_retryable() {
1707        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1708        let mut client = MockSandboxDataPlaneApi::new();
1709        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1710        let counter = seen.clone();
1711        client
1712            .expect_list_disk_images()
1713            .times(2)
1714            .returning(move |_| {
1715                let state = if counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
1716                    "Ready"
1717                } else {
1718                    "Building"
1719                };
1720                Ok(vec![disk_image("img", &label, state)])
1721            });
1722        client.expect_create_sandbox().times(1).returning(|_, _| {
1723            Err(AlienError::new(ClientErrorData::InvalidInput {
1724                message:
1725                    r#"Bad request for Resource 'grp': {"title":"DiskImageNotFound","status":400}"#
1726                        .to_string(),
1727                field_name: None,
1728            }))
1729        });
1730
1731        let error = registry_sandbox(client)
1732            .create(CreateSandboxRequest::default())
1733            .await
1734            .expect_err("nothing is Ready to start from");
1735
1736        assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1737        assert!(error.retryable, "{error}");
1738    }
1739
1740    /// A list that fails in transport comes before any create, so it is a retryable wait and
1741    /// never the create's unknown outcome.
1742    #[tokio::test]
1743    async fn a_disk_image_list_lost_in_transport_is_retryable() {
1744        let mut client = MockSandboxDataPlaneApi::new();
1745        client.expect_list_disk_images().times(1).returning(|_| {
1746            Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable {
1747                message: "Service unavailable".to_string(),
1748            }))
1749        });
1750        client.expect_create_sandbox().times(0);
1751
1752        let error = registry_sandbox(client)
1753            .create(CreateSandboxRequest::default())
1754            .await
1755            .expect_err("the list failed");
1756
1757        assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1758        assert!(error.retryable, "{error}");
1759    }
1760
1761    /// A list that still names the deleted image refuses the resend too. No sandbox was minted
1762    /// either time, so the caller is told to retry, and the stale id is not kept.
1763    #[tokio::test]
1764    async fn a_list_still_naming_the_deleted_image_is_retryable() {
1765        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1766        let mut client = MockSandboxDataPlaneApi::new();
1767        client
1768            .expect_list_disk_images()
1769            .times(2)
1770            .returning(move |_| Ok(vec![disk_image("old", &label, "Ready")]));
1771        client.expect_create_sandbox().times(2).returning(|_, _| {
1772            Err(AlienError::new(ClientErrorData::InvalidInput {
1773                message:
1774                    r#"Bad request for Resource 'grp': {"title":"DiskImageNotFound","status":400}"#
1775                        .to_string(),
1776                field_name: None,
1777            }))
1778        });
1779        let sandbox = registry_sandbox(client);
1780
1781        let error = sandbox
1782            .create(CreateSandboxRequest::default())
1783            .await
1784            .expect_err("the listed image is gone");
1785
1786        assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1787        assert!(error.retryable, "{error}");
1788        assert!(sandbox.cached_disk_image_id().is_none());
1789    }
1790
1791    /// A denied list is a refusal, not an unreachable agent: retrying cannot grant the role.
1792    #[tokio::test]
1793    async fn a_denied_disk_image_list_is_a_refusal() {
1794        let mut client = MockSandboxDataPlaneApi::new();
1795        client.expect_list_disk_images().times(1).returning(|_| {
1796            Err(AlienError::new(ClientErrorData::RemoteAccessDenied {
1797                resource_type: "Resource".to_string(),
1798                resource_name: "grp".to_string(),
1799            }))
1800        });
1801        client.expect_create_sandbox().times(0);
1802
1803        let error = registry_sandbox(client)
1804            .create(CreateSandboxRequest::default())
1805            .await
1806            .expect_err("the list is denied");
1807
1808        assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
1809        assert!(!error.retryable, "{error}");
1810    }
1811
1812    /// A label holding only Failed builds is not a wait: nothing rebuilds it until the
1813    /// declaration changes or the resource is retried, so the caller is told to stop.
1814    #[tokio::test]
1815    async fn a_label_with_only_failed_builds_is_not_retryable() {
1816        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1817        let mut client = MockSandboxDataPlaneApi::new();
1818        client
1819            .expect_list_disk_images()
1820            .times(1)
1821            .returning(move |_| Ok(vec![disk_image("failed", &label, "Failed")]));
1822        client.expect_create_sandbox().times(0);
1823
1824        let error = registry_sandbox(client)
1825            .create(CreateSandboxRequest::default())
1826            .await
1827            .expect_err("the only build failed");
1828
1829        assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
1830        assert!(!error.retryable, "{error}");
1831    }
1832
1833    /// Before the controller has built the image there is nothing to start from, and the miss is
1834    /// not remembered: the next create looks again and finds the image once it is Ready.
1835    #[tokio::test]
1836    async fn an_image_not_built_yet_fails_the_create_and_is_looked_up_again() {
1837        let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1838        let mut client = MockSandboxDataPlaneApi::new();
1839        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1840        let seen = calls.clone();
1841        client
1842            .expect_list_disk_images()
1843            .times(2)
1844            .returning(move |_| {
1845                if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
1846                    Ok(Vec::new())
1847                } else {
1848                    Ok(vec![disk_image("built", &label, "Ready")])
1849                }
1850            });
1851        client
1852            .expect_create_sandbox()
1853            .withf(|_, request| request.disk_image_id.as_deref() == Some("built"))
1854            .times(1)
1855            .returning(|_, _| {
1856                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1857                    id: "s1".to_string(),
1858                    egress_policy: None,
1859                    state: Some("Running".to_string()),
1860                })
1861            });
1862        settles_running(&mut client, None);
1863
1864        let sandbox = registry_sandbox(client);
1865        let error = sandbox
1866            .create(CreateSandboxRequest::default())
1867            .await
1868            .expect_err("no image is built yet");
1869        assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1870        assert!(error.to_string().contains("python:3.14-slim"), "{error}");
1871
1872        sandbox
1873            .create(CreateSandboxRequest::default())
1874            .await
1875            .expect("the image is found once it is Ready");
1876    }
1877
1878    /// Azure accepts a delete and completes it later, so returning on the accepted call would
1879    /// report that untrusted code had stopped while it was still running. Time is paused, so the
1880    /// poll runs to its bound instantly.
1881    #[tokio::test(start_paused = true)]
1882    async fn a_termination_that_never_completes_is_reported_as_unconfirmed() {
1883        let mut client = MockSandboxDataPlaneApi::new();
1884        client.expect_delete_sandbox().returning(|_, _| Ok(()));
1885        client.expect_get_sandbox().returning(|_, id| {
1886            Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1887                id: id.to_string(),
1888                egress_policy: None,
1889                state: Some("Running".to_string()),
1890            })
1891        });
1892
1893        let error = sandbox_with(client)
1894            .terminate("s1")
1895            .await
1896            .expect_err("a sandbox still present after the poll is not contained");
1897        assert!(
1898            error.to_string().contains("may still be running"),
1899            "says what is not known: {error}"
1900        );
1901    }
1902
1903    /// The same path when Azure does finish: the sandbox becomes absent and terminate succeeds.
1904    #[tokio::test(start_paused = true)]
1905    async fn a_termination_is_confirmed_once_the_sandbox_is_gone() {
1906        let mut client = MockSandboxDataPlaneApi::new();
1907        client.expect_delete_sandbox().returning(|_, _| Ok(()));
1908        client
1909            .expect_get_sandbox()
1910            .returning(|_, _| Err(http_error(404, "SandboxNotFound")));
1911
1912        sandbox_with(client)
1913            .terminate("s1")
1914            .await
1915            .expect("an absent sandbox is a confirmed termination");
1916    }
1917
1918    /// The discriminating case. A throttle whose body mentions 404 — a trace id, an inner code, a
1919    /// path — must not read as "the sandbox is gone": that starts a second sandbox while the
1920    /// first keeps running, reporting a live sandbox as terminated.
1921    #[test]
1922    fn only_the_status_decides_whether_a_sandbox_is_gone() {
1923        assert!(is_not_found(&http_error(404, "SandboxNotFound")));
1924
1925        // The shape the client actually produces: a 404 is returned as
1926        // `http_error.context(RemoteResourceNotFound)`, so the outer variant is the classified
1927        // one. Matching only `HttpResponseError` would read every real 404 as a live sandbox.
1928        assert!(
1929            is_not_found(&AlienError::new(ClientErrorData::RemoteResourceNotFound {
1930                resource_type: "Sandbox".to_string(),
1931                resource_name: "s1".to_string(),
1932            })),
1933            "a wrapped 404 is how the client reports an absent sandbox"
1934        );
1935
1936        assert!(
1937            !is_not_found(&http_error(429, "throttled; see trace 404abc")),
1938            "a throttle is not a missing sandbox"
1939        );
1940        assert!(
1941            !is_not_found(&http_error(403, "denied on /sandboxes/404/read")),
1942            "a path containing 404 is not a missing sandbox"
1943        );
1944        assert!(
1945            !is_not_found(&http_error(500, "internal error 404")),
1946            "a server failure is not a missing sandbox"
1947        );
1948    }
1949
1950    /// A hand-written data plane, because mockall resolves an async expectation immediately and
1951    /// these tests need exec to hang, or to answer differently per call.
1952    #[derive(Debug)]
1953    struct ScriptedExec {
1954        deleted: std::sync::Arc<std::sync::atomic::AtomicBool>,
1955        delete_refuses: std::sync::atomic::AtomicBool,
1956        commands: std::sync::Mutex<Vec<String>>,
1957        /// One result per exec call, in order; an empty queue hangs.
1958        results: std::sync::Mutex<
1959            std::collections::VecDeque<alien_azure_clients::azure::sandbox_data_plane::ExecResult>,
1960        >,
1961    }
1962
1963    impl ScriptedExec {
1964        fn new(
1965            results: Vec<alien_azure_clients::azure::sandbox_data_plane::ExecResult>,
1966        ) -> std::sync::Arc<Self> {
1967            std::sync::Arc::new(Self {
1968                deleted: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1969                delete_refuses: std::sync::atomic::AtomicBool::new(false),
1970                commands: std::sync::Mutex::new(Vec::new()),
1971                results: std::sync::Mutex::new(results.into_iter().collect()),
1972            })
1973        }
1974
1975        fn exec_result(
1976            exit_code: i32,
1977            stdout: &str,
1978            stderr: &str,
1979        ) -> alien_azure_clients::azure::sandbox_data_plane::ExecResult {
1980            alien_azure_clients::azure::sandbox_data_plane::ExecResult {
1981                stdout: stdout.to_string(),
1982                stderr: stderr.to_string(),
1983                exit_code: Some(exit_code),
1984            }
1985        }
1986    }
1987
1988    #[async_trait]
1989    impl SandboxDataPlaneApi for ScriptedExec {
1990        async fn stop_sandbox(
1991            &self,
1992            _group: &str,
1993            _sandbox_id: &str,
1994        ) -> alien_client_core::Result<()> {
1995            unreachable!("the command paths never pause")
1996        }
1997
1998        async fn resume_sandbox(
1999            &self,
2000            _group: &str,
2001            _sandbox_id: &str,
2002        ) -> alien_client_core::Result<()> {
2003            unreachable!("the command paths never resume")
2004        }
2005
2006        async fn read_file(
2007            &self,
2008            _group: &str,
2009            _sandbox_id: &str,
2010            _path: &str,
2011        ) -> alien_client_core::Result<Vec<u8>> {
2012            unreachable!("the command paths never read files")
2013        }
2014
2015        async fn write_file(
2016            &self,
2017            _group: &str,
2018            _sandbox_id: &str,
2019            _path: &str,
2020            _contents: Vec<u8>,
2021        ) -> alien_client_core::Result<()> {
2022            unreachable!("the command paths never write files")
2023        }
2024
2025        async fn mkdir(
2026            &self,
2027            _group: &str,
2028            _sandbox_id: &str,
2029            _path: &str,
2030        ) -> alien_client_core::Result<()> {
2031            unreachable!("the command paths never create directories")
2032        }
2033
2034        async fn create_sandbox(
2035            &self,
2036            _group: &str,
2037            _request: CreateSandbox,
2038        ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox>
2039        {
2040            unreachable!("the command paths never create")
2041        }
2042
2043        async fn get_sandbox(
2044            &self,
2045            _group: &str,
2046            sandbox_id: &str,
2047        ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox>
2048        {
2049            if self.deleted.load(std::sync::atomic::Ordering::SeqCst) {
2050                return Err(http_error(404, "SandboxNotFound"));
2051            }
2052            Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2053                id: sandbox_id.to_string(),
2054                egress_policy: None,
2055                state: Some("Running".to_string()),
2056            })
2057        }
2058
2059        async fn delete_sandbox(
2060            &self,
2061            _group: &str,
2062            _sandbox_id: &str,
2063        ) -> alien_client_core::Result<()> {
2064            if self
2065                .delete_refuses
2066                .load(std::sync::atomic::Ordering::SeqCst)
2067            {
2068                return Err(http_error(503, "gateway timeout"));
2069            }
2070            self.deleted
2071                .store(true, std::sync::atomic::Ordering::SeqCst);
2072            Ok(())
2073        }
2074
2075        async fn execute_shell_command(
2076            &self,
2077            _group: &str,
2078            _sandbox_id: &str,
2079            command: &str,
2080            _working_directory: Option<String>,
2081        ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::ExecResult>
2082        {
2083            self.commands
2084                .lock()
2085                .expect("commands lock")
2086                .push(command.to_string());
2087            let next = self.results.lock().expect("results lock").pop_front();
2088            match next {
2089                // A scripted `TIMEOUT_PLACEHOLDER` stands for "the wrapper fired": the double
2090                // answers with the marker the provider itself put in the program, which is the
2091                // only way a test can produce one — the nonce is made per command.
2092                Some(result) => Ok(ExecResult {
2093                    stderr: as_sandbox_stderr(&result.stderr),
2094                    ..result
2095                }),
2096                None => std::future::pending().await,
2097            }
2098        }
2099
2100        async fn create_disk_image(
2101            &self,
2102            _group: &str,
2103            _request: alien_azure_clients::azure::sandbox_data_plane::CreateDiskImage,
2104        ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::DiskImage>
2105        {
2106            unreachable!("the command paths never build images")
2107        }
2108
2109        async fn get_disk_image(
2110            &self,
2111            _group: &str,
2112            _image_id: &str,
2113        ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::DiskImage>
2114        {
2115            unreachable!("the command paths never read images")
2116        }
2117
2118        async fn list_disk_images(
2119            &self,
2120            _group: &str,
2121        ) -> alien_client_core::Result<Vec<alien_azure_clients::azure::sandbox_data_plane::DiskImage>>
2122        {
2123            unreachable!("the command paths never list images")
2124        }
2125
2126        async fn delete_disk_image(
2127            &self,
2128            _group: &str,
2129            _image_id: &str,
2130        ) -> alien_client_core::Result<()> {
2131            unreachable!("the command paths never delete images")
2132        }
2133    }
2134
2135    /// Stands in for the wrapper's kill in a scripted result.
2136    const TIMEOUT_PLACEHOLDER: &str = "<timeout>";
2137    /// The nonce a sandbox would draw. Announced on the first line of stderr, and repeated by
2138    /// the killer, exactly as the wrapper does.
2139    /// The width the wrapper draws — `od -N16` is 16 bytes, so 32 hex digits. Short of that is
2140    /// not an announcement, and a fixture that used a short one pinned a weaker rule than the
2141    /// sandbox's.
2142    const SANDBOX_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
2143
2144    /// Wraps a scripted stderr the way a bounded sandbox would return it.
2145    fn as_sandbox_stderr(stderr: &str) -> String {
2146        match stderr {
2147            TIMEOUT_PLACEHOLDER => format!("{SANDBOX_NONCE}\npartial-err{SANDBOX_NONCE}"),
2148            other => format!("{SANDBOX_NONCE}\n{other}"),
2149        }
2150    }
2151
2152    fn provider(client: std::sync::Arc<ScriptedExec>) -> AzureSandbox {
2153        AzureSandbox::new(
2154            client,
2155            "grp".to_string(),
2156            "ubuntu".to_string(),
2157            SandboxEgress::Allow,
2158            None,
2159            "1000m".to_string(),
2160            "2048Mi".to_string(),
2161            None,
2162        )
2163    }
2164
2165    fn command(timeout_secs: u64) -> RunCommandRequest {
2166        RunCommandRequest {
2167            command: "sleep".to_string(),
2168            args: vec!["forever".to_string()],
2169            cwd: None,
2170            env: BTreeMap::new(),
2171            timeout: std::time::Duration::from_secs(timeout_secs),
2172        }
2173    }
2174
2175    /// The deadline is enforced inside the sandbox: the command is wrapped in `timeout`, and
2176    /// when it fires the output is kept, the stream ends in `timeoutExceeded`, and the sandbox
2177    /// is not touched — the caller can keep using it, as on the agent-supervised backends.
2178    ///
2179    /// The wrapper reports its own kill, so the fake answers with that report rather than the
2180    /// test leaning on timing.
2181    #[tokio::test]
2182    async fn a_command_past_its_timeout_is_killed_in_place_and_the_sandbox_survives() {
2183        let client = ScriptedExec::new(vec![ScriptedExec::exec_result(
2184            137,
2185            "partial\n",
2186            TIMEOUT_PLACEHOLDER,
2187        )]);
2188        let sandbox = provider(client.clone());
2189
2190        let frames: Vec<Result<CommandOutput>> = sandbox
2191            .run_command("s1", command(30))
2192            .await
2193            .expect("the call itself succeeds; the deadline is reported in the stream")
2194            .collect()
2195            .await;
2196
2197        assert!(
2198            matches!(&frames[0], Ok(CommandOutput::Stdout { data, .. }) if data == b"partial\n"),
2199            "output produced before the deadline is kept: {frames:?}"
2200        );
2201        let terminal = frames
2202            .last()
2203            .expect("frames")
2204            .as_ref()
2205            .expect_err("the stream must end in the deadline error, not an exit frame");
2206        assert!(
2207            terminal.to_string().contains("timeoutExceeded"),
2208            "the caller has to be able to tell this apart from a command that failed: {terminal}"
2209        );
2210        assert!(
2211            !client.deleted.load(std::sync::atomic::Ordering::SeqCst),
2212            "the sandbox survives an in-sandbox kill"
2213        );
2214        let sent = client.commands.lock().expect("commands lock").clone();
2215        assert_eq!(sent.len(), 1, "one command: {sent:?}");
2216        assert!(
2217            sent[0].starts_with("sh -c '") && sent[0].ends_with("' sh 'sleep' 'forever'"),
2218            "the command is passed as arguments, not pasted into the program: {}",
2219            sent[0]
2220        );
2221        assert!(sent[0].contains("sleep 30"), "{}", sent[0]);
2222    }
2223
2224    /// 124 is an ordinary exit status. Without the wrapper's report the command exited on its
2225    /// own, and saying otherwise would tell the caller its command was killed.
2226    #[tokio::test]
2227    async fn a_command_exiting_124_of_its_own_accord_is_an_exit_not_a_timeout() {
2228        let client = ScriptedExec::new(vec![ScriptedExec::exec_result(124, "done\n", "")]);
2229        let sandbox = provider(client.clone());
2230
2231        let frames: Vec<Result<CommandOutput>> = sandbox
2232            .run_command("s1", command(300))
2233            .await
2234            .expect("runs")
2235            .collect()
2236            .await;
2237
2238        assert!(matches!(
2239            frames.last().expect("frames"),
2240            Ok(CommandOutput::Exit { code: 124, .. })
2241        ));
2242    }
2243
2244    /// When the sandbox cannot end the command — exec never returns — the guard ends the
2245    /// sandbox, and reports the deadline only once the sandbox is confirmed gone: on this path
2246    /// untrusted code is known to be running past its deadline. Time is paused, so the guard and
2247    /// the confirmation polls arrive instantly.
2248    #[tokio::test(start_paused = true)]
2249    async fn a_command_the_sandbox_cannot_end_takes_the_sandbox_with_it() {
2250        let client = ScriptedExec::new(Vec::new());
2251        let sandbox = provider(client.clone());
2252
2253        let error = sandbox
2254            .run_command("s1", command(30))
2255            .await
2256            .err()
2257            .expect("a command that outran its deadline has not succeeded");
2258
2259        assert!(
2260            error.to_string().contains("timeoutExceeded"),
2261            "the caller has to be able to tell this apart from a command that failed: {error}"
2262        );
2263        assert!(
2264            client.deleted.load(std::sync::atomic::Ordering::SeqCst),
2265            "the sandbox must actually be deleted, not merely reported as terminated"
2266        );
2267    }
2268
2269    /// A terminate that itself fails leaves the command even more likely to be running, so the
2270    /// unknown outcome has to survive it. Returning the terminate's own error instead would mark
2271    /// this retryable, and a caller honouring that would start the command a second time beside
2272    /// the one still going.
2273    #[tokio::test(start_paused = true)]
2274    async fn a_terminate_that_fails_does_not_hide_the_unknown_outcome() {
2275        let client = ScriptedExec::new(Vec::new());
2276        client
2277            .delete_refuses
2278            .store(true, std::sync::atomic::Ordering::SeqCst);
2279        let sandbox = provider(client.clone());
2280
2281        let error = sandbox
2282            .run_command("s1", command(30))
2283            .await
2284            .err()
2285            .expect("a command that outran its deadline has not succeeded");
2286
2287        assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "{error}");
2288        assert!(
2289            !error.retryable,
2290            "a retry would run the command a second time beside the first: {error}"
2291        );
2292        assert!(
2293            error.to_string().contains("could not end it"),
2294            "the deadline stays the headline: {error}"
2295        );
2296    }
2297
2298    /// Every argument survives quoting as itself: an operator or a space inside one is data,
2299    /// because the shell receives it as an argument rather than as program text.
2300    #[test]
2301    fn the_bounded_shell_passes_arguments_untouched() {
2302        let wrapped = bounded_shell(
2303            &[
2304                "echo".to_string(),
2305                "it's".to_string(),
2306                "&&".to_string(),
2307                "sleep 5".to_string(),
2308            ],
2309            &BTreeMap::new(),
2310            std::time::Duration::from_millis(1500),
2311        );
2312        assert!(wrapped.contains("sleep 1.500"), "{wrapped}");
2313        assert!(
2314            wrapped.ends_with("' sh 'echo' 'it'\\''s' '&&' 'sleep 5'"),
2315            "{wrapped}"
2316        );
2317    }
2318
2319    /// A per-command variable reaches the command, and its value stays data.
2320    ///
2321    /// The exec endpoint takes no environment, so the assignment travels in the shell string —
2322    /// which is exactly where an unquoted value would stop being a value.
2323    #[test]
2324    fn the_bounded_shell_carries_variables_as_data() {
2325        let wrapped = bounded_shell(
2326            &["printenv".to_string(), "TOKEN".to_string()],
2327            &BTreeMap::from([("TOKEN".to_string(), "a'; rm -rf /".to_string())]),
2328            std::time::Duration::from_millis(1500),
2329        );
2330
2331        assert!(
2332            wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' 'printenv' 'TOKEN'"),
2333            "the value has to survive as one argument to env: {wrapped}"
2334        );
2335    }
2336
2337    /// A caller's `PATH` reaches the command and not the wrapper that bounds it.
2338    ///
2339    /// The wrapper resolves `setsid`, `od`, `sleep` and `kill` through `PATH`. A caller able to
2340    /// set it on the wrapper's own shell could hand it no-ops, and the deadline that keeps
2341    /// untrusted code bounded would never fire.
2342    #[test]
2343    fn a_caller_cannot_repoint_the_wrappers_own_path() {
2344        let wrapped = bounded_shell(
2345            &["sleep".to_string(), "forever".to_string()],
2346            &BTreeMap::from([("PATH".to_string(), "/tmp/attacker".to_string())]),
2347            std::time::Duration::from_millis(1500),
2348        );
2349
2350        let (wrapper, argv) = wrapped
2351            .split_once("' sh ")
2352            .expect("the wrapper's program ends where its arguments begin");
2353        assert!(
2354            !wrapper.contains("PATH"),
2355            "the wrapper has to resolve its own tools: {wrapper}"
2356        );
2357        assert_eq!(
2358            argv, "'env' 'PATH=/tmp/attacker' 'sleep' 'forever'",
2359            "the variable belongs to the command, not to the shell that bounds it"
2360        );
2361    }
2362
2363    /// The wrapper the provider builds actually runs, with the variable set.
2364    ///
2365    /// The other tests here assert the shape of the string. This one runs it, because the shape
2366    /// can be exactly what was intended and still not execute: `env` reads operands as
2367    /// assignments until one is not, so a separator in the wrong place becomes the program name.
2368    ///
2369    /// A stand-in `setsid` is supplied because macOS ships none, and it only `exec`s — it starts
2370    /// no sandbox. So this pins that the command runs and the variable arrives; it says nothing
2371    /// about the kill, which needs a real `setsid` and a real process group.
2372    #[test]
2373    #[cfg(unix)]
2374    fn the_wrapper_this_builds_runs_with_the_variable_set() {
2375        use std::os::unix::fs::PermissionsExt;
2376
2377        let bin = std::env::temp_dir().join(format!("alien-azure-shell-{}", std::process::id()));
2378        std::fs::create_dir_all(&bin).expect("a directory for the stand-in");
2379        let setsid = bin.join("setsid");
2380        std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written");
2381        std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755))
2382            .expect("the stand-in is executable");
2383        let path = format!(
2384            "{}:{}",
2385            bin.display(),
2386            std::env::var("PATH").unwrap_or_default()
2387        );
2388
2389        // Addressed absolutely, so the command itself does not depend on the `PATH` under test.
2390        let command = [
2391            "/bin/sh".to_string(),
2392            "-c".to_string(),
2393            "printf %s \"$TOKEN\"".to_string(),
2394        ];
2395
2396        let run = |env: BTreeMap<String, String>| {
2397            let shell = bounded_shell(&command, &env, std::time::Duration::from_secs(5));
2398            std::process::Command::new("/bin/sh")
2399                .arg("-c")
2400                .arg(shell)
2401                .env("PATH", &path)
2402                .output()
2403                .expect("a shell runs")
2404        };
2405
2406        let plain = run(BTreeMap::from([(
2407            "TOKEN".to_string(),
2408            "reached".to_string(),
2409        )]));
2410        assert_eq!(
2411            String::from_utf8_lossy(&plain.stdout),
2412            "reached",
2413            "the variable has to reach the command; stderr {:?}",
2414            String::from_utf8_lossy(&plain.stderr)
2415        );
2416
2417        // The wrapper resolves its own tools before the caller's environment applies, so a `PATH`
2418        // that points nowhere reaches the command and leaves the deadline intact.
2419        let repointed = run(BTreeMap::from([
2420            ("TOKEN".to_string(), "reached".to_string()),
2421            ("PATH".to_string(), "/nonexistent".to_string()),
2422        ]));
2423        assert_eq!(
2424            String::from_utf8_lossy(&repointed.stdout),
2425            "reached",
2426            "a caller's PATH must not break the wrapper; stderr {:?}",
2427            String::from_utf8_lossy(&repointed.stderr)
2428        );
2429
2430        std::fs::remove_dir_all(&bin).ok();
2431    }
2432
2433    /// A sandbox cannot set the variables the deadline wrapper reads.
2434    ///
2435    /// The wrapper runs inside the sandbox and inherits its environment, so a sandbox-level
2436    /// `PATH` picks which `od` draws the deadline nonce and an `IFS` changes how the wrapper
2437    /// reads its own pids — either lets the command claim a deadline nothing enforced. The same
2438    /// names on a command are fine, because those reach only the command.
2439    #[tokio::test]
2440    async fn a_sandbox_cannot_set_what_the_timeout_wrapper_reads() {
2441        // `LD_AUDIT` is the one that proves the family has to go as a family: it runs attacker
2442        // code inside `od`, which is what draws the nonce the deadline report rests on.
2443        for name in [
2444            "PATH",
2445            "IFS",
2446            "LD_PRELOAD",
2447            "LD_LIBRARY_PATH",
2448            "LD_AUDIT",
2449            "LD_DEBUG",
2450            "LD_BIND_NOW",
2451            "SHELLOPTS",
2452            "BASHOPTS",
2453        ] {
2454            let mut client = MockSandboxDataPlaneApi::new();
2455            client.expect_create_sandbox().never();
2456
2457            let error = sandbox_with(client)
2458                .create(CreateSandboxRequest {
2459                    sandbox_id: None,
2460                    tenant_key: None,
2461                    env: BTreeMap::from([(name.to_string(), "/tmp/attacker".to_string())]),
2462                    ..Default::default()
2463                })
2464                .await
2465                .expect_err("a sandbox that could forge its own deadline must not be created");
2466
2467            assert_eq!(error.code, "INVALID_INPUT", "{name}: {error}");
2468        }
2469
2470        // The ordinary case still reaches the create body.
2471        let mut client = MockSandboxDataPlaneApi::new();
2472        client
2473            .expect_create_sandbox()
2474            .times(1)
2475            .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t"))
2476            .returning(|_, _| Ok(running("s1", None)));
2477        client
2478            .expect_get_sandbox()
2479            .returning(|_, id| Ok(running(id, None)));
2480
2481        sandbox_with(client)
2482            .create(CreateSandboxRequest {
2483                sandbox_id: None,
2484                tenant_key: None,
2485                env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]),
2486                ..Default::default()
2487            })
2488            .await
2489            .expect("an ordinary variable is still carried");
2490    }
2491
2492    /// A command with no program is refused rather than run.
2493    ///
2494    /// `env` with assignments and no operand prints the environment it was given and exits 0, so
2495    /// an empty command would hand the caller the sandbox's own variables and read as a command
2496    /// that succeeded.
2497    #[tokio::test]
2498    async fn a_command_naming_no_program_is_refused() {
2499        let mut client = MockSandboxDataPlaneApi::new();
2500        client
2501            .expect_get_sandbox()
2502            .returning(|_, id| Ok(running(id, None)));
2503        client.expect_execute_shell_command().never();
2504
2505        let mut request = command(5);
2506        request.command = String::new();
2507        request.env = BTreeMap::from([("SECRET".to_string(), "hunter2".to_string())]);
2508
2509        let error = match sandbox_with(client).run_command("s1", request).await {
2510            Ok(_) => panic!("a command with no program must not run"),
2511            Err(error) => error,
2512        };
2513
2514        assert_eq!(error.code, "INVALID_INPUT", "{error}");
2515    }
2516
2517    /// A program whose own name carries `=` is refused when the call also declares variables.
2518    ///
2519    /// `env` reads operands as assignments until one is not, so such a name would be taken as a
2520    /// variable and the next argument run in its place — the command silently replaced rather
2521    /// than refused.
2522    #[tokio::test]
2523    async fn a_program_name_env_would_swallow_is_refused() {
2524        let mut client = MockSandboxDataPlaneApi::new();
2525        client
2526            .expect_get_sandbox()
2527            .returning(|_, id| Ok(running(id, None)));
2528        client.expect_execute_shell_command().never();
2529
2530        let mut request = command(5);
2531        request.command = "FOO=bar".to_string();
2532        request.args = vec!["printenv".to_string()];
2533        request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]);
2534
2535        let error = match sandbox_with(client).run_command("s1", request).await {
2536            Ok(_) => panic!("a command env would swallow must not be sent"),
2537            Err(error) => error,
2538        };
2539
2540        assert_eq!(error.code, "INVALID_INPUT", "{error}");
2541    }
2542
2543    /// A name the shell would read as a second command never reaches the shell string.
2544    #[test]
2545    fn a_variable_name_that_is_not_a_name_is_refused() {
2546        for name in ["", "A B", "A;rm", "1A", "A=B", "A-B"] {
2547            let error = checked_env_name("sandbox.runCommand", name)
2548                .expect_err("a name the shell would not read as a name must be refused");
2549            assert_eq!(error.code, "INVALID_INPUT", "name '{name}': {error}");
2550        }
2551        for name in ["A", "_a", "TOKEN_1"] {
2552            checked_env_name("sandbox.runCommand", name)
2553                .unwrap_or_else(|error| panic!("name '{name}' is a shell name: {error}"));
2554        }
2555    }
2556
2557    /// A path that could leave the caller's own directory is refused before anything is sent.
2558    ///
2559    /// Asserted on the client never being called, not on the error: the data plane's own path
2560    /// handling is undocumented, so a request that leaves this process is already outside what
2561    /// this backend can promise.
2562    #[tokio::test]
2563    async fn a_path_that_could_escape_never_reaches_the_data_plane() {
2564        let mut client = MockSandboxDataPlaneApi::new();
2565        client.expect_read_file().never();
2566        client.expect_write_file().never();
2567        let sandbox = sandbox_with(client);
2568
2569        for path in [
2570            "../etc/shadow",
2571            "",
2572            "/",
2573            "work/",
2574            "a//b",
2575            "a/../../b",
2576            "/../escape",
2577        ] {
2578            let error = sandbox
2579                .read_file("s1", path)
2580                .await
2581                .expect_err(&format!("'{path}' must be refused"));
2582            assert_eq!(error.code, "INVALID_INPUT", "{path}: {error}");
2583
2584            sandbox
2585                .write_files("s1", BTreeMap::from([(path.to_string(), vec![1u8])]))
2586                .await
2587                .expect_err(&format!("'{path}' must be refused on write too"));
2588        }
2589
2590        // The same shapes, accepted: a rule that refuses everything would pass the loop above.
2591        // An absolute path is one of them — it means "under the sandbox's own root" on every
2592        // other backend, and arrives at the data plane with the leading slash trimmed.
2593        let mut client = MockSandboxDataPlaneApi::new();
2594        client
2595            .expect_read_file()
2596            .withf(|_, _, path| !path.starts_with('/'))
2597            .times(3)
2598            .returning(|_, _, _| Ok(Vec::new()));
2599        let sandbox = sandbox_with(client);
2600        for path in ["app.py", "src/app.py", "/work/app.py"] {
2601            sandbox
2602                .read_file("s1", path)
2603                .await
2604                .unwrap_or_else(|error| panic!("'{path}' is a normal path: {error}"));
2605        }
2606    }
2607
2608    /// The group, the sandbox and the path each reach the call they belong to, and the bytes come
2609    /// back unchanged.
2610    #[tokio::test]
2611    async fn a_read_carries_the_sandbox_and_path_to_the_data_plane() {
2612        let mut client = MockSandboxDataPlaneApi::new();
2613        client
2614            .expect_read_file()
2615            .withf(|group, sandbox_id, path| {
2616                group == "grp" && sandbox_id == "s1" && path == "src/app.py"
2617            })
2618            .times(1)
2619            .returning(|_, _, _| Ok(b"print(1)\n".to_vec()));
2620
2621        let contents = sandbox_with(client)
2622            .read_file("s1", "src/app.py")
2623            .await
2624            .expect("the read should succeed");
2625
2626        assert_eq!(contents, b"print(1)\n");
2627    }
2628
2629    /// One bad path fails the batch before anything is written.
2630    ///
2631    /// Partial application is the contract for a data plane that refuses midway — not for a path
2632    /// this process could have refused before the first request.
2633    #[tokio::test]
2634    async fn a_batch_with_an_unusable_path_writes_nothing() {
2635        let mut client = MockSandboxDataPlaneApi::new();
2636        client.expect_write_file().never();
2637
2638        let error = sandbox_with(client)
2639            .write_files(
2640                "s1",
2641                BTreeMap::from([
2642                    ("a.txt".to_string(), vec![1u8]),
2643                    ("b/../../escape".to_string(), vec![2u8]),
2644                ]),
2645            )
2646            .await
2647            .expect_err("a path that could escape must fail the batch");
2648
2649        assert_eq!(error.code, "INVALID_INPUT", "{error}");
2650    }
2651
2652    /// Writing stops at the first failure rather than pressing on, which is what makes a partial
2653    /// write observable to the caller instead of a success with a hole in it.
2654    #[tokio::test]
2655    async fn a_failed_write_stops_the_ones_behind_it() {
2656        let mut client = MockSandboxDataPlaneApi::new();
2657        settles_running(&mut client, None);
2658        client
2659            .expect_write_file()
2660            .times(1)
2661            .returning(|_, _, path, _| {
2662                assert_eq!(
2663                    path, "a.txt",
2664                    "the first path in order is the one attempted"
2665                );
2666                Err(AlienError::new(ClientErrorData::RemoteAccessDenied {
2667                    resource_type: "sandbox".to_string(),
2668                    resource_name: "s1".to_string(),
2669                }))
2670            });
2671
2672        let error = sandbox_with(client)
2673            .write_files(
2674                "s1",
2675                BTreeMap::from([
2676                    ("a.txt".to_string(), vec![1u8]),
2677                    ("b.txt".to_string(), vec![2u8]),
2678                ]),
2679            )
2680            .await
2681            .expect_err("a refused write must fail the call");
2682
2683        assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
2684    }
2685
2686    /// The two buckets a caller retries on, and the one it must not.
2687    ///
2688    /// A refusal repeated is refused again, and a file operation whose outcome is unknown is safe
2689    /// to repeat — but a command may already be running, and a retry there runs it twice.
2690    #[tokio::test]
2691    async fn only_the_operations_that_are_safe_to_repeat_are_marked_retryable() {
2692        let mut client = MockSandboxDataPlaneApi::new();
2693        client.expect_read_file().times(1).returning(|_, _, _| {
2694            Err(AlienError::new(ClientErrorData::RemoteResourceNotFound {
2695                resource_type: "file".to_string(),
2696                resource_name: "missing.txt".to_string(),
2697            }))
2698        });
2699        let refused = sandbox_with(client)
2700            .read_file("s1", "missing.txt")
2701            .await
2702            .expect_err("a missing file is an error");
2703        assert_eq!(refused.code, "SANDBOX_COMMAND_FAILED", "{refused}");
2704        assert!(
2705            !refused.retryable,
2706            "repeating a refusal repeats it: {refused}"
2707        );
2708
2709        let mut client = MockSandboxDataPlaneApi::new();
2710        client.expect_read_file().times(1).returning(|_, _, _| {
2711            Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable {
2712                message: "the data plane is unavailable".to_string(),
2713            }))
2714        });
2715        let unreachable = sandbox_with(client)
2716            .read_file("s1", "app.py")
2717            .await
2718            .expect_err("an unavailable data plane is an error");
2719        assert_eq!(unreachable.code, "SANDBOX_UNREACHABLE", "{unreachable}");
2720        assert!(
2721            unreachable.retryable,
2722            "a read is safe to repeat: {unreachable}"
2723        );
2724
2725        let mut client = MockSandboxDataPlaneApi::new();
2726        settles_running(&mut client, None);
2727        client
2728            .expect_execute_shell_command()
2729            .times(1)
2730            .returning(|_, _, _, _| {
2731                Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable {
2732                    message: "the data plane is unavailable".to_string(),
2733                }))
2734            });
2735        let command = match sandbox_with(client).run_command("s1", command(5)).await {
2736            Ok(_) => panic!("an unavailable data plane is an error"),
2737            Err(error) => error,
2738        };
2739        assert_eq!(command.code, "SANDBOX_OUTCOME_UNKNOWN", "{command}");
2740        assert!(
2741            !command.retryable,
2742            "the command may already be running, so a retry would run it twice: {command}"
2743        );
2744    }
2745
2746    /// A sandbox's state is the data plane's, not a default.
2747    ///
2748    /// The four states that are not `Running` each mean a command sent now does not run, so
2749    /// reporting `Running` for any of them tells a caller to use a sandbox that cannot answer.
2750    #[tokio::test]
2751    async fn a_sandbox_reports_the_state_the_data_plane_gave_it() {
2752        for (reported, expected) in [
2753            ("Running", SandboxState::Running),
2754            ("Creating", SandboxState::Starting),
2755            ("Resuming", SandboxState::Starting),
2756            // On its way down, and the four states the trait publishes have no word for it.
2757            ("Stopping", SandboxState::Paused),
2758            ("Stopped", SandboxState::Paused),
2759            ("Suspended", SandboxState::Paused),
2760            ("Idle", SandboxState::Paused),
2761            ("Deleting", SandboxState::Terminated),
2762        ] {
2763            let mut client = MockSandboxDataPlaneApi::new();
2764            let state = reported.to_string();
2765            client.expect_get_sandbox().times(1).returning(move |_, _| {
2766                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2767                    id: "s1".to_string(),
2768                    egress_policy: None,
2769                    state: Some(state.clone()),
2770                })
2771            });
2772
2773            let sandbox = sandbox_with(client)
2774                .get("s1")
2775                .await
2776                .unwrap_or_else(|error| panic!("{reported}: {error}"))
2777                .unwrap_or_else(|| panic!("{reported}: the sandbox exists"));
2778
2779            assert_eq!(sandbox.state, expected, "state {reported}");
2780        }
2781    }
2782
2783    /// A state this client does not know is a preview API that moved, and guessing which of the
2784    /// four it maps to is how a caller ends up talking to a sandbox that is going away.
2785    #[tokio::test]
2786    async fn an_unknown_state_is_an_error_rather_than_a_guess() {
2787        for reported in [Some("Hibernated"), None] {
2788            let mut client = MockSandboxDataPlaneApi::new();
2789            let state = reported.map(str::to_string);
2790            client.expect_get_sandbox().times(1).returning(move |_, _| {
2791                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2792                    id: "s1".to_string(),
2793                    egress_policy: None,
2794                    state: state.clone(),
2795                })
2796            });
2797
2798            let error = sandbox_with(client)
2799                .get("s1")
2800                .await
2801                .expect_err("an unreadable state must not become a sandbox");
2802
2803            assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
2804        }
2805    }
2806
2807    /// The variables the caller declared have to reach the create body: a sandbox inherits none
2808    /// of them, and the data plane accepts a create that omits them.
2809    #[tokio::test]
2810    async fn the_declared_variables_reach_the_create_call() {
2811        let mut client = MockSandboxDataPlaneApi::new();
2812        client
2813            .expect_create_sandbox()
2814            .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t"))
2815            .times(1)
2816            .returning(|_, _| {
2817                Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2818                    id: "s1".to_string(),
2819                    egress_policy: None,
2820                    state: Some("Creating".to_string()),
2821                })
2822            });
2823
2824        // Created as `Creating`, so the create waits: the trait owes the caller a sandbox that
2825        // can already take work, and returning one that cannot pushes the readiness poll into
2826        // every caller.
2827        client
2828            .expect_get_sandbox()
2829            .times(1)
2830            .returning(|_, _| Ok(running("s1", None)));
2831
2832        let sandbox = sandbox_with(client)
2833            .create(CreateSandboxRequest {
2834                sandbox_id: None,
2835                tenant_key: None,
2836                env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]),
2837                ..Default::default()
2838            })
2839            .await
2840            .expect("the create should succeed");
2841
2842        assert_eq!(sandbox.state, SandboxState::Running);
2843    }
2844
2845    fn running(
2846        id: &str,
2847        egress: Option<EgressPolicy>,
2848    ) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2849        alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2850            id: id.to_string(),
2851            egress_policy: egress,
2852            state: Some("Running".to_string()),
2853        }
2854    }
2855
2856    fn sandbox_denying(client: MockSandboxDataPlaneApi, egress: SandboxEgress) -> AzureSandbox {
2857        AzureSandbox::new(
2858            std::sync::Arc::new(client),
2859            "grp".to_string(),
2860            "ubuntu".to_string(),
2861            egress,
2862            None,
2863            "1000m".to_string(),
2864            "2048Mi".to_string(),
2865            None,
2866        )
2867    }
2868
2869    /// What each declared mode is created with.
2870    ///
2871    /// The inspection mode is the half that is easy to leave out and impossible to notice: under
2872    /// anything but `Full` a `Deny` default still lets every non-HTTP protocol out, so a sandbox
2873    /// would carry the label and none of the containment. `allow` must send no policy, because
2874    /// `Full` would block the traffic `allow` promises.
2875    #[tokio::test]
2876    async fn each_declared_mode_is_created_with_the_policy_that_realises_it() {
2877        let mut client = MockSandboxDataPlaneApi::new();
2878        client
2879            .expect_create_sandbox()
2880            .times(1)
2881            .returning(|_, request| {
2882                let policy = request.egress.expect("deny must send a policy");
2883                assert_eq!(policy.default_action, "Deny");
2884                assert_eq!(
2885                    policy.traffic_inspection.as_deref(),
2886                    Some("Full"),
2887                    "only Full inspection blocks non-HTTP traffic"
2888                );
2889                assert_eq!(
2890                    policy.host_rules,
2891                    vec![EgressHostRule {
2892                        pattern: "*".to_string(),
2893                        action: "Deny".to_string(),
2894                    }],
2895                    "deny is written as a rule too, so it does not rest on how the proxy treats a \
2896                     policy with no rules"
2897                );
2898                Ok(running("s1", Some(policy)))
2899            });
2900        settles_running(
2901            &mut client,
2902            Some(EgressPolicy {
2903                default_action: "Deny".to_string(),
2904                host_rules: vec![EgressHostRule {
2905                    pattern: "*".to_string(),
2906                    action: "Deny".to_string(),
2907                }],
2908                rules: Vec::new(),
2909                unmodelled: Default::default(),
2910                traffic_inspection: Some("Full".to_string()),
2911            }),
2912        );
2913        sandbox_denying(client, SandboxEgress::Deny)
2914            .create(CreateSandboxRequest::default())
2915            .await
2916            .expect("deny should create");
2917
2918        let mut client = MockSandboxDataPlaneApi::new();
2919        client
2920            .expect_create_sandbox()
2921            .times(1)
2922            .returning(|_, request| {
2923                let policy = request.egress.expect("allowDomains must send a policy");
2924                assert_eq!(policy.default_action, "Deny", "anything unlisted is denied");
2925                assert_eq!(policy.traffic_inspection.as_deref(), Some("Full"));
2926                assert_eq!(
2927                    policy.host_rules,
2928                    vec![EgressHostRule {
2929                        pattern: "api.example.com".to_string(),
2930                        action: "Allow".to_string(),
2931                    }]
2932                );
2933                Ok(running("s1", Some(policy)))
2934            });
2935        settles_running(
2936            &mut client,
2937            Some(EgressPolicy {
2938                default_action: "Deny".to_string(),
2939                host_rules: vec![EgressHostRule {
2940                    pattern: "api.example.com".to_string(),
2941                    action: "Allow".to_string(),
2942                }],
2943                rules: Vec::new(),
2944                unmodelled: Default::default(),
2945                traffic_inspection: Some("Full".to_string()),
2946            }),
2947        );
2948        sandbox_denying(
2949            client,
2950            SandboxEgress::AllowDomains {
2951                domains: vec!["api.example.com".to_string()],
2952            },
2953        )
2954        .create(CreateSandboxRequest::default())
2955        .await
2956        .expect("allowDomains should create");
2957
2958        let mut client = MockSandboxDataPlaneApi::new();
2959        client
2960            .expect_create_sandbox()
2961            .times(1)
2962            .returning(|_, request| {
2963                assert!(
2964                    request.egress.is_none(),
2965                    "an open sandbox sends no policy: Full inspection would block non-HTTP traffic"
2966                );
2967                Ok(running("s1", None))
2968            });
2969        settles_running(&mut client, None);
2970        sandbox_denying(client, SandboxEgress::Allow)
2971            .create(CreateSandboxRequest::default())
2972            .await
2973            .expect("allow should create");
2974    }
2975
2976    /// A restriction that did not take effect is the failure this whole path exists to prevent,
2977    /// so the sandbox is deleted rather than returned with a `deny` label and a live network.
2978    #[tokio::test]
2979    async fn a_sandbox_that_came_up_without_its_policy_is_deleted_rather_than_handed_back() {
2980        for came_up_with in [
2981            None,
2982            // The default action alone: every non-HTTP protocol still leaves.
2983            Some(EgressPolicy {
2984                default_action: "Deny".to_string(),
2985                unmodelled: Default::default(),
2986                rules: Vec::new(),
2987                host_rules: Vec::new(),
2988                traffic_inspection: Some("Partial".to_string()),
2989            }),
2990            // Inspected, and open.
2991            Some(EgressPolicy {
2992                default_action: "Allow".to_string(),
2993                unmodelled: Default::default(),
2994                rules: Vec::new(),
2995                host_rules: Vec::new(),
2996                traffic_inspection: Some("Full".to_string()),
2997            }),
2998        ] {
2999            let mut client = MockSandboxDataPlaneApi::new();
3000            let effective = came_up_with.clone();
3001            client
3002                .expect_create_sandbox()
3003                .times(1)
3004                .returning(move |_, _| Ok(running("s1", effective.clone())));
3005            settles_running(&mut client, came_up_with.clone());
3006            client
3007                .expect_delete_sandbox()
3008                .withf(|_, id| id == "s1")
3009                .times(1)
3010                .returning(|_, _| Ok(()));
3011
3012            let error = sandbox_denying(client, SandboxEgress::Deny)
3013                .create(CreateSandboxRequest::default())
3014                .await
3015                .expect_err("a sandbox without its policy must not be handed back");
3016
3017            assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3018        }
3019    }
3020
3021    /// A host the declaration named that the sandbox is not running is the same failure as a
3022    /// missing policy: the caller believes traffic to it is allowed and it is not, or worse, the
3023    /// list came back holding something else.
3024    #[tokio::test]
3025    async fn a_missing_host_rule_fails_the_create() {
3026        let mut client = MockSandboxDataPlaneApi::new();
3027        let elsewhere = EgressPolicy {
3028            default_action: "Deny".to_string(),
3029            unmodelled: Default::default(),
3030            rules: Vec::new(),
3031            host_rules: vec![EgressHostRule {
3032                pattern: "elsewhere.example.com".to_string(),
3033                action: "Allow".to_string(),
3034            }],
3035            traffic_inspection: Some("Full".to_string()),
3036        };
3037        let echoed = elsewhere.clone();
3038        client
3039            .expect_create_sandbox()
3040            .times(1)
3041            .returning(move |_, _| Ok(running("s1", Some(echoed.clone()))));
3042        settles_running(&mut client, Some(elsewhere));
3043        client
3044            .expect_delete_sandbox()
3045            .times(1)
3046            .returning(|_, _| Ok(()));
3047
3048        let error = sandbox_denying(
3049            client,
3050            SandboxEgress::AllowDomains {
3051                domains: vec!["api.example.com".to_string()],
3052            },
3053        )
3054        .create(CreateSandboxRequest::default())
3055        .await
3056        .expect_err("a host the declaration named must be in the effective policy");
3057
3058        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3059    }
3060
3061    /// A sandbox that is going away is not one to reconnect to.
3062    ///
3063    /// `get_or_create` hands back whatever `get` finds, and the id of a deleting sandbox will not
3064    /// run again — so the caller would receive a handle whose every command lands on nothing.
3065    #[tokio::test]
3066    async fn a_terminated_sandbox_is_replaced_rather_than_reconnected_to() {
3067        let mut client = MockSandboxDataPlaneApi::new();
3068        client.expect_get_sandbox().times(1).returning(|_, id| {
3069            Ok(running(id, None)).map(
3070                |mut sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox| {
3071                    sandbox.state = Some("Deleting".to_string());
3072                    sandbox
3073                },
3074            )
3075        });
3076        client
3077            .expect_create_sandbox()
3078            .times(1)
3079            .returning(|_, request| Ok(running("fresh", request.egress)));
3080        settles_running(
3081            &mut client,
3082            Some(EgressPolicy {
3083                default_action: "Deny".to_string(),
3084                host_rules: vec![EgressHostRule {
3085                    pattern: "*".to_string(),
3086                    action: "Deny".to_string(),
3087                }],
3088                rules: Vec::new(),
3089                unmodelled: Default::default(),
3090                traffic_inspection: Some("Full".to_string()),
3091            }),
3092        );
3093
3094        // Declared `deny`, because a terminated sandbox carries no policy — judging it before
3095        // reading the state reported a disappearing sandbox as an uncontained one.
3096        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3097            .get_or_create(CreateSandboxRequest {
3098                sandbox_id: Some("going-away".to_string()),
3099                tenant_key: None,
3100                env: BTreeMap::new(),
3101                ..Default::default()
3102            })
3103            .await
3104            .expect("a new sandbox should be created");
3105
3106        assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
3107        assert!(sandbox.created, "a replacement is a sandbox this call made");
3108    }
3109
3110    /// A permission the declaration never asked for fails the create as surely as a missing one.
3111    ///
3112    /// The check looks outward as well as inward: an `Allow` the sandbox holds and the caller did
3113    /// not name is the whole failure this path exists to catch, and a group-scoped policy is a
3114    /// documented way for one to appear.
3115    #[tokio::test]
3116    async fn a_permission_nobody_asked_for_fails_the_create() {
3117        let asked_for = || SandboxEgress::AllowDomains {
3118            domains: vec!["api.example.com".to_string()],
3119        };
3120        let declared = EgressHostRule {
3121            pattern: "api.example.com".to_string(),
3122            action: "Allow".to_string(),
3123        };
3124
3125        for came_up_with in [
3126            // A second host, allowed.
3127            EgressPolicy {
3128                default_action: "Deny".to_string(),
3129                unmodelled: Default::default(),
3130                host_rules: vec![
3131                    declared.clone(),
3132                    EgressHostRule {
3133                        pattern: "exfil.example.com".to_string(),
3134                        action: "Allow".to_string(),
3135                    },
3136                ],
3137                rules: Vec::new(),
3138                traffic_inspection: Some("Full".to_string()),
3139            },
3140            // Everything, through the list this client never writes.
3141            EgressPolicy {
3142                default_action: "Deny".to_string(),
3143                unmodelled: Default::default(),
3144                host_rules: vec![declared.clone()],
3145                rules: vec![EgressRule {
3146                    name: None,
3147                    r#match: Some(EgressRuleMatch {
3148                        host: "*".to_string(),
3149                        path: None,
3150                        methods: None,
3151                    }),
3152                    action: Some(EgressRuleAction {
3153                        action_type: "Allow".to_string(),
3154                        host: None,
3155                        path: None,
3156                        scheme: None,
3157                        headers: None,
3158                    }),
3159                }],
3160                traffic_inspection: Some("Full".to_string()),
3161            },
3162        ] {
3163            let mut client = MockSandboxDataPlaneApi::new();
3164            let effective = came_up_with.clone();
3165            client
3166                .expect_create_sandbox()
3167                .times(1)
3168                .returning(move |_, _| Ok(running("s1", Some(effective.clone()))));
3169            settles_running(&mut client, Some(came_up_with.clone()));
3170            client
3171                .expect_delete_sandbox()
3172                .times(1)
3173                .returning(|_, _| Ok(()));
3174
3175            let error = sandbox_denying(client, asked_for())
3176                .create(CreateSandboxRequest::default())
3177                .await
3178                .expect_err("a permission nobody asked for must fail the create");
3179
3180            assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3181        }
3182
3183        // The same policy without the extra permission creates normally, so the rule above is
3184        // refusing the addition rather than refusing everything.
3185        let mut client = MockSandboxDataPlaneApi::new();
3186        client
3187            .expect_create_sandbox()
3188            .times(1)
3189            .returning(move |_, _| {
3190                Ok(running(
3191                    "s1",
3192                    Some(EgressPolicy {
3193                        default_action: "Deny".to_string(),
3194                        unmodelled: Default::default(),
3195                        host_rules: vec![EgressHostRule {
3196                            pattern: "api.example.com".to_string(),
3197                            action: "Allow".to_string(),
3198                        }],
3199                        rules: Vec::new(),
3200                        traffic_inspection: Some("Full".to_string()),
3201                    }),
3202                ))
3203            });
3204        settles_running(
3205            &mut client,
3206            Some(EgressPolicy {
3207                default_action: "Deny".to_string(),
3208                unmodelled: Default::default(),
3209                host_rules: vec![EgressHostRule {
3210                    pattern: "api.example.com".to_string(),
3211                    action: "Allow".to_string(),
3212                }],
3213                rules: Vec::new(),
3214                traffic_inspection: Some("Full".to_string()),
3215            }),
3216        );
3217        sandbox_denying(client, asked_for())
3218            .create(CreateSandboxRequest::default())
3219            .await
3220            .expect("the policy that was asked for should create");
3221    }
3222
3223    /// Pause and resume are one call each, and each has to reach the verb it names.
3224    ///
3225    /// Returning on acceptance rather than on the state change is the same contract AWS follows,
3226    /// so a caller that needs the sandbox stopped polls `get` — the alternative is a call that
3227    /// blocks for a resume Microsoft describes as sub-second and a stop that is not.
3228    #[tokio::test]
3229    async fn pause_and_resume_reach_their_own_verbs() {
3230        let mut client = MockSandboxDataPlaneApi::new();
3231        client
3232            .expect_stop_sandbox()
3233            .withf(|group, id| group == "grp" && id == "s1")
3234            .times(1)
3235            .returning(|_, _| Ok(()));
3236        client.expect_resume_sandbox().never();
3237        sandbox_with(client)
3238            .pause("s1")
3239            .await
3240            .expect("pause should be accepted");
3241
3242        // Found asleep, so the verb is actually sent — a mock that answers `Running` on the
3243        // first read would let this pass with `resume_sandbox` never called at all.
3244        let mut client = MockSandboxDataPlaneApi::new();
3245        let mut reads = 0;
3246        client.expect_get_sandbox().returning(move |_, id| {
3247            reads += 1;
3248            let mut sandbox = running(id, None);
3249            if reads < 3 {
3250                sandbox.state = Some("Stopped".to_string());
3251            }
3252            Ok(sandbox)
3253        });
3254        client
3255            .expect_resume_sandbox()
3256            .withf(|group, id| group == "grp" && id == "s1")
3257            .times(1)
3258            .returning(|_, _| Ok(()));
3259        client.expect_stop_sandbox().never();
3260        sandbox_with(client)
3261            .resume("s1")
3262            .await
3263            .expect("resume should reach a running sandbox");
3264    }
3265
3266    /// A lost or transient stop response is reconciled against the record, not reported as a
3267    /// failure the caller cannot act on: a sandbox that came back suspended means the stop landed.
3268    #[tokio::test]
3269    async fn pause_owns_a_lost_stop_when_the_sandbox_comes_back_suspended() {
3270        // Stop errors, but the sandbox reads Suspended — the stop took effect, so report success.
3271        let mut client = MockSandboxDataPlaneApi::new();
3272        client
3273            .expect_stop_sandbox()
3274            .times(1)
3275            .returning(|_, _| Err(http_error(503, "gateway timeout")));
3276        client.expect_get_sandbox().returning(|_, id| {
3277            let mut sandbox = running(id, None);
3278            sandbox.state = Some("Stopped".to_string());
3279            Ok(sandbox)
3280        });
3281        sandbox_with(client)
3282            .pause("s1")
3283            .await
3284            .expect("a stop that landed is success even when its response was lost");
3285
3286        // Stop errors and the sandbox is still Running — the stop did not land, so surface it.
3287        let mut client = MockSandboxDataPlaneApi::new();
3288        client
3289            .expect_stop_sandbox()
3290            .times(1)
3291            .returning(|_, _| Err(http_error(503, "gateway timeout")));
3292        client
3293            .expect_get_sandbox()
3294            .returning(|_, id| Ok(running(id, None)));
3295        sandbox_with(client)
3296            .pause("s1")
3297            .await
3298            .expect_err("a stop that did not land must surface the failure");
3299    }
3300
3301    /// A declared idle-suspend policy has to reach the create body.
3302    ///
3303    /// The data plane takes it at create and nowhere else, and accepts a body without it — so a
3304    /// declaration that stops at the binding leaves the sandbox on whatever the service defaults
3305    /// to, with nothing anywhere saying the number was ignored.
3306    #[tokio::test]
3307    async fn a_declared_idle_suspend_reaches_the_create_call() {
3308        let mut client = MockSandboxDataPlaneApi::new();
3309        client
3310            .expect_create_sandbox()
3311            .withf(|_, request| request.idle_pause_seconds == Some(900))
3312            .times(1)
3313            .returning(|_, _| Ok(running("s1", None)));
3314        settles_running(&mut client, None);
3315
3316        AzureSandbox::new(
3317            std::sync::Arc::new(client),
3318            "grp".to_string(),
3319            "ubuntu".to_string(),
3320            SandboxEgress::Allow,
3321            Some(900),
3322            "1000m".to_string(),
3323            "2048Mi".to_string(),
3324            None,
3325        )
3326        .create(CreateSandboxRequest::default())
3327        .await
3328        .expect("the create should succeed");
3329    }
3330
3331    /// Reconnect is the path a stale policy survives on.
3332    ///
3333    /// Azure has no wall-clock ceiling and an idle sandbox only suspends, so one created under an
3334    /// older declaration outlives the change. Checking only at create hands the caller a sandbox
3335    /// whose containment is whatever it was built with, under the label it has now.
3336    #[tokio::test]
3337    async fn a_reconnect_to_a_sandbox_built_under_another_policy_is_refused() {
3338        let mut client = MockSandboxDataPlaneApi::new();
3339        client.expect_get_sandbox().times(1).returning(|_, id| {
3340            // What an `allow` declaration built, before it was changed to `deny`.
3341            Ok(running(id, None))
3342        });
3343
3344        let error = sandbox_denying(client, SandboxEgress::Deny)
3345            .get("built-under-allow")
3346            .await
3347            .expect_err("a sandbox without the declared policy must not be handed back");
3348
3349        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3350    }
3351
3352    /// A create whose response cannot be read owns a sandbox the caller has no id for.
3353    ///
3354    /// Azure allocates the id and has no enumeration verb, so an abandoned sandbox has no
3355    /// id-holder and nothing to reap it — it runs until someone finds it by hand.
3356    #[tokio::test]
3357    async fn a_create_that_cannot_be_read_deletes_what_it_made() {
3358        let mut client = MockSandboxDataPlaneApi::new();
3359        let unreadable = || {
3360            Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
3361                id: "orphan".to_string(),
3362                egress_policy: None,
3363                state: Some("Hibernated".to_string()),
3364            })
3365        };
3366        client
3367            .expect_create_sandbox()
3368            .times(1)
3369            .returning(move |_, _| unreadable());
3370        client
3371            .expect_get_sandbox()
3372            .returning(move |_, _| unreadable());
3373        client
3374            .expect_delete_sandbox()
3375            .withf(|_, id| id == "orphan")
3376            .times(1)
3377            .returning(|_, _| Ok(()));
3378
3379        let error = sandbox_with(client)
3380            .create(CreateSandboxRequest::default())
3381            .await
3382            .expect_err("an unreadable state must fail the create");
3383
3384        assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
3385    }
3386
3387    /// The three shapes a permitting policy can arrive in that a looser check would pass.
3388    #[tokio::test]
3389    async fn a_policy_this_client_cannot_read_whole_fails_the_create() {
3390        let declared = || SandboxEgress::Deny;
3391        let catch_all = EgressHostRule {
3392            pattern: "*".to_string(),
3393            action: "Deny".to_string(),
3394        };
3395
3396        for came_up_with in [
3397            // A host rule carrying an action this client cannot weigh: `Transform` reaches a host
3398            // by rewriting the request rather than by naming it.
3399            EgressPolicy {
3400                default_action: "Deny".to_string(),
3401                host_rules: vec![
3402                    catch_all.clone(),
3403                    EgressHostRule {
3404                        pattern: "api.example.com".to_string(),
3405                        action: "Transform".to_string(),
3406                    },
3407                ],
3408                rules: Vec::new(),
3409                unmodelled: Default::default(),
3410                traffic_inspection: Some("Full".to_string()),
3411            },
3412            // A field this client does not model at all.
3413            EgressPolicy {
3414                default_action: "Deny".to_string(),
3415                host_rules: vec![catch_all.clone()],
3416                rules: Vec::new(),
3417                unmodelled: BTreeMap::from([(
3418                    "bypassList".to_string(),
3419                    serde_json::json!(["exfil.example.com"]),
3420                )]),
3421                traffic_inspection: Some("Full".to_string()),
3422            },
3423        ] {
3424            let mut client = MockSandboxDataPlaneApi::new();
3425            let effective = came_up_with.clone();
3426            client
3427                .expect_create_sandbox()
3428                .times(1)
3429                .returning(move |_, _| Ok(running("s1", Some(effective.clone()))));
3430            settles_running(&mut client, Some(came_up_with.clone()));
3431            client
3432                .expect_delete_sandbox()
3433                .times(1)
3434                .returning(|_, _| Ok(()));
3435
3436            let error = sandbox_denying(client, declared())
3437                .create(CreateSandboxRequest::default())
3438                .await
3439                .expect_err("a policy this client cannot read whole must fail the create");
3440
3441            assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3442        }
3443
3444        // Case is the data plane's to choose: the same policy, normalised, still creates.
3445        let mut client = MockSandboxDataPlaneApi::new();
3446        client.expect_create_sandbox().times(1).returning(|_, _| {
3447            Ok(running(
3448                "s1",
3449                Some(EgressPolicy {
3450                    default_action: "deny".to_string(),
3451                    host_rules: vec![EgressHostRule {
3452                        pattern: "*".to_string(),
3453                        action: "deny".to_string(),
3454                    }],
3455                    rules: Vec::new(),
3456                    unmodelled: Default::default(),
3457                    traffic_inspection: Some("full".to_string()),
3458                }),
3459            ))
3460        });
3461        settles_running(
3462            &mut client,
3463            Some(EgressPolicy {
3464                default_action: "deny".to_string(),
3465                host_rules: vec![EgressHostRule {
3466                    pattern: "*".to_string(),
3467                    action: "deny".to_string(),
3468                }],
3469                rules: Vec::new(),
3470                unmodelled: Default::default(),
3471                traffic_inspection: Some("full".to_string()),
3472            }),
3473        );
3474        sandbox_denying(client, declared())
3475            .create(CreateSandboxRequest::default())
3476            .await
3477            .expect("a normalised echo of the same policy is the same policy");
3478    }
3479
3480    /// A sandbox the declaration no longer matches is replaced, not a permanent error.
3481    ///
3482    /// `get_or_create` owes the caller a usable sandbox, and a stale-policy sandbox is as
3483    /// unusable as a terminated one. The old sandbox is left running: another revision of the
3484    /// same stack may share this group, and the replacement is what this caller asked for.
3485    #[tokio::test]
3486    async fn a_stale_policy_sandbox_is_replaced_rather_than_refused_forever() {
3487        let mut client = MockSandboxDataPlaneApi::new();
3488        // The stale sandbox is running under no policy at all; the replacement carries the one
3489        // the declaration asks for.
3490        client.expect_get_sandbox().returning(move |_, id| {
3491            if id == "built-under-allow" {
3492                return Ok(running(id, None));
3493            }
3494            Ok(running(
3495                id,
3496                Some(EgressPolicy {
3497                    default_action: "Deny".to_string(),
3498                    host_rules: vec![EgressHostRule {
3499                        pattern: "*".to_string(),
3500                        action: "Deny".to_string(),
3501                    }],
3502                    rules: Vec::new(),
3503                    unmodelled: Default::default(),
3504                    traffic_inspection: Some("Full".to_string()),
3505                }),
3506            ))
3507        });
3508        client.expect_delete_sandbox().never();
3509        client
3510            .expect_create_sandbox()
3511            .times(1)
3512            .returning(|_, request| Ok(running("fresh", request.egress)));
3513
3514        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3515            .get_or_create(CreateSandboxRequest {
3516                sandbox_id: Some("built-under-allow".to_string()),
3517                tenant_key: None,
3518                env: BTreeMap::new(),
3519                ..Default::default()
3520            })
3521            .await
3522            .expect("a stale sandbox is replaced");
3523
3524        assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
3525        assert!(sandbox.created, "a replacement is a sandbox this call made");
3526    }
3527
3528    /// A sandbox id is one path segment, because it is interpolated into the data-plane URL and
3529    /// `..` in a URL resolves — reaching a sandbox group this binding was never scoped to.
3530    #[tokio::test]
3531    async fn a_traversing_sandbox_id_never_reaches_the_data_plane() {
3532        let mut client = MockSandboxDataPlaneApi::new();
3533        client.expect_get_sandbox().never();
3534        client.expect_delete_sandbox().never();
3535        client.expect_execute_shell_command().never();
3536        let sandbox = sandbox_with(client);
3537
3538        for id in ["../../other-group/sandboxes/theirs", "a/b", "", "has space"] {
3539            assert_eq!(
3540                sandbox
3541                    .get(id)
3542                    .await
3543                    .expect_err(&format!("'{id}' must be refused"))
3544                    .code,
3545                "INVALID_INPUT"
3546            );
3547            sandbox
3548                .terminate(id)
3549                .await
3550                .expect_err(&format!("'{id}' must be refused on every verb"));
3551        }
3552    }
3553
3554    /// A stale sandbox cannot run code, which is the one verb where it matters most.
3555    ///
3556    /// An id outlives a declaration change and the SDK hands `runCommand` an arbitrary string, so
3557    /// without this the containment check is one a caller can walk around by keeping an id.
3558    #[tokio::test]
3559    async fn a_stale_policy_sandbox_cannot_run_a_command() {
3560        let mut client = MockSandboxDataPlaneApi::new();
3561        client
3562            .expect_get_sandbox()
3563            .times(1)
3564            .returning(|_, id| Ok(running(id, None)));
3565        // Refused, not reaped: this call did not create the sandbox and was not asked to replace
3566        // it, and two revisions of a stack share a sandbox group.
3567        client.expect_delete_sandbox().never();
3568        client.expect_execute_shell_command().never();
3569
3570        let error = match sandbox_denying(client, SandboxEgress::Deny)
3571            .run_command("built-under-allow", command(5))
3572            .await
3573        {
3574            Ok(_) => panic!("a sandbox without the declared policy must not run code"),
3575            Err(error) => error,
3576        };
3577
3578        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3579    }
3580
3581    /// A policy that changed while a sandbox was suspended is caught on the way back.
3582    ///
3583    /// The effective policy can be set on the group, somewhere this binding never writes, so the
3584    /// read that finds a stopped sandbox is not the read that decides whether it is contained —
3585    /// the one taken after it comes up is.
3586    #[tokio::test]
3587    async fn a_policy_that_changed_during_suspension_is_caught_on_reconnect() {
3588        let declared = EgressPolicy {
3589            default_action: "Deny".to_string(),
3590            host_rules: vec![EgressHostRule {
3591                pattern: "*".to_string(),
3592                action: "Deny".to_string(),
3593            }],
3594            rules: Vec::new(),
3595            unmodelled: Default::default(),
3596            traffic_inspection: Some("Full".to_string()),
3597        };
3598
3599        let mut client = MockSandboxDataPlaneApi::new();
3600        let mut reads = 0;
3601        let stopped = declared.clone();
3602        client.expect_get_sandbox().returning(move |_, id| {
3603            // The replacement is compliant; only the sandbox that was asleep woke up wider.
3604            if id != "was-suspended" {
3605                return Ok(running(id, Some(stopped.clone())));
3606            }
3607            reads += 1;
3608            Ok(match reads {
3609                // Suspended and compliant for the reconnect's read and the wait's first poll, so
3610                // the reconnect proceeds and the wait is what wakes it.
3611                1 | 2 => {
3612                    let mut sandbox = running(id, Some(stopped.clone()));
3613                    sandbox.state = Some("Stopped".to_string());
3614                    sandbox
3615                }
3616                // Awake, and the group gained a host nobody here asked for.
3617                _ => running(
3618                    id,
3619                    Some(EgressPolicy {
3620                        host_rules: vec![
3621                            EgressHostRule {
3622                                pattern: "*".to_string(),
3623                                action: "Deny".to_string(),
3624                            },
3625                            EgressHostRule {
3626                                pattern: "exfil.example.com".to_string(),
3627                                action: "Allow".to_string(),
3628                            },
3629                        ],
3630                        ..stopped.clone()
3631                    }),
3632                ),
3633            })
3634        });
3635        // Woken here, so this call owes the put-back: it is returned to the state it was found
3636        // in rather than destroyed, because another revision may hold the same id.
3637        client.expect_resume_sandbox().returning(|_, _| Ok(()));
3638        client
3639            .expect_stop_sandbox()
3640            .withf(|_, id| id == "was-suspended")
3641            .times(1)
3642            .returning(|_, _| Ok(()));
3643        client.expect_delete_sandbox().never();
3644        client
3645            .expect_create_sandbox()
3646            .times(1)
3647            .returning(|_, request| Ok(running("fresh", request.egress)));
3648
3649        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3650            .get_or_create(CreateSandboxRequest {
3651                sandbox_id: Some("was-suspended".to_string()),
3652                tenant_key: None,
3653                env: BTreeMap::new(),
3654                ..Default::default()
3655            })
3656            .await
3657            .expect("a caller asking for a sandbox gets a usable one");
3658
3659        // Answered the same way as a terminated id: the caller gets a fresh sandbox. The one
3660        // that woke up wider is put back to sleep, not deleted — the id may be another
3661        // revision's.
3662        assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
3663        assert!(sandbox.created, "a replacement is a sandbox this call made");
3664    }
3665
3666    /// A sandbox left behind must not publish the cloud's own response text.
3667    ///
3668    /// `discard` wraps the reason so the leak is named, and the wrapper inherits visibility: the
3669    /// error it wraps is the cloud client's, which carries the request and response of the call
3670    /// that failed, and the flag `into_external` reads is the outermost one.
3671    #[tokio::test]
3672    async fn a_sandbox_left_behind_does_not_publish_the_response_body() {
3673        const SECRET: &str = "tenant-only-detail";
3674
3675        let mut client = MockSandboxDataPlaneApi::new();
3676        client
3677            .expect_create_sandbox()
3678            .times(1)
3679            .returning(|_, _| Ok(running("s1", None)));
3680        // The readiness read and the delete both fail, which is one failure in practice: a
3681        // missing data-plane role refuses every verb.
3682        client
3683            .expect_get_sandbox()
3684            .returning(|_, _| Err(http_error(403, SECRET)));
3685        client
3686            .expect_delete_sandbox()
3687            .returning(|_, _| Err(http_error(403, SECRET)));
3688
3689        let error = sandbox_with(client)
3690            .create(CreateSandboxRequest::default())
3691            .await
3692            .expect_err("a create that cannot be confirmed must fail");
3693
3694        assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
3695        assert!(
3696            error.internal,
3697            "the wrapper must inherit the cloud error's visibility: {error}"
3698        );
3699    }
3700
3701    /// Waking a sandbox puts what it was running back on the network, so it is gated like
3702    /// `run_command`: a caller holding an id from an older declaration must not be able to
3703    /// resume its way around the check.
3704    #[tokio::test]
3705    async fn a_stale_policy_sandbox_cannot_be_resumed() {
3706        let mut client = MockSandboxDataPlaneApi::new();
3707        // Found asleep, so this call is what wakes it — and therefore what must put it back.
3708        let mut reads = 0;
3709        client.expect_get_sandbox().returning(move |_, id| {
3710            reads += 1;
3711            let mut sandbox = running(id, None);
3712            // Asleep for the resume's own read and the wait's first poll, so the wait is what
3713            // wakes it — and therefore what owes the put-back.
3714            if reads <= 2 {
3715                sandbox.state = Some("Stopped".to_string());
3716            }
3717            Ok(sandbox)
3718        });
3719        client.expect_resume_sandbox().returning(|_, _| Ok(()));
3720        // Refused, not reaped: the caller asked to wake a sandbox, not to lose it. Put back,
3721        // because this call is what woke it.
3722        client.expect_delete_sandbox().never();
3723        client
3724            .expect_stop_sandbox()
3725            .times(1)
3726            .returning(|_, _| Ok(()));
3727
3728        let error = sandbox_denying(client, SandboxEgress::Deny)
3729            .resume("built-under-allow")
3730            .await
3731            .expect_err("a sandbox without the declared policy must not be woken");
3732
3733        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3734    }
3735
3736    /// A resume that finds the sandbox already awake refuses without touching it.
3737    ///
3738    /// Two revisions of a stack share a sandbox group, so stopping a sandbox this call did not
3739    /// wake ends whatever command the other revision is running. Refusing is this call's to do;
3740    /// suspending someone else's work is not.
3741    #[tokio::test]
3742    async fn a_sandbox_this_call_did_not_wake_is_left_running() {
3743        let mut client = MockSandboxDataPlaneApi::new();
3744        client
3745            .expect_get_sandbox()
3746            .returning(|_, id| Ok(running(id, None)));
3747        client.expect_resume_sandbox().never();
3748        client.expect_stop_sandbox().never();
3749        client.expect_delete_sandbox().never();
3750
3751        let error = sandbox_denying(client, SandboxEgress::Deny)
3752            .resume("someone-elses-sandbox")
3753            .await
3754            .expect_err("a sandbox without the declared policy must not be handed back");
3755
3756        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3757    }
3758
3759    /// A sandbox that came up on its own is not this call's to suspend.
3760    ///
3761    /// A read taken before the wait sees `Creating` and calls that asleep, but nothing here woke
3762    /// it — another revision created it a moment earlier. Stopping it on a policy mismatch ends
3763    /// that revision's sandbox; only refusing is this call's to do.
3764    #[tokio::test]
3765    async fn a_sandbox_that_came_up_on_its_own_is_not_suspended() {
3766        let mut client = MockSandboxDataPlaneApi::new();
3767        let mut reads = 0;
3768        client.expect_get_sandbox().returning(move |_, id| {
3769            reads += 1;
3770            let mut sandbox = running(id, None);
3771            if reads <= 2 {
3772                sandbox.state = Some("Creating".to_string());
3773            }
3774            Ok(sandbox)
3775        });
3776        client.expect_resume_sandbox().never();
3777        client.expect_stop_sandbox().never();
3778        client.expect_delete_sandbox().never();
3779
3780        let error = sandbox_denying(client, SandboxEgress::Deny)
3781            .resume("created-by-another-revision")
3782            .await
3783            .expect_err("a sandbox without the declared policy must not be handed back");
3784
3785        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3786    }
3787
3788    /// A suspended sandbox that reports no policy reads as suspended, not as a mismatch.
3789    ///
3790    /// Whether the data plane reports `egressPolicy` off `Running` is unverified; judging it
3791    /// here would turn every idle-suspended sandbox into a containment failure.
3792    #[tokio::test]
3793    async fn a_suspended_sandbox_reporting_no_policy_is_not_a_mismatch() {
3794        let mut client = MockSandboxDataPlaneApi::new();
3795        client.expect_get_sandbox().times(1).returning(|_, id| {
3796            let mut sandbox = running(id, None);
3797            sandbox.state = Some("Stopped".to_string());
3798            Ok(sandbox)
3799        });
3800
3801        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3802            .get("asleep")
3803            .await
3804            .expect("a sleeping sandbox must still be readable")
3805            .expect("the sandbox exists");
3806
3807        assert_eq!(sandbox.state, SandboxState::Paused);
3808    }
3809
3810    /// A sleeping sandbox whose own record is plainly wrong is refused before anything wakes it.
3811    ///
3812    /// Waking it to reach the same verdict puts its workload back on the network for the length of
3813    /// a boot, which is the window this check exists to close.
3814    #[tokio::test]
3815    async fn a_sleeping_sandbox_with_a_wrong_policy_is_never_woken() {
3816        let mut client = MockSandboxDataPlaneApi::new();
3817        client.expect_get_sandbox().times(1).returning(|_, id| {
3818            let mut sandbox = running(
3819                id,
3820                Some(EgressPolicy {
3821                    default_action: "Allow".to_string(),
3822                    host_rules: Vec::new(),
3823                    rules: Vec::new(),
3824                    unmodelled: Default::default(),
3825                    traffic_inspection: Some("Full".to_string()),
3826                }),
3827            );
3828            sandbox.state = Some("Stopped".to_string());
3829            Ok(sandbox)
3830        });
3831        client.expect_resume_sandbox().never();
3832        client.expect_stop_sandbox().never();
3833        client.expect_delete_sandbox().never();
3834
3835        let error = sandbox_denying(client, SandboxEgress::Deny)
3836            .resume("built-under-allow")
3837            .await
3838            .expect_err("a stored policy that already fails must not be woken");
3839
3840        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3841    }
3842
3843    /// A wait that woke a sandbox and then failed still puts it back.
3844    ///
3845    /// The wait can fail after issuing the resume, and a sandbox left awake by a call that
3846    /// returned an error is exactly the one nothing else will come back for.
3847    #[tokio::test]
3848    async fn a_sandbox_woken_by_a_wait_that_then_failed_is_put_back() {
3849        let mut client = MockSandboxDataPlaneApi::new();
3850        let mut reads = 0;
3851        client.expect_get_sandbox().returning(move |_, id| {
3852            reads += 1;
3853            let mut sandbox = running(id, None);
3854            // Asleep for the resume's read and the wait's first poll, then unreadable.
3855            sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string());
3856            Ok(sandbox)
3857        });
3858        client
3859            .expect_resume_sandbox()
3860            .times(1)
3861            .returning(|_, _| Ok(()));
3862        client
3863            .expect_stop_sandbox()
3864            .times(1)
3865            .returning(|_, _| Ok(()));
3866
3867        let error = sandbox_denying(client, SandboxEgress::Deny)
3868            .resume("wakes-then-breaks")
3869            .await
3870            .expect_err("a wait that cannot finish must not report a resumed sandbox");
3871
3872        assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
3873    }
3874
3875    /// A reconnect that woke a sandbox and then could not use it puts back what it woke.
3876    ///
3877    /// The refusal travels either way; what must not survive it is a live sandbox this call put
3878    /// on the network and then walked away from. Returned to sleep rather than deleted, because
3879    /// the id may be another revision's.
3880    #[tokio::test]
3881    async fn a_sandbox_woken_by_a_failed_reconnect_is_put_back() {
3882        let mut client = MockSandboxDataPlaneApi::new();
3883        let mut reads = 0;
3884        client.expect_get_sandbox().returning(move |_, id| {
3885            reads += 1;
3886            let mut sandbox = running(id, None);
3887            sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string());
3888            Ok(sandbox)
3889        });
3890        client
3891            .expect_resume_sandbox()
3892            .times(1)
3893            .returning(|_, _| Ok(()));
3894        client
3895            .expect_stop_sandbox()
3896            .withf(|_, id| id == "woken-then-unreadable")
3897            .times(1)
3898            .returning(|_, _| Ok(()));
3899        client.expect_delete_sandbox().never();
3900
3901        let error = sandbox_with(client)
3902            .get_or_create(CreateSandboxRequest {
3903                sandbox_id: Some("woken-then-unreadable".to_string()),
3904                tenant_key: None,
3905                env: BTreeMap::new(),
3906                ..Default::default()
3907            })
3908            .await
3909            .expect_err("a state this client cannot read is not a sandbox");
3910
3911        assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
3912    }
3913
3914    /// The program leads its arguments inside the wrapper, each as one shell argument.
3915    ///
3916    /// The data plane takes one shell string, so the split is rejoined here; a rejoin that
3917    /// dropped, reordered or duplicated an element would run something other than what was asked
3918    /// for. Two arguments rather than one: with a single argument an inverted or duplicated
3919    /// rejoin builds the same string as the correct one.
3920    #[tokio::test]
3921    async fn the_program_leads_its_arguments_inside_the_wrapper() {
3922        let mut client = MockSandboxDataPlaneApi::new();
3923        client
3924            .expect_get_sandbox()
3925            .returning(|_, id| Ok(running(id, None)));
3926        client
3927            .expect_execute_shell_command()
3928            .times(1)
3929            .withf(|_, _, shell, cwd| {
3930                shell.ends_with("' sh 'python' '-u' 'main.py'") && cwd.as_deref() == Some("/work")
3931            })
3932            .returning(|_, _, _, _| {
3933                Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult {
3934                    exit_code: Some(0),
3935                    stdout: String::new(),
3936                    // The wrapper announces its nonce before starting the command.
3937                    stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(),
3938                })
3939            });
3940
3941        let mut request = command(5);
3942        request.command = "python".to_string();
3943        request.args = vec!["-u".to_string(), "main.py".to_string()];
3944        request.cwd = Some("/work".to_string());
3945
3946        let frames: Vec<Result<CommandOutput>> = sandbox_with(client)
3947            .run_command("s1", request)
3948            .await
3949            .expect("the command runs")
3950            .collect()
3951            .await;
3952
3953        assert!(
3954            matches!(frames.last(), Some(Ok(CommandOutput::Exit { code, .. })) if *code == 0),
3955            "the command has to reach its exit: {frames:?}"
3956        );
3957    }
3958
3959    /// The variables a command declares reach the command.
3960    ///
3961    /// Every other backend honours `RunCommandRequest.env`; dropping it here would answer a
3962    /// documented field with nothing, and the failure would surface inside the sandbox.
3963    #[tokio::test]
3964    async fn a_declared_variable_reaches_the_command() {
3965        let mut client = MockSandboxDataPlaneApi::new();
3966        client
3967            .expect_get_sandbox()
3968            .returning(|_, id| Ok(running(id, None)));
3969        client
3970            .expect_execute_shell_command()
3971            .times(1)
3972            .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' 'sleep' 'forever'"))
3973            .returning(|_, _, _, _| {
3974                Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult {
3975                    exit_code: Some(0),
3976                    stdout: String::new(),
3977                    // The wrapper announces its nonce before starting the command.
3978                    stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(),
3979                })
3980            });
3981
3982        let mut request = command(5);
3983        request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]);
3984
3985        let frames: Vec<Result<CommandOutput>> = sandbox_with(client)
3986            .run_command("s1", request)
3987            .await
3988            .expect("a command declaring a variable must run")
3989            .collect()
3990            .await;
3991
3992        assert!(
3993            matches!(frames.last(), Some(Ok(CommandOutput::Exit { code, .. })) if *code == 0),
3994            "the command has to reach its exit: {frames:?}"
3995        );
3996    }
3997
3998    /// A variable name that is not a name never reaches the shell string.
3999    ///
4000    /// The name sits left of the `=`, where quoting cannot reach it, so an unchecked one is a
4001    /// second command running inside the sandbox rather than a variable in it.
4002    #[tokio::test]
4003    async fn a_command_carrying_an_unusable_variable_name_runs_nothing() {
4004        let mut client = MockSandboxDataPlaneApi::new();
4005        client
4006            .expect_get_sandbox()
4007            .returning(|_, id| Ok(running(id, None)));
4008        client.expect_execute_shell_command().never();
4009
4010        let mut request = command(5);
4011        request.env = BTreeMap::from([("X; curl evil".to_string(), "1".to_string())]);
4012
4013        let error = match sandbox_with(client).run_command("s1", request).await {
4014            Ok(_) => panic!("a name the shell would run must not reach the shell"),
4015            Err(error) => error,
4016        };
4017
4018        assert_eq!(error.code, "INVALID_INPUT", "{error}");
4019    }
4020
4021    /// A resume whose outcome is unknown is one this call owns.
4022    ///
4023    /// A 5xx or a dropped connection does not mean the POST failed to land: the sandbox can wake
4024    /// anyway. Treating that as "did not wake" leaves a sandbox this call put back on the network
4025    /// under a policy the declaration forbids, with nothing coming back for it.
4026    #[tokio::test]
4027    async fn a_resume_that_may_have_landed_is_owned() {
4028        let mut client = MockSandboxDataPlaneApi::new();
4029        let mut reads = 0;
4030        client.expect_get_sandbox().returning(move |_, id| {
4031            reads += 1;
4032            let mut sandbox = running(id, None);
4033            if reads <= 2 {
4034                sandbox.state = Some("Stopped".to_string());
4035            }
4036            Ok(sandbox)
4037        });
4038        // The answer never arrived; the data plane may still have taken it.
4039        client
4040            .expect_resume_sandbox()
4041            .returning(|_, _| Err(http_error(503, "GatewayTimeout")));
4042        client
4043            .expect_stop_sandbox()
4044            .times(1)
4045            .returning(|_, _| Ok(()));
4046
4047        let error = sandbox_denying(client, SandboxEgress::Deny)
4048            .resume("woke-or-did-not")
4049            .await
4050            .expect_err("a sandbox that came up uncontained is not a resumed sandbox");
4051
4052        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4053    }
4054
4055    /// A resume the data plane refused is not one this call woke.
4056    ///
4057    /// The other side of the same rule: a 4xx is an answer, so the sandbox stayed asleep and
4058    /// whatever woke it afterwards was someone else. Stopping it would end their work.
4059    #[tokio::test]
4060    async fn a_refused_resume_leaves_someone_elses_sandbox_alone() {
4061        let mut client = MockSandboxDataPlaneApi::new();
4062        let mut reads = 0;
4063        client.expect_get_sandbox().returning(move |_, id| {
4064            reads += 1;
4065            let mut sandbox = running(id, None);
4066            if reads <= 2 {
4067                sandbox.state = Some("Stopped".to_string());
4068            }
4069            Ok(sandbox)
4070        });
4071        // Refused, so this call did not wake it — another revision did, between the polls.
4072        client
4073            .expect_resume_sandbox()
4074            .returning(|_, _| Err(http_error(409, "SandboxNotStopped")));
4075        client.expect_stop_sandbox().never();
4076        client.expect_delete_sandbox().never();
4077
4078        let error = sandbox_denying(client, SandboxEgress::Deny)
4079            .resume("someone-elses-sandbox")
4080            .await
4081            .expect_err("a sandbox without the declared policy must not be handed back");
4082
4083        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4084    }
4085
4086    /// A sandbox that vanished while it was being put back is not "left awake".
4087    ///
4088    /// The put-back exists to name a sandbox this call left running. One the data plane says is
4089    /// gone has reached that state by another route, and reporting it sends an operator looking
4090    /// for something that does not exist.
4091    #[tokio::test]
4092    async fn a_sandbox_that_vanished_is_not_reported_as_left_awake() {
4093        let mut client = MockSandboxDataPlaneApi::new();
4094        let mut reads = 0;
4095        client.expect_get_sandbox().returning(move |_, id| {
4096            reads += 1;
4097            let mut sandbox = running(id, None);
4098            if reads <= 2 {
4099                sandbox.state = Some("Stopped".to_string());
4100            }
4101            Ok(sandbox)
4102        });
4103        client.expect_resume_sandbox().returning(|_, _| Ok(()));
4104        client
4105            .expect_stop_sandbox()
4106            .times(1)
4107            .returning(|_, _| Err(http_error(404, "SandboxNotFound")));
4108
4109        let error = sandbox_denying(client, SandboxEgress::Deny)
4110            .resume("gone-by-then")
4111            .await
4112            .expect_err("the refusal still travels");
4113
4114        assert!(
4115            !error.to_string().contains("sandboxLeftAwake"),
4116            "a sandbox the data plane says is gone was not left awake: {error}"
4117        );
4118        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4119    }
4120
4121    /// A sandbox being deleted is still running, so it must not take new work.
4122    ///
4123    /// `get` skips the policy check for one — a sandbox on its way out carries no policy to
4124    /// judge — so a gate that only asks "does it exist" would run untrusted code on a live
4125    /// sandbox under whatever egress it was built with. Azure accepts a delete rather than
4126    /// completing it, which is why `terminate` polls to a 404 instead of trusting the accept.
4127    #[tokio::test]
4128    async fn a_sandbox_being_deleted_takes_no_new_work() {
4129        for outcome in ["Deleting", "gone"] {
4130            let mut client = MockSandboxDataPlaneApi::new();
4131            let deleting = outcome == "Deleting";
4132            client.expect_get_sandbox().returning(move |_, id| {
4133                if deleting {
4134                    let mut sandbox = running(id, None);
4135                    sandbox.state = Some("Deleting".to_string());
4136                    Ok(sandbox)
4137                } else {
4138                    Err(http_error(404, "SandboxNotFound"))
4139                }
4140            });
4141            client.expect_execute_shell_command().never();
4142            client.expect_resume_sandbox().never();
4143            let sandbox = sandbox_denying(client, SandboxEgress::Deny);
4144
4145            let ran = match sandbox.run_command("on-its-way-out", command(5)).await {
4146                Ok(_) => panic!("{outcome}: a sandbox that cannot take work must not run code"),
4147                Err(error) => error,
4148            };
4149            assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {ran}");
4150
4151            let woken = sandbox
4152                .resume("on-its-way-out")
4153                .await
4154                .expect_err("a sandbox that cannot take work must not be resumed");
4155            assert_eq!(woken.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {woken}");
4156        }
4157    }
4158
4159    /// A create whose id this client will not send is reaped unless the id is why.
4160    ///
4161    /// An over-long or oddly-spelled id is still one path segment, so the sandbox can be deleted
4162    /// once and must be — nothing else can find it. An id carrying a separator or an escape is
4163    /// the one case where the delete itself would travel somewhere else.
4164    #[tokio::test]
4165    async fn an_unaddressable_minted_id_is_reaped_unless_the_id_is_the_hazard() {
4166        let minted = |id: &'static str| {
4167            let mut client = MockSandboxDataPlaneApi::new();
4168            client
4169                .expect_create_sandbox()
4170                .times(1)
4171                .returning(move |_, _| Ok(running(id, None)));
4172            client
4173        };
4174
4175        // Safe to address once: reaped.
4176        let mut client = minted("x".repeat(80).leak());
4177        client
4178            .expect_delete_sandbox()
4179            .times(1)
4180            .returning(|_, _| Ok(()));
4181        let error = sandbox_with(client)
4182            .create(CreateSandboxRequest::default())
4183            .await
4184            .expect_err("an id this client will not send must fail the create");
4185        assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
4186
4187        // The id is the hazard: the delete would travel into another group, so it is not sent.
4188        let mut client = minted("../../other-group/sandboxes/theirs");
4189        client.expect_delete_sandbox().never();
4190        let error = sandbox_with(client)
4191            .create(CreateSandboxRequest::default())
4192            .await
4193            .expect_err("a traversing id must fail the create");
4194        assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
4195    }
4196
4197    /// A sandbox that is still coming up has no policy yet, and that is not a mismatch.
4198    ///
4199    /// `policy_holds` reads an absent policy as a failure, so judging a `Creating` sandbox would
4200    /// report a booting sandbox as an uncontained one — and `get_or_create` acts on that by
4201    /// deleting it and creating another.
4202    #[tokio::test]
4203    async fn a_sandbox_that_is_still_coming_up_is_not_a_policy_mismatch() {
4204        let mut client = MockSandboxDataPlaneApi::new();
4205        client.expect_get_sandbox().times(1).returning(|_, id| {
4206            let mut sandbox = running(id, None);
4207            sandbox.state = Some("Creating".to_string());
4208            Ok(sandbox)
4209        });
4210        client.expect_delete_sandbox().never();
4211
4212        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4213            .get("still-booting")
4214            .await
4215            .expect("a booting sandbox is not a contained-ness failure")
4216            .expect("the sandbox exists");
4217
4218        assert_eq!(sandbox.state, SandboxState::Starting);
4219    }
4220
4221    /// Writing into a stale sandbox is refused before the bytes land.
4222    ///
4223    /// `write_files` is the one file operation that moves the caller's own content in, so a
4224    /// write-then-run against an id kept across a tightened declaration would put the payload
4225    /// inside a sandbox with the egress the declaration just removed.
4226    #[tokio::test]
4227    async fn a_stale_policy_sandbox_takes_no_written_files() {
4228        let mut client = MockSandboxDataPlaneApi::new();
4229        client
4230            .expect_get_sandbox()
4231            .times(1)
4232            .returning(|_, id| Ok(running(id, None)));
4233        client.expect_delete_sandbox().never();
4234        client.expect_write_file().never();
4235
4236        let error = sandbox_denying(client, SandboxEgress::Deny)
4237            .write_files(
4238                "built-under-allow",
4239                BTreeMap::from([("app.py".to_string(), vec![1u8])]),
4240            )
4241            .await
4242            .expect_err("a sandbox without the declared policy must take no content");
4243
4244        assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4245    }
4246
4247    /// A resume the data plane refuses once is retried, not abandoned for the whole wait.
4248    ///
4249    /// The first attempt is the one most likely to be refused — a resume racing a sandbox that is
4250    /// still stopping answers 409 — so remembering only that an attempt was made would spend the
4251    /// budget watching a sandbox nothing is bringing up.
4252    #[tokio::test]
4253    async fn a_refused_resume_is_tried_again() {
4254        let mut client = MockSandboxDataPlaneApi::new();
4255        let mut reads = 0;
4256        client.expect_get_sandbox().returning(move |_, id| {
4257            reads += 1;
4258            let mut sandbox = running(id, None);
4259            // Stopping, then stopped, then up — the shape a suspend-then-resume race produces.
4260            sandbox.state = Some(
4261                match reads {
4262                    1 => "Stopping",
4263                    2 | 3 => "Stopped",
4264                    _ => "Running",
4265                }
4266                .to_string(),
4267            );
4268            Ok(sandbox)
4269        });
4270
4271        let mut attempts = 0;
4272        client
4273            .expect_resume_sandbox()
4274            .times(2)
4275            .returning(move |_, _| {
4276                attempts += 1;
4277                if attempts == 1 {
4278                    // The 409 a sandbox still stopping answers.
4279                    Err(http_error(409, "SandboxNotStopped"))
4280                } else {
4281                    Ok(())
4282                }
4283            });
4284
4285        sandbox_with(client)
4286            .resume("racing-the-idle-policy")
4287            .await
4288            .expect("a refused first resume must not doom the wait");
4289    }
4290
4291    /// A sandbox that is not running takes no work and no content, and is not woken to take it.
4292    ///
4293    /// Waking one to write into it would undo the idle suspend the declaration asked for, and a
4294    /// stopped sandbox's policy record is not the one the work would run under.
4295    #[tokio::test]
4296    async fn a_suspended_sandbox_is_refused_rather_than_woken() {
4297        let mut client = MockSandboxDataPlaneApi::new();
4298        client.expect_get_sandbox().returning(|_, id| {
4299            let mut sandbox = running(id, None);
4300            sandbox.state = Some("Stopped".to_string());
4301            Ok(sandbox)
4302        });
4303        client.expect_resume_sandbox().never();
4304        client.expect_write_file().never();
4305        client.expect_execute_shell_command().never();
4306        let sandbox = sandbox_denying(client, SandboxEgress::Deny);
4307
4308        let wrote = sandbox
4309            .write_files(
4310                "asleep",
4311                BTreeMap::from([("app.py".to_string(), vec![1u8])]),
4312            )
4313            .await
4314            .expect_err("a suspended sandbox takes no content");
4315        assert_eq!(wrote.code, "SANDBOX_COMMAND_FAILED", "{wrote}");
4316
4317        let ran = match sandbox.run_command("asleep", command(5)).await {
4318            Ok(_) => panic!("a suspended sandbox runs no code"),
4319            Err(error) => error,
4320        };
4321        assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{ran}");
4322    }
4323
4324    /// A stopped sandbox that no longer matches is refused before anything wakes it.
4325    ///
4326    /// The stopped record carries the policy it stopped under, so it is judgeable — and waking a
4327    /// sandbox to find out would put its workload back on the network for the length of a boot
4328    /// before this call could refuse it.
4329    #[tokio::test]
4330    async fn a_stopped_sandbox_is_judged_before_it_is_woken() {
4331        let mut client = MockSandboxDataPlaneApi::new();
4332        let declared = EgressPolicy {
4333            default_action: "Deny".to_string(),
4334            host_rules: vec![EgressHostRule {
4335                pattern: "*".to_string(),
4336                action: "Deny".to_string(),
4337            }],
4338            rules: Vec::new(),
4339            unmodelled: Default::default(),
4340            traffic_inspection: Some("Full".to_string()),
4341        };
4342        client.expect_get_sandbox().returning(move |_, id| {
4343            if id == "fresh" {
4344                return Ok(running(id, Some(declared.clone())));
4345            }
4346            // Asleep, and the record it stopped under is present and open.
4347            let mut sandbox = running(
4348                id,
4349                Some(EgressPolicy {
4350                    default_action: "Allow".to_string(),
4351                    host_rules: Vec::new(),
4352                    rules: Vec::new(),
4353                    unmodelled: Default::default(),
4354                    traffic_inspection: Some("Full".to_string()),
4355                }),
4356            );
4357            sandbox.state = Some("Stopped".to_string());
4358            Ok(sandbox)
4359        });
4360        client.expect_resume_sandbox().never();
4361        // Nothing woke it and nothing owns it here, so it is left exactly as found.
4362        client.expect_delete_sandbox().never();
4363        client.expect_stop_sandbox().never();
4364        client
4365            .expect_create_sandbox()
4366            .times(1)
4367            .returning(|_, request| Ok(running("fresh", request.egress)));
4368
4369        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4370            .get_or_create(CreateSandboxRequest {
4371                sandbox_id: Some("asleep-under-allow".to_string()),
4372                tenant_key: None,
4373                env: BTreeMap::new(),
4374                ..Default::default()
4375            })
4376            .await
4377            .expect("a caller asking for a sandbox gets a usable one");
4378
4379        assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
4380        assert!(sandbox.created, "a replacement is a sandbox this call made");
4381    }
4382
4383    /// A sandbox the data plane reports as `Failed` is replaced, not carried forever.
4384    ///
4385    /// It is a documented terminal state, and one this client did not know: an unmapped state
4386    /// becomes an unexpected-response error, which nothing heals, so the id would be permanently
4387    /// unusable through `get_or_create`.
4388    #[tokio::test]
4389    async fn a_failed_sandbox_is_replaced() {
4390        let mut client = MockSandboxDataPlaneApi::new();
4391        client.expect_get_sandbox().returning(|_, id| {
4392            if id == "fresh" {
4393                return Ok(running(id, None));
4394            }
4395            let mut sandbox = running(id, None);
4396            sandbox.state = Some("Failed".to_string());
4397            Ok(sandbox)
4398        });
4399        // A failed sandbox is not going away on its own, so it is reaped rather than left beside
4400        // its replacement.
4401        client
4402            .expect_delete_sandbox()
4403            .withf(|_, id| id == "broken")
4404            .times(1)
4405            .returning(|_, _| Ok(()));
4406        client
4407            .expect_create_sandbox()
4408            .times(1)
4409            .returning(|_, _| Ok(running("fresh", None)));
4410
4411        let sandbox = sandbox_with(client)
4412            .get_or_create(CreateSandboxRequest {
4413                sandbox_id: Some("broken".to_string()),
4414                tenant_key: None,
4415                env: BTreeMap::new(),
4416                ..Default::default()
4417            })
4418            .await
4419            .expect("a failed sandbox is replaced rather than returned");
4420
4421        assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
4422        assert!(sandbox.created, "a replacement is a sandbox this call made");
4423    }
4424
4425    /// `Failed` is a state the data plane reports and this client has to know.
4426    ///
4427    /// An unmapped state becomes an unexpected-response error, and nothing heals that — so the id
4428    /// of a failed sandbox would be permanently unusable rather than replaced.
4429    #[tokio::test]
4430    async fn a_failed_sandbox_reads_as_terminated() {
4431        let mut client = MockSandboxDataPlaneApi::new();
4432        client.expect_get_sandbox().times(1).returning(|_, id| {
4433            let mut sandbox = running(id, None);
4434            sandbox.state = Some("Failed".to_string());
4435            Ok(sandbox)
4436        });
4437
4438        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4439            .get("broken")
4440            .await
4441            .expect("a failed sandbox is a state, not an unreadable response")
4442            .expect("the sandbox exists");
4443
4444        assert_eq!(sandbox.state, SandboxState::Terminated);
4445    }
4446
4447    /// A sandbox that dies while it is being waited for is replaced, like one already dead.
4448    ///
4449    /// The same condition one read earlier heals as `sandboxGone`; answering it differently
4450    /// depending on which read observed it is the inconsistency this path exists to avoid.
4451    #[tokio::test]
4452    async fn a_sandbox_that_dies_during_the_wait_is_replaced() {
4453        let mut client = MockSandboxDataPlaneApi::new();
4454        let mut reads = 0;
4455        client.expect_get_sandbox().returning(move |_, id| {
4456            if id == "fresh" {
4457                return Ok(running(id, None));
4458            }
4459            reads += 1;
4460            let mut sandbox = running(id, None);
4461            // Asleep when it is found, being deleted by the time the wait looks.
4462            sandbox.state = Some(if reads == 1 { "Stopped" } else { "Deleting" }.to_string());
4463            Ok(sandbox)
4464        });
4465        client
4466            .expect_create_sandbox()
4467            .times(1)
4468            .returning(|_, _| Ok(running("fresh", None)));
4469
4470        let sandbox = sandbox_with(client)
4471            .get_or_create(CreateSandboxRequest {
4472                sandbox_id: Some("dying".to_string()),
4473                tenant_key: None,
4474                env: BTreeMap::new(),
4475                ..Default::default()
4476            })
4477            .await
4478            .expect("a sandbox that died mid-wait is replaced");
4479
4480        assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
4481        assert!(sandbox.created, "a replacement is a sandbox this call made");
4482    }
4483
4484    /// A sleeping sandbox that still matches is reconnected, not replaced.
4485    ///
4486    /// The discriminating case for judging a stopped record: if the data plane does report the
4487    /// policy for a suspended sandbox, a compliant one has to survive the reconnect — otherwise
4488    /// every idle-suspended sandbox would be silently churned on each attach.
4489    #[tokio::test]
4490    async fn a_sleeping_sandbox_that_still_matches_is_kept() {
4491        let declared = EgressPolicy {
4492            default_action: "Deny".to_string(),
4493            host_rules: vec![EgressHostRule {
4494                pattern: "*".to_string(),
4495                action: "Deny".to_string(),
4496            }],
4497            rules: Vec::new(),
4498            unmodelled: Default::default(),
4499            traffic_inspection: Some("Full".to_string()),
4500        };
4501
4502        let mut client = MockSandboxDataPlaneApi::new();
4503        let mut reads = 0;
4504        let carried = declared.clone();
4505        client.expect_get_sandbox().returning(move |_, id| {
4506            reads += 1;
4507            let mut sandbox = running(id, Some(carried.clone()));
4508            // Asleep for the first two reads — the reconnect's own, and the wait's first poll —
4509            // so the resume is actually issued.
4510            if reads <= 2 {
4511                sandbox.state = Some("Stopped".to_string());
4512            }
4513            Ok(sandbox)
4514        });
4515        client
4516            .expect_resume_sandbox()
4517            .times(1)
4518            .returning(|_, _| Ok(()));
4519        client.expect_delete_sandbox().never();
4520        client.expect_create_sandbox().never();
4521
4522        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4523            .get_or_create(CreateSandboxRequest {
4524                sandbox_id: Some("asleep-and-fine".to_string()),
4525                tenant_key: None,
4526                env: BTreeMap::new(),
4527                ..Default::default()
4528            })
4529            .await
4530            .expect("a compliant sleeping sandbox is woken and returned");
4531
4532        assert_eq!(sandbox.sandbox.sandbox_id, "asleep-and-fine");
4533        assert!(
4534            !sandbox.created,
4535            "waking a sleeping sandbox is not creating one"
4536        );
4537    }
4538
4539    /// A sleeping sandbox with no policy on its record is woken before it is judged.
4540    ///
4541    /// Whether the data plane reports `egressPolicy` for a sandbox that is not running is
4542    /// unverified. If it does not, judging the sleeping record would delete every compliant
4543    /// idle-suspended sandbox on every reconnect, so the absence is left for the post-wake read.
4544    #[tokio::test]
4545    async fn a_sleeping_sandbox_with_no_policy_is_woken_before_it_is_judged() {
4546        let declared = EgressPolicy {
4547            default_action: "Deny".to_string(),
4548            host_rules: vec![EgressHostRule {
4549                pattern: "*".to_string(),
4550                action: "Deny".to_string(),
4551            }],
4552            rules: Vec::new(),
4553            unmodelled: Default::default(),
4554            traffic_inspection: Some("Full".to_string()),
4555        };
4556
4557        let mut client = MockSandboxDataPlaneApi::new();
4558        let mut reads = 0;
4559        let carried = declared.clone();
4560        client.expect_get_sandbox().returning(move |_, id| {
4561            reads += 1;
4562            if reads <= 2 {
4563                let mut asleep = running(id, None);
4564                asleep.state = Some("Stopped".to_string());
4565                return Ok(asleep);
4566            }
4567            Ok(running(id, Some(carried.clone())))
4568        });
4569        client
4570            .expect_resume_sandbox()
4571            .times(1)
4572            .returning(|_, _| Ok(()));
4573        client.expect_delete_sandbox().never();
4574        client.expect_create_sandbox().never();
4575
4576        let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4577            .get_or_create(CreateSandboxRequest {
4578                sandbox_id: Some("asleep-without-a-record".to_string()),
4579                tenant_key: None,
4580                env: BTreeMap::new(),
4581                ..Default::default()
4582            })
4583            .await
4584            .expect("an absent policy on a sleeping record is unknown, not a mismatch");
4585
4586        assert_eq!(sandbox.sandbox.sandbox_id, "asleep-without-a-record");
4587        assert!(
4588            !sandbox.created,
4589            "waking a sleeping sandbox is not creating one"
4590        );
4591    }
4592
4593    /// A sandbox woken to be judged, found uncontained, and left awake says so.
4594    ///
4595    /// The refusal alone would read as "nothing happened", when what happened is a sandbox this
4596    /// call put back on the network under a policy the declaration does not allow.
4597    #[tokio::test]
4598    async fn a_sandbox_that_cannot_be_put_back_is_reported_as_left_awake() {
4599        let mut client = MockSandboxDataPlaneApi::new();
4600        let mut reads = 0;
4601        client.expect_get_sandbox().returning(move |_, id| {
4602            reads += 1;
4603            let mut sandbox = running(id, None);
4604            // Asleep for the resume's own read and the wait's first poll, so the wait is what
4605            // wakes it — and therefore what owes the put-back.
4606            if reads <= 2 {
4607                sandbox.state = Some("Stopped".to_string());
4608            }
4609            Ok(sandbox)
4610        });
4611        client.expect_resume_sandbox().returning(|_, _| Ok(()));
4612        client
4613            .expect_stop_sandbox()
4614            .times(1)
4615            .returning(|_, _| Err(http_error(500, "SuspendFailed")));
4616
4617        let error = sandbox_denying(client, SandboxEgress::Deny)
4618            .resume("built-under-allow")
4619            .await
4620            .expect_err("a sandbox that woke up uncontained must not be reported as resumed");
4621
4622        assert!(
4623            error.to_string().contains("sandboxLeftAwake"),
4624            "a sandbox left awake has to be named, not folded into the refusal: {error}"
4625        );
4626    }
4627
4628    /// A state this client cannot read takes no work, and is not called suspended.
4629    ///
4630    /// Reporting it as suspended sends the caller to `resume`, which answers the same thing —
4631    /// a loop that ends in a timeout instead of the unreadable state that caused it.
4632    #[tokio::test]
4633    async fn an_unreadable_state_takes_no_work_and_is_not_called_suspended() {
4634        let mut client = MockSandboxDataPlaneApi::new();
4635        client.expect_get_sandbox().times(1).returning(|_, _| {
4636            Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
4637                id: "s1".to_string(),
4638                egress_policy: None,
4639                state: Some("Hibernated".to_string()),
4640            })
4641        });
4642        client.expect_execute_shell_command().never();
4643        client.expect_resume_sandbox().never();
4644
4645        let error = match sandbox_with(client).run_command("s1", command(5)).await {
4646            Ok(_) => panic!("an unreadable state must not take work"),
4647            Err(error) => error,
4648        };
4649
4650        assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
4651    }
4652
4653    /// Pins `capabilities()`'s doc: `domainEgressRules` must describe the backend even for a
4654    /// sandbox declared `allow`, the case a narrowing would get wrong.
4655    #[test]
4656    fn capabilities_describe_the_backend_not_this_declaration() {
4657        let platform =
4658            SandboxCapabilities::for_platform(Platform::Azure).expect("Azure has a backend");
4659
4660        assert_eq!(
4661            sandbox_with(MockSandboxDataPlaneApi::new()).capabilities(),
4662            platform
4663        );
4664
4665        let listed = AzureSandbox::new(
4666            std::sync::Arc::new(MockSandboxDataPlaneApi::new()),
4667            "grp".to_string(),
4668            "ubuntu".to_string(),
4669            SandboxEgress::AllowDomains {
4670                domains: vec!["api.example.com".to_string()],
4671            },
4672            None,
4673            "1000m".to_string(),
4674            "2048Mi".to_string(),
4675            None,
4676        );
4677        assert_eq!(
4678            listed.capabilities(),
4679            platform,
4680            "the declaration is not the row"
4681        );
4682        assert!(
4683            platform.domain_egress_rules,
4684            "Azure does host-pattern egress"
4685        );
4686    }
4687
4688    /// Pins the refusal, not the message: a caller branches on `error.code`, and
4689    /// `create_sandbox` must never be called — the failure this guards is a sandbox that starts
4690    /// anyway and serves every tenant from one box.
4691    #[tokio::test]
4692    async fn a_tenant_key_is_refused_rather_than_dropped() {
4693        let mut client = MockSandboxDataPlaneApi::new();
4694        client.expect_create_sandbox().never();
4695
4696        let error = sandbox_with(client)
4697            .create(CreateSandboxRequest {
4698                tenant_key: Some("tenant-1".to_string()),
4699                ..Default::default()
4700            })
4701            .await
4702            .expect_err("a tenant key Azure cannot honour is refused");
4703
4704        assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}");
4705        assert!(
4706            error.to_string().contains("tenantKey"),
4707            "the refusal has to name the field a caller must remove: {error}"
4708        );
4709    }
4710}