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