1use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use tokio::io::BufReader;
12use tokio::process::Command;
13
14use crate::error::SandboxError;
15use tokio::io::{AsyncRead, AsyncWrite};
16
17use crate::ipc::{
18 read_message, write_message, ChildMessage, IpcDispatchError, ParentMessage, WorkerConfig,
19};
20use crate::{ResourceDispatcher, StashDispatcher, ToolDispatcher};
21
22const MAX_STDERR_CAPTURE_BYTES: usize = 4096;
24
25pub(crate) async fn capture_bounded_stderr<R: tokio::io::AsyncRead + Unpin>(mut stderr: R) {
30 use tokio::io::AsyncReadExt;
31 let mut buf = vec![0u8; MAX_STDERR_CAPTURE_BYTES];
32 let mut total = 0;
33 loop {
34 match stderr.read(&mut buf[total..]).await {
35 Ok(0) => break,
36 Ok(n) => {
37 total += n;
38 if total >= MAX_STDERR_CAPTURE_BYTES {
39 break;
40 }
41 }
42 Err(_) => break,
43 }
44 }
45 if total > 0 {
46 let text = String::from_utf8_lossy(&buf[..total]);
47 tracing::debug!(target: "forge::sandbox::worker::stderr", "{}", text);
48 }
49 let mut discard = [0u8; 1024];
51 loop {
52 match stderr.read(&mut discard).await {
53 Ok(0) | Err(_) => break,
54 Ok(_) => continue,
55 }
56 }
57}
58
59pub struct SandboxHost;
61
62impl SandboxHost {
63 #[tracing::instrument(skip(code, config, dispatcher, resource_dispatcher, stash_dispatcher, known_servers, known_tools), fields(code_len = code.len()))]
71 pub async fn execute_in_child(
72 code: &str,
73 config: &crate::SandboxConfig,
74 dispatcher: Arc<dyn ToolDispatcher>,
75 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
76 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
77 known_servers: Option<std::collections::HashSet<String>>,
78 known_tools: Option<Vec<(String, String)>>,
79 ) -> Result<serde_json::Value, SandboxError> {
80 let worker_bin = find_worker_binary()?;
81 let mut worker_config = WorkerConfig::from(config);
82 worker_config.known_tools = known_tools;
83 worker_config.known_servers = known_servers;
84 let timeout = config.timeout;
85
86 let debug_mode = std::env::var("FORGE_DEBUG").is_ok();
89 let mut child = Command::new(&worker_bin)
90 .stdin(std::process::Stdio::piped())
91 .stdout(std::process::Stdio::piped())
92 .stderr(if debug_mode {
93 std::process::Stdio::piped()
94 } else {
95 std::process::Stdio::null()
96 })
97 .env_clear()
98 .kill_on_drop(true)
99 .spawn()
100 .map_err(|e| {
101 SandboxError::Execution(anyhow::anyhow!(
102 "failed to spawn worker at {}: {}",
103 worker_bin.display(),
104 e
105 ))
106 })?;
107
108 let _stderr_handle = if debug_mode {
110 child
111 .stderr
112 .take()
113 .map(|stderr| tokio::spawn(capture_bounded_stderr(stderr)))
114 } else {
115 None
116 };
117
118 let mut child_stdin = child
119 .stdin
120 .take()
121 .ok_or_else(|| SandboxError::Execution(anyhow::anyhow!("no stdin on child")))?;
122 let child_stdout = child
123 .stdout
124 .take()
125 .ok_or_else(|| SandboxError::Execution(anyhow::anyhow!("no stdout on child")))?;
126 let mut child_stdout = BufReader::new(child_stdout);
127
128 let execute_msg = ParentMessage::Execute {
130 code: code.to_string(),
131 manifest: None,
132 config: worker_config,
133 };
134 write_message(&mut child_stdin, &execute_msg)
135 .await
136 .map_err(|e| {
137 SandboxError::Execution(anyhow::anyhow!("failed to send Execute: {}", e))
138 })?;
139
140 let result = tokio::time::timeout(
142 timeout + Duration::from_secs(2),
145 ipc_event_loop(
146 &mut child_stdin,
147 &mut child_stdout,
148 dispatcher,
149 resource_dispatcher,
150 stash_dispatcher,
151 ),
152 )
153 .await;
154
155 match result {
156 Ok(inner) => inner,
157 Err(_elapsed) => {
158 let _ = child.kill().await;
160 Err(SandboxError::Timeout {
161 timeout_ms: timeout.as_millis() as u64,
162 })
163 }
164 }
165 }
166}
167
168#[tracing::instrument(skip_all)]
174pub(crate) async fn ipc_event_loop<W, R>(
175 child_stdin: &mut W,
176 child_stdout: &mut R,
177 dispatcher: Arc<dyn ToolDispatcher>,
178 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
179 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
180) -> Result<serde_json::Value, SandboxError>
181where
182 W: AsyncWrite + Unpin,
183 R: AsyncRead + Unpin,
184{
185 loop {
186 let msg: Option<ChildMessage> = read_message(child_stdout)
187 .await
188 .map_err(|e| SandboxError::Execution(anyhow::anyhow!("IPC read error: {}", e)))?;
189
190 match msg {
191 Some(ChildMessage::ExecutionComplete {
192 result,
193 error_kind,
194 timeout_ms: structured_timeout_ms,
195 }) => {
196 return match result {
197 Ok(value) => Ok(value),
198 Err(err) => {
199 match error_kind {
203 Some(crate::ipc::ErrorKind::Timeout) => {
204 let timeout_ms = structured_timeout_ms.unwrap_or_else(|| {
207 err.split("after ")
208 .nth(1)
209 .and_then(|s| s.trim_end_matches("ms").parse::<u64>().ok())
210 .unwrap_or(0)
211 });
212 Err(SandboxError::Timeout { timeout_ms })
213 }
214 Some(crate::ipc::ErrorKind::HeapLimit) => {
215 Err(SandboxError::HeapLimitExceeded)
216 }
217 Some(crate::ipc::ErrorKind::Execution) => {
218 Err(SandboxError::Execution(anyhow::anyhow!("{}", err)))
219 }
220 Some(crate::ipc::ErrorKind::JsError) | None => {
221 let message = err
225 .strip_prefix("javascript error: ")
226 .unwrap_or(&err)
227 .to_string();
228 Err(SandboxError::JsError { message })
229 }
230 }
231 }
232 };
233 }
234 Some(ChildMessage::ToolCallRequest {
235 request_id,
236 server,
237 tool,
238 args,
239 }) => {
240 let tool_result = dispatcher.call_tool(&server, &tool, args).await;
242
243 let response = ParentMessage::ToolCallResult {
244 request_id,
245 result: tool_result.map_err(|e| IpcDispatchError::from(&e)),
246 };
247
248 write_message(child_stdin, &response).await.map_err(|e| {
249 SandboxError::Execution(anyhow::anyhow!("failed to send tool result: {}", e))
250 })?;
251 }
252 Some(ChildMessage::ResourceReadRequest {
253 request_id,
254 server,
255 uri,
256 }) => {
257 let result = if let Err(e) = crate::ops::validate_resource_uri(&uri) {
259 Err(IpcDispatchError::from_string(e))
260 } else {
261 match &resource_dispatcher {
262 Some(rd) => rd
263 .read_resource(&server, &uri)
264 .await
265 .map_err(|e| IpcDispatchError::from(&e)),
266 None => Err(IpcDispatchError::from_string(
267 "resource dispatcher not available".to_string(),
268 )),
269 }
270 };
271
272 let response = ParentMessage::ResourceReadResult { request_id, result };
273
274 write_message(child_stdin, &response).await.map_err(|e| {
275 SandboxError::Execution(anyhow::anyhow!(
276 "failed to send resource result: {}",
277 e
278 ))
279 })?;
280 }
281 Some(ChildMessage::StashPut {
282 request_id,
283 key,
284 value,
285 ttl_secs,
286 group,
287 }) => {
288 let result = match &stash_dispatcher {
289 Some(sd) => sd
290 .put(&key, value, ttl_secs, group)
291 .await
292 .map_err(|e| IpcDispatchError::from(&e)),
293 None => Err(IpcDispatchError::from_string(
294 "stash dispatcher not available".to_string(),
295 )),
296 };
297
298 let response = ParentMessage::StashResult { request_id, result };
299 write_message(child_stdin, &response).await.map_err(|e| {
300 SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
301 })?;
302 }
303 Some(ChildMessage::StashGet {
304 request_id,
305 key,
306 group,
307 }) => {
308 let result = match &stash_dispatcher {
309 Some(sd) => sd
310 .get(&key, group)
311 .await
312 .map_err(|e| IpcDispatchError::from(&e)),
313 None => Err(IpcDispatchError::from_string(
314 "stash dispatcher not available".to_string(),
315 )),
316 };
317
318 let response = ParentMessage::StashResult { request_id, result };
319 write_message(child_stdin, &response).await.map_err(|e| {
320 SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
321 })?;
322 }
323 Some(ChildMessage::StashDelete {
324 request_id,
325 key,
326 group,
327 }) => {
328 let result = match &stash_dispatcher {
329 Some(sd) => sd
330 .delete(&key, group)
331 .await
332 .map_err(|e| IpcDispatchError::from(&e)),
333 None => Err(IpcDispatchError::from_string(
334 "stash dispatcher not available".to_string(),
335 )),
336 };
337
338 let response = ParentMessage::StashResult { request_id, result };
339 write_message(child_stdin, &response).await.map_err(|e| {
340 SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
341 })?;
342 }
343 Some(ChildMessage::StashKeys { request_id, group }) => {
344 let result = match &stash_dispatcher {
345 Some(sd) => sd.keys(group).await.map_err(|e| IpcDispatchError::from(&e)),
346 None => Err(IpcDispatchError::from_string(
347 "stash dispatcher not available".to_string(),
348 )),
349 };
350
351 let response = ParentMessage::StashResult { request_id, result };
352 write_message(child_stdin, &response).await.map_err(|e| {
353 SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
354 })?;
355 }
356 Some(ChildMessage::Log { message }) => {
357 tracing::info!(target: "forge::sandbox::worker", "{}", message);
358 }
359 Some(ChildMessage::ResetComplete) => {
360 tracing::warn!("received unexpected ResetComplete in single-execution mode");
362 }
363 None => {
364 return Err(SandboxError::Execution(anyhow::anyhow!(
366 "worker exited without sending result"
367 )));
368 }
369 }
370 }
371}
372
373#[tracing::instrument]
381pub fn find_worker_binary() -> Result<PathBuf, SandboxError> {
382 if let Ok(path) = std::env::var("FORGE_WORKER_BIN") {
384 let p = PathBuf::from(&path);
385 if !p.is_absolute() {
386 return Err(SandboxError::Execution(anyhow::anyhow!(
387 "FORGE_WORKER_BIN must be an absolute path, got: {}",
388 path
389 )));
390 }
391 if p.exists() {
392 validate_binary_permissions(&p)?;
393 return Ok(p);
394 }
395 }
396
397 if let Ok(exe) = std::env::current_exe() {
399 if let Some(dir) = exe.parent() {
400 let worker = dir.join("forgemax-worker");
401 if worker.exists() {
402 validate_binary_permissions(&worker)?;
403 return Ok(worker);
404 }
405 if let Some(parent) = dir.parent() {
407 let worker = parent.join("forgemax-worker");
408 if worker.exists() {
409 validate_binary_permissions(&worker)?;
410 return Ok(worker);
411 }
412 }
413 }
414 }
415
416 Err(SandboxError::Execution(anyhow::anyhow!(
417 "forgemax-worker binary not found. Set FORGE_WORKER_BIN or install alongside forgemax"
418 )))
419}
420
421fn validate_binary_permissions(_path: &std::path::Path) -> Result<(), SandboxError> {
428 #[cfg(unix)]
429 {
430 use std::os::unix::fs::PermissionsExt;
431
432 let metadata = std::fs::metadata(_path).map_err(|e| {
434 SandboxError::Execution(anyhow::anyhow!(
435 "cannot read metadata for {}: {}",
436 _path.display(),
437 e
438 ))
439 })?;
440 let mode = metadata.permissions().mode();
441 if mode & 0o002 != 0 {
442 return Err(SandboxError::Execution(anyhow::anyhow!(
443 "insecure permissions on worker binary {}: mode {:o} is world-writable",
444 _path.display(),
445 mode,
446 )));
447 }
448
449 if let Some(parent) = _path.parent() {
451 let dir_metadata = std::fs::metadata(parent).map_err(|e| {
452 SandboxError::Execution(anyhow::anyhow!(
453 "cannot read metadata for parent directory {}: {}",
454 parent.display(),
455 e
456 ))
457 })?;
458 let dir_mode = dir_metadata.permissions().mode();
459 if dir_mode & 0o002 != 0 && dir_mode & 0o1000 == 0 {
461 return Err(SandboxError::Execution(anyhow::anyhow!(
462 "insecure directory for worker binary {}: parent {} mode {:o} is world-writable without sticky bit",
463 _path.display(),
464 parent.display(),
465 dir_mode,
466 )));
467 }
468 }
469 }
470 Ok(())
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn find_worker_binary_from_exe_dir() {
479 let _ = find_worker_binary();
484 }
485
486 #[test]
487 fn find_worker_binary_rejects_relative_env_var() {
488 temp_env::with_var("FORGE_WORKER_BIN", Some("./relative/path"), || {
490 let result = find_worker_binary();
491 let err = result.unwrap_err().to_string();
492 assert!(
493 err.contains("absolute"),
494 "expected 'absolute' in error: {err}"
495 );
496 });
497 }
498
499 #[test]
500 fn find_worker_binary_no_which_fallback() {
501 temp_env::with_var_unset("FORGE_WORKER_BIN", || {
504 let result = find_worker_binary();
505 if let Err(e) = result {
506 let msg = e.to_string();
507 assert!(
508 !msg.contains("PATH"),
509 "error should not mention PATH: {msg}"
510 );
511 assert!(
512 msg.contains("FORGE_WORKER_BIN") || msg.contains("forgemax"),
513 "error should guide user: {msg}"
514 );
515 }
516 });
518 }
519
520 #[cfg(unix)]
521 #[test]
522 fn find_worker_binary_rejects_world_writable() {
523 use std::os::unix::fs::PermissionsExt;
524
525 let dir = tempfile::tempdir().unwrap();
526 let bin = dir.path().join("forgemax-worker");
527 std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
528 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o777)).unwrap();
529
530 temp_env::with_var("FORGE_WORKER_BIN", Some(bin.to_str().unwrap()), || {
531 let result = find_worker_binary();
532 let err = result.unwrap_err().to_string();
533 assert!(
534 err.contains("insecure"),
535 "expected 'insecure' in error: {err}"
536 );
537 });
538 }
539
540 #[cfg(unix)]
541 #[test]
542 fn find_worker_binary_accepts_secure_binary() {
543 use std::os::unix::fs::PermissionsExt;
544
545 let dir = tempfile::tempdir().unwrap();
546 let bin = dir.path().join("forgemax-worker");
547 std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
548 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
549
550 temp_env::with_var("FORGE_WORKER_BIN", Some(bin.to_str().unwrap()), || {
551 let result = find_worker_binary();
552 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
553 });
554 }
555
556 #[cfg(unix)]
558 #[test]
559 fn bin_sec_01_symlink_to_world_writable_rejected() {
560 use std::os::unix::fs::PermissionsExt;
561
562 let dir = tempfile::tempdir().unwrap();
563 let real_bin = dir.path().join("real-worker");
564 std::fs::write(&real_bin, b"#!/bin/sh\n").unwrap();
565 std::fs::set_permissions(&real_bin, std::fs::Permissions::from_mode(0o777)).unwrap();
566
567 let link = dir.path().join("forgemax-worker");
568 std::os::unix::fs::symlink(&real_bin, &link).unwrap();
569
570 let result = validate_binary_permissions(&link);
572 assert!(
573 result.is_err(),
574 "should reject symlink to world-writable binary"
575 );
576 let msg = result.unwrap_err().to_string();
577 assert!(msg.contains("insecure"), "should say insecure: {msg}");
578 }
579
580 #[cfg(unix)]
582 #[test]
583 fn bin_sec_02_symlink_to_secure_accepted() {
584 use std::os::unix::fs::PermissionsExt;
585
586 let dir = tempfile::tempdir().unwrap();
587 let real_bin = dir.path().join("real-worker");
588 std::fs::write(&real_bin, b"#!/bin/sh\n").unwrap();
589 std::fs::set_permissions(&real_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
590
591 let link = dir.path().join("forgemax-worker");
592 std::os::unix::fs::symlink(&real_bin, &link).unwrap();
593
594 let result = validate_binary_permissions(&link);
595 assert!(
596 result.is_ok(),
597 "should accept symlink to secure binary: {:?}",
598 result
599 );
600 }
601
602 #[cfg(unix)]
604 #[test]
605 fn bin_sec_03_world_writable_dir_without_sticky_rejected() {
606 use std::os::unix::fs::PermissionsExt;
607
608 let dir = tempfile::tempdir().unwrap();
609 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
611
612 let bin = dir.path().join("forgemax-worker");
613 std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
614 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
615
616 let result = validate_binary_permissions(&bin);
617 assert!(
618 result.is_err(),
619 "should reject binary in world-writable dir"
620 );
621 let msg = result.unwrap_err().to_string();
622 assert!(
623 msg.contains("world-writable"),
624 "should say world-writable: {msg}"
625 );
626 }
627
628 #[test]
629 fn worker_stderr_is_conditional_on_debug() {
630 temp_env::with_var_unset("FORGE_DEBUG", || {
633 assert!(std::env::var("FORGE_DEBUG").is_err());
634 });
635
636 temp_env::with_var("FORGE_DEBUG", Some("1"), || {
638 assert!(std::env::var("FORGE_DEBUG").is_ok());
639 });
640 }
641
642 #[test]
645 fn h3_01_host_worker_stderr_never_inherits() {
646 temp_env::with_var_unset("FORGE_DEBUG", || {
649 assert!(
650 std::env::var("FORGE_DEBUG").is_err(),
651 "FORGE_DEBUG should not be set"
652 );
653 });
655 temp_env::with_var("FORGE_DEBUG", Some("1"), || {
656 assert!(
657 std::env::var("FORGE_DEBUG").is_ok(),
658 "FORGE_DEBUG should be set"
659 );
660 });
662 }
663
664 #[test]
665 fn h3_02_pool_worker_stderr_never_inherits() {
666 temp_env::with_var_unset("FORGE_DEBUG", || {
669 let debug = std::env::var("FORGE_DEBUG").is_ok();
670 assert!(!debug, "non-debug should use null");
671 });
672 temp_env::with_var("FORGE_DEBUG", Some("1"), || {
673 let debug = std::env::var("FORGE_DEBUG").is_ok();
674 assert!(debug, "debug should use piped (not inherit)");
675 });
676 }
677
678 #[tokio::test]
679 async fn h3_03_debug_mode_captures_bounded_stderr() {
680 use std::io::Cursor;
682
683 let large_data = vec![b'E'; 8192];
685 let cursor = Cursor::new(large_data);
686
687 capture_bounded_stderr(cursor).await;
689
690 let small_data = b"some warning\n".to_vec();
692 let cursor = Cursor::new(small_data);
693 capture_bounded_stderr(cursor).await;
694 }
695
696 #[tokio::test]
697 async fn h3_04_non_debug_mode_nulls_stderr() {
698 temp_env::with_var_unset("FORGE_DEBUG", || {
700 let debug = std::env::var("FORGE_DEBUG").is_ok();
701 assert!(
702 !debug,
703 "without FORGE_DEBUG, stderr should be null (not inherit)"
704 );
705 });
706 }
707
708 struct GroupRecordingStash {
712 recorded_groups: std::sync::Mutex<Vec<Option<String>>>,
713 }
714
715 #[async_trait::async_trait]
716 impl crate::StashDispatcher for GroupRecordingStash {
717 async fn put(
718 &self,
719 _key: &str,
720 _value: serde_json::Value,
721 _ttl_secs: Option<u32>,
722 current_group: Option<String>,
723 ) -> Result<serde_json::Value, forge_error::DispatchError> {
724 self.recorded_groups.lock().unwrap().push(current_group);
725 Ok(serde_json::json!({"ok": true}))
726 }
727
728 async fn get(
729 &self,
730 _key: &str,
731 current_group: Option<String>,
732 ) -> Result<serde_json::Value, forge_error::DispatchError> {
733 self.recorded_groups.lock().unwrap().push(current_group);
734 Ok(serde_json::json!(null))
735 }
736
737 async fn delete(
738 &self,
739 _key: &str,
740 current_group: Option<String>,
741 ) -> Result<serde_json::Value, forge_error::DispatchError> {
742 self.recorded_groups.lock().unwrap().push(current_group);
743 Ok(serde_json::json!({"deleted": true}))
744 }
745
746 async fn keys(
747 &self,
748 current_group: Option<String>,
749 ) -> Result<serde_json::Value, forge_error::DispatchError> {
750 self.recorded_groups.lock().unwrap().push(current_group);
751 Ok(serde_json::json!([]))
752 }
753 }
754
755 struct NeverCalledTool;
757
758 #[async_trait::async_trait]
759 impl crate::ToolDispatcher for NeverCalledTool {
760 async fn call_tool(
761 &self,
762 _server: &str,
763 _tool: &str,
764 _args: serde_json::Value,
765 ) -> Result<serde_json::Value, forge_error::DispatchError> {
766 panic!("tool call not expected");
767 }
768 }
769
770 async fn run_ipc_event_loop_with_messages(
772 messages: Vec<crate::ipc::ChildMessage>,
773 stash: Arc<GroupRecordingStash>,
774 ) {
775 use crate::ipc::write_message;
776
777 let mut child_output = Vec::new();
779 for msg in &messages {
780 write_message(&mut child_output, msg).await.unwrap();
781 }
782 let complete = crate::ipc::ChildMessage::ExecutionComplete {
784 result: Ok(serde_json::json!("done")),
785 error_kind: None,
786 timeout_ms: None,
787 };
788 write_message(&mut child_output, &complete).await.unwrap();
789
790 let mut child_stdout = std::io::Cursor::new(child_output);
791 let mut child_stdin = Vec::new();
792
793 let tool: Arc<dyn crate::ToolDispatcher> = Arc::new(NeverCalledTool);
794 let resource: Option<Arc<dyn crate::ResourceDispatcher>> = None;
795 let stash_disp: Option<Arc<dyn crate::StashDispatcher>> = Some(stash);
796
797 let result = ipc_event_loop(
798 &mut child_stdin,
799 &mut child_stdout,
800 tool,
801 resource,
802 stash_disp,
803 )
804 .await;
805 assert!(result.is_ok());
806 }
807
808 #[tokio::test]
809 async fn h1_host_07_ipc_event_loop_passes_group_to_stash_put() {
810 let stash = Arc::new(GroupRecordingStash {
811 recorded_groups: std::sync::Mutex::new(Vec::new()),
812 });
813
814 run_ipc_event_loop_with_messages(
815 vec![crate::ipc::ChildMessage::StashPut {
816 request_id: 1,
817 key: "k".into(),
818 value: serde_json::json!("v"),
819 ttl_secs: None,
820 group: Some("mygroup".into()),
821 }],
822 stash.clone(),
823 )
824 .await;
825
826 let groups = stash.recorded_groups.lock().unwrap();
827 assert_eq!(groups.len(), 1);
828 assert_eq!(groups[0], Some("mygroup".into()));
829 }
830
831 #[tokio::test]
832 async fn h1_host_08_ipc_event_loop_passes_group_to_stash_get() {
833 let stash = Arc::new(GroupRecordingStash {
834 recorded_groups: std::sync::Mutex::new(Vec::new()),
835 });
836
837 run_ipc_event_loop_with_messages(
838 vec![crate::ipc::ChildMessage::StashGet {
839 request_id: 1,
840 key: "k".into(),
841 group: Some("getgroup".into()),
842 }],
843 stash.clone(),
844 )
845 .await;
846
847 let groups = stash.recorded_groups.lock().unwrap();
848 assert_eq!(groups.len(), 1);
849 assert_eq!(groups[0], Some("getgroup".into()));
850 }
851
852 #[tokio::test]
853 async fn h1_host_09_ipc_event_loop_passes_group_to_stash_delete() {
854 let stash = Arc::new(GroupRecordingStash {
855 recorded_groups: std::sync::Mutex::new(Vec::new()),
856 });
857
858 run_ipc_event_loop_with_messages(
859 vec![crate::ipc::ChildMessage::StashDelete {
860 request_id: 1,
861 key: "k".into(),
862 group: Some("delgroup".into()),
863 }],
864 stash.clone(),
865 )
866 .await;
867
868 let groups = stash.recorded_groups.lock().unwrap();
869 assert_eq!(groups.len(), 1);
870 assert_eq!(groups[0], Some("delgroup".into()));
871 }
872
873 #[tokio::test]
874 async fn h1_host_10_ipc_event_loop_passes_group_to_stash_keys() {
875 let stash = Arc::new(GroupRecordingStash {
876 recorded_groups: std::sync::Mutex::new(Vec::new()),
877 });
878
879 run_ipc_event_loop_with_messages(
880 vec![crate::ipc::ChildMessage::StashKeys {
881 request_id: 1,
882 group: Some("keysgroup".into()),
883 }],
884 stash.clone(),
885 )
886 .await;
887
888 let groups = stash.recorded_groups.lock().unwrap();
889 assert_eq!(groups.len(), 1);
890 assert_eq!(groups[0], Some("keysgroup".into()));
891 }
892
893 #[tokio::test]
894 async fn h1_host_11_ipc_event_loop_passes_none_group_when_absent() {
895 let stash = Arc::new(GroupRecordingStash {
896 recorded_groups: std::sync::Mutex::new(Vec::new()),
897 });
898
899 run_ipc_event_loop_with_messages(
900 vec![crate::ipc::ChildMessage::StashPut {
901 request_id: 1,
902 key: "k".into(),
903 value: serde_json::json!("v"),
904 ttl_secs: None,
905 group: None,
906 }],
907 stash.clone(),
908 )
909 .await;
910
911 let groups = stash.recorded_groups.lock().unwrap();
912 assert_eq!(groups.len(), 1);
913 assert_eq!(groups[0], None);
914 }
915
916 #[tokio::test]
917 async fn h1_host_12_ipc_event_loop_all_stash_ops_with_same_group() {
918 let stash = Arc::new(GroupRecordingStash {
919 recorded_groups: std::sync::Mutex::new(Vec::new()),
920 });
921
922 run_ipc_event_loop_with_messages(
923 vec![
924 crate::ipc::ChildMessage::StashPut {
925 request_id: 1,
926 key: "k".into(),
927 value: serde_json::json!("v"),
928 ttl_secs: None,
929 group: Some("shared".into()),
930 },
931 crate::ipc::ChildMessage::StashGet {
932 request_id: 2,
933 key: "k".into(),
934 group: Some("shared".into()),
935 },
936 crate::ipc::ChildMessage::StashDelete {
937 request_id: 3,
938 key: "k".into(),
939 group: Some("shared".into()),
940 },
941 crate::ipc::ChildMessage::StashKeys {
942 request_id: 4,
943 group: Some("shared".into()),
944 },
945 ],
946 stash.clone(),
947 )
948 .await;
949
950 let groups = stash.recorded_groups.lock().unwrap();
951 assert_eq!(groups.len(), 4);
952 for g in groups.iter() {
953 assert_eq!(g, &Some("shared".into()));
954 }
955 }
956}