1use 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#[derive(Debug)]
28pub struct AzureSandbox {
29 client: std::sync::Arc<dyn SandboxDataPlaneApi>,
30 sandbox_group: String,
31 disk_image: String,
33 egress: SandboxEgress,
35 idle_suspend_seconds: Option<u32>,
37 cpu: String,
40 memory: String,
41 disk: Option<String>,
42}
43
44impl AzureSandbox {
45 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 #[cfg(test)]
71 pub(crate) fn disk_image(&self) -> &str {
72 &self.disk_image
73 }
74
75 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 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 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
142fn 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 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 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 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 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 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 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 match self.reconnect(id).await {
264 Ok(session) => return Ok(session),
265 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 self.judged_session(RUN_COMMAND, session_id).await?;
314
315 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 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 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 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 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 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 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 let files = files
430 .into_iter()
431 .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents)))
432 .collect::<Result<Vec<_>>>()?;
433
434 self.judged_session("sandbox.writeFiles", session_id)
438 .await?;
439
440 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 let Err(error) = self
477 .client
478 .stop_sandbox(&self.sandbox_group, session_id)
479 .await
480 else {
481 return Ok(());
482 };
483
484 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 self.judge_if_judgeable(&found)?;
510
511 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 for _ in 0..TERMINATE_POLL_ATTEMPTS {
564 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 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 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 self.judge_if_judgeable(&found)?;
623
624 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 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 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 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 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 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 async fn settle(
747 &self,
748 sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
749 ) -> Result<SandboxSession> {
750 let mut resumed_here = false;
754 let running = self
755 .await_running(CREATE, &sandbox.id, &mut resumed_here)
756 .await?;
757
758 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 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 match sandbox.state.as_deref() {
798 Some("Running") => return Ok(sandbox),
799 Some("Creating" | "Resuming") => {}
800 Some("Stopping") => {}
803 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 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 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 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 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 _ => 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 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 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 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 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 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 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
1018const TERMINATE_POLL_ATTEMPTS: u32 = 15;
1024const TERMINATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
1025
1026fn 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 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
1058fn 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
1087fn 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
1109fn 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 if path.ends_with('/') {
1128 return refused("must not end in '/'");
1129 }
1130 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
1150fn 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 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
1190fn 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 && 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 && 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 && effective.unmodelled.is_empty()
1239}
1240
1241fn 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
1264fn 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 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
1292const DENY: &str = "Deny";
1295const ALLOW: &str = "Allow";
1296const FULL_INSPECTION: &str = "Full";
1297
1298const EVERY_HOST: &str = "*";
1300
1301const MAX_SESSION_ID: usize = 63;
1306
1307const 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
1317const 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
1321fn is_refusal(error: &AlienError<ClientErrorData>) -> bool {
1327 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
1343fn is_not_found(error: &AlienError<ClientErrorData>) -> bool {
1349 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 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 #[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 #[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 #[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 #[test]
1485 fn only_the_status_decides_whether_a_session_is_gone() {
1486 assert!(is_not_found(&http_error(404, "SandboxNotFound")));
1487
1488 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 #[derive(Debug)]
1516 struct ScriptedExec {
1517 deleted: std::sync::Arc<std::sync::atomic::AtomicBool>,
1518 commands: std::sync::Mutex<Vec<String>>,
1519 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 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 const DEADLINE_PLACEHOLDER: &str = "<deadline>";
1658 const SESSION_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
1664
1665 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[tokio::test]
1931 async fn a_session_cannot_set_what_the_deadline_wrapper_reads() {
1932 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 ("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 #[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 #[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 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 #[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 #[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 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 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 #[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 #[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 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 #[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 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 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 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 #[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 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 #[tokio::test]
2759 async fn suspend_owns_a_lost_stop_when_the_session_comes_back_suspended() {
2760 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 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 #[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 #[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 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 #[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 #[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 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 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 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 #[tokio::test]
2976 async fn a_stale_policy_session_is_replaced_rather_than_refused_forever() {
2977 let mut client = MockSandboxDataPlaneApi::new();
2978 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 #[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 #[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 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 #[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 if id != "was-suspended" {
3093 return Ok(running(id, Some(stopped.clone())));
3094 }
3095 reads += 1;
3096 Ok(match reads {
3097 1 | 2 => {
3100 let mut sandbox = running(id, Some(stopped.clone()));
3101 sandbox.state = Some("Stopped".to_string());
3102 sandbox
3103 }
3104 _ => 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 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 assert_eq!(session.session_id, "fresh");
3150 }
3151
3152 #[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 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 #[tokio::test]
3191 async fn a_stale_policy_session_cannot_be_resumed() {
3192 let mut client = MockSandboxDataPlaneApi::new();
3193 let mut reads = 0;
3195 client.expect_get_sandbox().returning(move |_, id| {
3196 reads += 1;
3197 let mut sandbox = running(id, None);
3198 if reads <= 2 {
3201 sandbox.state = Some("Stopped".to_string());
3202 }
3203 Ok(sandbox)
3204 });
3205 client.expect_resume_sandbox().returning(|_, _| Ok(()));
3206 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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}