Skip to main content

roma_core/
os.rs

1//! OS abstraction layer: the `Os` trait, `LocalOs` passthrough, `MockOs`
2//! test double (spec: docs/superpowers/specs/2026-08-13-os-abstraction-design.md).
3//!
4//! Honesty note: `LocalOs` provides NO isolation — it is a direct passthrough.
5//! The trait is the mount point for future backends and the test seam today.
6
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9
10use futures::Stream;
11
12use crate::floor_char_boundary;
13use crate::path::PathError;
14
15#[derive(Debug, thiserror::Error)]
16pub enum OsError {
17    #[error("{0}")]
18    Path(#[from] PathError),
19    #[error("io: {0}")]
20    Io(#[from] std::io::Error),
21}
22
23/// How `Os::write` treats existing content.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum WriteMode {
26    Truncate,
27    Append,
28    Prepend,
29}
30
31/// A process spawn request.
32#[derive(Debug, Clone)]
33pub struct ExecSpec {
34    pub program: String,
35    pub args: Vec<String>,
36    pub cwd: PathBuf,
37    pub env: Vec<(String, String)>,
38}
39
40/// Exit information preserving `ExitStatus::code()` semantics
41/// (`None` for signal-killed processes).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct ExitInfo {
44    pub code: Option<i32>,
45}
46
47impl ExitInfo {
48    #[must_use]
49    pub fn success(&self) -> bool {
50        self.code == Some(0)
51    }
52}
53
54/// Chunks of process output as a stream.
55pub type ByteStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, OsError>> + Send + Sync>>;
56
57/// A spawned process handle.
58#[async_trait::async_trait]
59pub trait OsProcess: Send + Sync {
60    /// Take the stdout stream (single-take; `None` afterwards).
61    fn take_stdout(&mut self) -> Option<ByteStream>;
62    /// Take the stderr stream (single-take; `None` afterwards).
63    fn take_stderr(&mut self) -> Option<ByteStream>;
64    async fn wait(&mut self) -> Result<ExitInfo, OsError>;
65    async fn kill(&mut self) -> Result<(), OsError>;
66}
67
68/// The OS abstraction: filesystem + process, behind one trait.
69///
70/// The loop shares one `Arc<dyn Os>` across every `ToolContext` it
71/// builds. Implementations may receive concurrent calls and must
72/// synchronize their own interior state (Send + Sync).
73#[async_trait::async_trait]
74pub trait Os: Send + Sync {
75    fn name(&self) -> &str;
76    fn cwd(&self) -> &Path;
77    /// Resolve a user-supplied path under the implementation's working
78    /// directory policy. LocalOs: `safe_resolve` (cwd-confined, lexical).
79    ///
80    /// Contract note: `resolve()` returns a cwd-canonical ABSOLUTE path for
81    /// display, locking, and policy-check purposes. The fs methods
82    /// (read/read_text/write/mkdir) expect cwd-RELATIVE user paths and re-apply
83    /// the confinement policy internally at point of use — do NOT feed
84    /// resolve()'s absolute output back into the fs methods (it will be
85    /// rejected as absolute). This re-resolve-at-use is deliberate: policy is
86    /// enforced at the moment of the syscall (TOCTOU posture), not at an
87    /// earlier check.
88    fn resolve(&self, path: &Path) -> Result<PathBuf, OsError>;
89
90    async fn read(&self, path: &Path, limit: Option<usize>) -> Result<Vec<u8>, OsError>;
91    async fn read_text(&self, path: &Path, limit: Option<usize>) -> Result<String, OsError>;
92    async fn write(&self, path: &Path, data: &[u8], mode: WriteMode) -> Result<(), OsError>;
93    async fn mkdir(&self, path: &Path, parents: bool) -> Result<(), OsError>;
94
95    async fn exec(&self, spec: ExecSpec) -> Result<Box<dyn OsProcess>, OsError>;
96}
97
98/// Direct passthrough to the real OS (no isolation).
99pub struct LocalOs {
100    cwd: PathBuf,
101}
102
103impl LocalOs {
104    #[must_use]
105    pub fn new(cwd: PathBuf) -> Self {
106        Self { cwd }
107    }
108
109    fn full(&self, path: &Path) -> Result<PathBuf, OsError> {
110        self.resolve(path)
111    }
112}
113
114#[async_trait::async_trait]
115impl Os for LocalOs {
116    fn name(&self) -> &str {
117        "local"
118    }
119
120    fn cwd(&self) -> &Path {
121        &self.cwd
122    }
123
124    fn resolve(&self, path: &Path) -> Result<PathBuf, OsError> {
125        Ok(crate::path::safe_resolve(&self.cwd, path)?)
126    }
127
128    async fn read(&self, path: &Path, limit: Option<usize>) -> Result<Vec<u8>, OsError> {
129        let mut data = tokio::fs::read(self.full(path)?).await?;
130        if let Some(cap) = limit
131            && data.len() > cap
132        {
133            data.truncate(cap);
134        }
135        Ok(data)
136    }
137
138    async fn read_text(&self, path: &Path, limit: Option<usize>) -> Result<String, OsError> {
139        // Same semantics as tokio::fs::read_to_string: invalid UTF-8 is an
140        // error, not a lossy conversion. A limit caps at a char boundary.
141        let bytes = tokio::fs::read(self.full(path)?).await?;
142        let mut text = String::from_utf8(bytes)
143            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
144        if let Some(cap) = limit
145            && text.len() > cap
146        {
147            text.truncate(floor_char_boundary(&text, cap));
148        }
149        Ok(text)
150    }
151
152    async fn write(&self, path: &Path, data: &[u8], mode: WriteMode) -> Result<(), OsError> {
153        let path = self.full(path)?;
154        match mode {
155            WriteMode::Truncate => tokio::fs::write(&path, data).await?,
156            WriteMode::Append => {
157                use tokio::io::AsyncWriteExt;
158                // create(true): appending to a missing file creates it,
159                // matching file_write's NotFound-as-empty behavior.
160                let mut f = tokio::fs::OpenOptions::new()
161                    .append(true)
162                    .create(true)
163                    .open(&path)
164                    .await?;
165                f.write_all(data).await?;
166                f.shutdown().await?;
167            }
168            WriteMode::Prepend => {
169                // Only NotFound means "no existing content"; other errors
170                // (EACCES, EISDIR, ...) must propagate instead of silently
171                // dropping the existing file's data.
172                let existing = match tokio::fs::read(&path).await {
173                    Ok(b) => b,
174                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
175                    Err(e) => return Err(e.into()),
176                };
177                let mut combined = data.to_vec();
178                combined.extend_from_slice(&existing);
179                tokio::fs::write(&path, combined).await?;
180            }
181        }
182        Ok(())
183    }
184
185    async fn mkdir(&self, path: &Path, parents: bool) -> Result<(), OsError> {
186        let path = self.full(path)?;
187        if parents {
188            tokio::fs::create_dir_all(&path).await?;
189        } else {
190            tokio::fs::create_dir(&path).await?;
191        }
192        Ok(())
193    }
194
195    async fn exec(&self, spec: ExecSpec) -> Result<Box<dyn OsProcess>, OsError> {
196        let mut cmd = tokio::process::Command::new(&spec.program);
197        cmd.args(&spec.args)
198            .current_dir(&spec.cwd)
199            .envs(spec.env.iter().map(|(k, v)| (k, v)))
200            .stdin(std::process::Stdio::null())
201            .stdout(std::process::Stdio::piped())
202            .stderr(std::process::Stdio::piped())
203            .kill_on_drop(true);
204        let mut child = cmd.spawn()?;
205        let stdout = child.stdout.take().map(child_stream);
206        let stderr = child.stderr.take().map(child_stream);
207        Ok(Box::new(LocalProcess {
208            child,
209            stdout,
210            stderr,
211        }))
212    }
213}
214
215/// Wrap an `AsyncRead` pipe as a `ByteStream` (~8KB chunks).
216fn child_stream<R>(mut reader: R) -> ByteStream
217where
218    R: tokio::io::AsyncRead + Send + Sync + Unpin + 'static,
219{
220    use std::task::Poll;
221    Box::pin(futures::stream::poll_fn(move |cx| {
222        let mut buf = vec![0u8; 8192];
223        let mut rb = tokio::io::ReadBuf::new(&mut buf);
224        match Pin::new(&mut reader).poll_read(cx, &mut rb) {
225            Poll::Ready(Ok(())) => {
226                let n = rb.filled().len();
227                if n == 0 {
228                    Poll::Ready(None)
229                } else {
230                    Poll::Ready(Some(Ok(rb.filled().to_vec())))
231                }
232            }
233            Poll::Ready(Err(e)) => Poll::Ready(Some(Err(OsError::Io(e)))),
234            Poll::Pending => Poll::Pending,
235        }
236    }))
237}
238
239/// `OsProcess` over a `tokio::process::Child` (kill_on_drop set at spawn).
240struct LocalProcess {
241    child: tokio::process::Child,
242    stdout: Option<ByteStream>,
243    stderr: Option<ByteStream>,
244}
245
246#[async_trait::async_trait]
247impl OsProcess for LocalProcess {
248    fn take_stdout(&mut self) -> Option<ByteStream> {
249        self.stdout.take()
250    }
251
252    fn take_stderr(&mut self) -> Option<ByteStream> {
253        self.stderr.take()
254    }
255
256    async fn wait(&mut self) -> Result<ExitInfo, OsError> {
257        let status = self.child.wait().await?;
258        Ok(ExitInfo {
259            code: status.code(),
260        })
261    }
262
263    async fn kill(&mut self) -> Result<(), OsError> {
264        self.child.start_kill()?;
265        Ok(())
266    }
267}
268
269/// In-memory fs test double (spec D4). `exec` is `unimplemented!`.
270pub mod mock {
271    use std::collections::HashMap;
272    use std::sync::{Arc, RwLock};
273
274    use super::*;
275
276    #[derive(Debug, Clone, PartialEq, Eq)]
277    pub enum OsCall {
278        Resolve(PathBuf),
279        Read(PathBuf),
280        ReadText(PathBuf),
281        Write(PathBuf, WriteMode),
282        Mkdir(PathBuf),
283    }
284
285    /// In-memory `Os`: files keyed by RESOLVED absolute paths.
286    pub struct MockOs {
287        cwd: PathBuf,
288        files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
289        dirs: Arc<RwLock<Vec<PathBuf>>>,
290        calls: Arc<RwLock<Vec<OsCall>>>,
291    }
292
293    impl MockOs {
294        #[must_use]
295        pub fn new(cwd: PathBuf) -> Self {
296            Self {
297                cwd,
298                files: Arc::new(RwLock::new(HashMap::new())),
299                dirs: Arc::new(RwLock::new(Vec::new())),
300                calls: Arc::new(RwLock::new(Vec::new())),
301            }
302        }
303
304        /// Lexical confinement identical to `safe_resolve`'s component
305        /// checks, WITHOUT touching the filesystem — `safe_resolve`
306        /// canonicalizes the base dir (a real path must exist), which a
307        /// hermetic mock cwd like `/wd` would fail.
308        fn resolve_lexical(&self, path: &Path) -> Result<PathBuf, OsError> {
309            for comp in path.components() {
310                match comp {
311                    std::path::Component::ParentDir => {
312                        return Err(PathError::Traversal(path.display().to_string()).into());
313                    }
314                    std::path::Component::RootDir | std::path::Component::Prefix(_) => {
315                        return Err(PathError::AbsolutePath(path.display().to_string()).into());
316                    }
317                    _ => {}
318                }
319            }
320            Ok(self.cwd.join(path))
321        }
322
323        #[must_use]
324        #[allow(clippy::expect_used)]
325        pub fn with_file(self, rel: &str, content: &[u8]) -> Self {
326            let abs = self
327                .resolve_lexical(Path::new(rel))
328                .expect("with_file path must resolve");
329            self.files
330                .write()
331                .unwrap_or_else(std::sync::PoisonError::into_inner)
332                .insert(abs, content.to_vec());
333            self
334        }
335
336        #[must_use]
337        pub fn calls(&self) -> Vec<OsCall> {
338            self.calls
339                .read()
340                .unwrap_or_else(std::sync::PoisonError::into_inner)
341                .clone()
342        }
343    }
344
345    #[async_trait::async_trait]
346    impl Os for MockOs {
347        fn name(&self) -> &str {
348            "mock"
349        }
350
351        fn cwd(&self) -> &Path {
352            &self.cwd
353        }
354
355        fn resolve(&self, path: &Path) -> Result<PathBuf, OsError> {
356            let resolved = self.resolve_lexical(path)?;
357            self.calls
358                .write()
359                .unwrap_or_else(std::sync::PoisonError::into_inner)
360                .push(OsCall::Resolve(resolved.clone()));
361            Ok(resolved)
362        }
363
364        async fn read(&self, path: &Path, limit: Option<usize>) -> Result<Vec<u8>, OsError> {
365            let p = self.resolve(path)?;
366            self.calls
367                .write()
368                .unwrap_or_else(std::sync::PoisonError::into_inner)
369                .push(OsCall::Read(p.clone()));
370            let files = self
371                .files
372                .read()
373                .unwrap_or_else(std::sync::PoisonError::into_inner);
374            let mut data = files.get(&p).cloned().ok_or_else(|| {
375                std::io::Error::new(std::io::ErrorKind::NotFound, format!("{}", p.display()))
376            })?;
377            if let Some(cap) = limit
378                && data.len() > cap
379            {
380                data.truncate(cap);
381            }
382            Ok(data)
383        }
384
385        async fn read_text(&self, path: &Path, limit: Option<usize>) -> Result<String, OsError> {
386            let p = self.resolve(path)?;
387            self.calls
388                .write()
389                .unwrap_or_else(std::sync::PoisonError::into_inner)
390                .push(OsCall::ReadText(p.clone()));
391            let files = self
392                .files
393                .read()
394                .unwrap_or_else(std::sync::PoisonError::into_inner);
395            let bytes = files.get(&p).cloned().ok_or_else(|| {
396                std::io::Error::new(std::io::ErrorKind::NotFound, format!("{}", p.display()))
397            })?;
398            drop(files);
399            let mut text = String::from_utf8(bytes)
400                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
401            if let Some(cap) = limit
402                && text.len() > cap
403            {
404                text.truncate(floor_char_boundary(&text, cap));
405            }
406            Ok(text)
407        }
408
409        async fn write(&self, path: &Path, data: &[u8], mode: WriteMode) -> Result<(), OsError> {
410            let p = self.resolve(path)?;
411            self.calls
412                .write()
413                .unwrap_or_else(std::sync::PoisonError::into_inner)
414                .push(OsCall::Write(p.clone(), mode));
415            let mut files = self
416                .files
417                .write()
418                .unwrap_or_else(std::sync::PoisonError::into_inner);
419            match mode {
420                WriteMode::Truncate => {
421                    files.insert(p, data.to_vec());
422                }
423                WriteMode::Append => {
424                    files.entry(p).or_default().extend_from_slice(data);
425                }
426                WriteMode::Prepend => {
427                    let mut combined = data.to_vec();
428                    combined.extend_from_slice(&files.get(&p).cloned().unwrap_or_default());
429                    files.insert(p, combined);
430                }
431            }
432            Ok(())
433        }
434
435        async fn mkdir(&self, path: &Path, _parents: bool) -> Result<(), OsError> {
436            let p = self.resolve(path)?;
437            self.calls
438                .write()
439                .unwrap_or_else(std::sync::PoisonError::into_inner)
440                .push(OsCall::Mkdir(p.clone()));
441            self.dirs
442                .write()
443                .unwrap_or_else(std::sync::PoisonError::into_inner)
444                .push(p);
445            Ok(())
446        }
447
448        async fn exec(&self, _spec: ExecSpec) -> Result<Box<dyn OsProcess>, OsError> {
449            unimplemented!("MockOs::exec is fs-only; use real processes for exec tests")
450        }
451    }
452}
453
454#[cfg(test)]
455#[allow(clippy::expect_used, clippy::unwrap_used)]
456mod mock_tests {
457    use super::mock::{self, MockOs};
458    use super::*;
459
460    #[tokio::test]
461    async fn mock_fs_roundtrip_and_recording() {
462        let os = MockOs::new(PathBuf::from("/wd")).with_file("a.txt", b"hello");
463        assert_eq!(os.read(Path::new("a.txt"), None).await.unwrap(), b"hello");
464        os.write(Path::new("b.txt"), b"x", WriteMode::Truncate)
465            .await
466            .unwrap();
467        os.write(Path::new("b.txt"), b"y", WriteMode::Append)
468            .await
469            .unwrap();
470        assert_eq!(os.read(Path::new("b.txt"), None).await.unwrap(), b"xy");
471        let missing = os.read(Path::new("nope"), None).await.unwrap_err();
472        assert!(matches!(&missing, OsError::Io(e) if e.kind() == std::io::ErrorKind::NotFound));
473        let calls = os.calls();
474        assert!(calls.contains(&mock::OsCall::Read(PathBuf::from("/wd/a.txt"))));
475        assert_eq!(
476            calls
477                .iter()
478                .filter(
479                    |c| matches!(c, mock::OsCall::Write(p, _) if p == &PathBuf::from("/wd/b.txt"))
480                )
481                .count(),
482            2
483        );
484    }
485
486    #[tokio::test]
487    async fn mock_resolve_confines_like_local() {
488        let os = MockOs::new(PathBuf::from("/wd"));
489        assert!(os.resolve(Path::new("a")).is_ok());
490        assert!(os.resolve(Path::new("../x")).is_err());
491    }
492
493    #[tokio::test]
494    #[should_panic(expected = "MockOs::exec")]
495    async fn mock_exec_panics_loudly() {
496        let os = MockOs::new(PathBuf::from("/wd"));
497        let _ = os
498            .exec(ExecSpec {
499                program: "sh".into(),
500                args: vec![],
501                cwd: "/wd".into(),
502                env: vec![],
503            })
504            .await;
505    }
506}
507
508#[cfg(test)]
509#[allow(clippy::expect_used, clippy::unwrap_used)]
510mod tests {
511    use super::*;
512    use std::path::Path;
513
514    fn tmp() -> tempfile::TempDir {
515        tempfile::TempDir::new().unwrap()
516    }
517
518    #[test]
519    fn resolve_confines_to_cwd_like_safe_resolve() {
520        let dir = tmp();
521        let os = LocalOs::new(dir.path().to_path_buf());
522        // safe_resolve canonicalizes the base (resolving e.g. macOS
523        // /var -> /private/var), so compare against the canonical path.
524        assert_eq!(
525            os.resolve(Path::new("a/b.txt")).unwrap(),
526            dir.path().canonicalize().unwrap().join("a/b.txt")
527        );
528        let err = os.resolve(Path::new("../escape.txt")).unwrap_err();
529        assert!(matches!(
530            err,
531            OsError::Path(crate::path::PathError::Traversal(_))
532        ));
533        let err = os.resolve(Path::new("/etc/passwd")).unwrap_err();
534        assert!(matches!(
535            err,
536            OsError::Path(crate::path::PathError::AbsolutePath(_))
537        ));
538    }
539
540    #[tokio::test]
541    async fn read_and_read_text_with_limits() {
542        let dir = tmp();
543        std::fs::write(dir.path().join("a.txt"), "hello world").unwrap();
544        let os = LocalOs::new(dir.path().to_path_buf());
545        assert_eq!(
546            os.read(Path::new("a.txt"), None).await.unwrap(),
547            b"hello world"
548        );
549        assert_eq!(
550            os.read(Path::new("a.txt"), Some(5)).await.unwrap(),
551            b"hello"
552        );
553        assert_eq!(
554            os.read_text(Path::new("a.txt"), None).await.unwrap(),
555            "hello world"
556        );
557        assert_eq!(
558            os.read_text(Path::new("a.txt"), Some(5)).await.unwrap(),
559            "hello"
560        );
561        let missing = os.read(Path::new("nope.txt"), None).await.unwrap_err();
562        assert!(matches!(&missing, OsError::Io(e) if e.kind() == std::io::ErrorKind::NotFound));
563    }
564
565    #[tokio::test]
566    async fn read_text_limit_caps_at_char_boundary() {
567        let dir = tmp();
568        std::fs::write(dir.path().join("cjk.txt"), "你好世界").unwrap(); // 3 bytes each
569        let os = LocalOs::new(dir.path().to_path_buf());
570        // A 4-byte limit would split the second char; must cap at 3.
571        assert_eq!(
572            os.read_text(Path::new("cjk.txt"), Some(4)).await.unwrap(),
573            "你"
574        );
575        // Invalid UTF-8 behaves like read_to_string (error, not lossy),
576        // surfaced as io InvalidData (file_read's mapping relies on this kind).
577        std::fs::write(dir.path().join("bad.txt"), [0xFF, 0xFE]).unwrap();
578        let err = os.read_text(Path::new("bad.txt"), None).await.unwrap_err();
579        assert!(
580            matches!(&err, OsError::Io(io) if io.kind() == std::io::ErrorKind::InvalidData),
581            "expected OsError::Io(InvalidData), got {err:?}"
582        );
583    }
584
585    #[tokio::test]
586    async fn write_modes() {
587        let dir = tmp();
588        let os = LocalOs::new(dir.path().to_path_buf());
589        os.write(Path::new("a.txt"), b"middle", WriteMode::Truncate)
590            .await
591            .unwrap();
592        os.write(Path::new("a.txt"), b"-end", WriteMode::Append)
593            .await
594            .unwrap();
595        os.write(Path::new("a.txt"), b"start-", WriteMode::Prepend)
596            .await
597            .unwrap();
598        assert_eq!(
599            std::fs::read(dir.path().join("a.txt")).unwrap(),
600            b"start-middle-end"
601        );
602        // Prepend on a missing file behaves as a plain write.
603        os.write(Path::new("b.txt"), b"new", WriteMode::Prepend)
604            .await
605            .unwrap();
606        assert_eq!(std::fs::read(dir.path().join("b.txt")).unwrap(), b"new");
607        // Append on a missing file creates it with the content.
608        os.write(Path::new("c.txt"), b"created", WriteMode::Append)
609            .await
610            .unwrap();
611        assert_eq!(std::fs::read(dir.path().join("c.txt")).unwrap(), b"created");
612    }
613
614    /// Pin the deliberate parity deviation: pre-migration file_write read
615    /// the existing file as UTF-8 text and concatenated, so Append/Prepend
616    /// onto non-UTF-8 content failed with io InvalidData. The byte-level
617    /// implementation succeeds — an improvement, pinned here so a future
618    /// "fix" back to text-level concat fails loudly.
619    #[tokio::test]
620    async fn write_append_onto_non_utf8_existing_succeeds() {
621        let dir = tmp();
622        std::fs::write(dir.path().join("bin.dat"), [0xFF, 0xFE]).unwrap();
623        let os = LocalOs::new(dir.path().to_path_buf());
624        os.write(Path::new("bin.dat"), b"tail", WriteMode::Append)
625            .await
626            .unwrap();
627        assert_eq!(
628            std::fs::read(dir.path().join("bin.dat")).unwrap(),
629            [0xFF, 0xFE, b't', b'a', b'i', b'l']
630        );
631    }
632
633    #[tokio::test]
634    async fn mkdir_parents() {
635        let dir = tmp();
636        let os = LocalOs::new(dir.path().to_path_buf());
637        os.mkdir(Path::new("x/y/z"), true).await.unwrap();
638        assert!(dir.path().join("x/y/z").is_dir());
639        // parents=false on a nested missing path fails.
640        assert!(os.mkdir(Path::new("p/q"), false).await.is_err());
641    }
642
643    #[tokio::test]
644    async fn exec_streams_stdout_and_waits() {
645        let dir = tmp();
646        let os = LocalOs::new(dir.path().to_path_buf());
647        let mut proc = os
648            .exec(ExecSpec {
649                program: "sh".into(),
650                args: vec!["-c".into(), "echo hello-from-exec".into()],
651                cwd: dir.path().to_path_buf(),
652                env: vec![],
653            })
654            .await
655            .unwrap();
656        let mut out = String::new();
657        let mut stream = proc.take_stdout().expect("stdout stream");
658        use futures::StreamExt;
659        while let Some(chunk) = stream.next().await {
660            out.push_str(&String::from_utf8_lossy(&chunk.unwrap()));
661        }
662        let status = proc.wait().await.unwrap();
663        assert!(status.success());
664        assert_eq!(status.code, Some(0));
665        assert!(out.contains("hello-from-exec"));
666        // Single-take semantics.
667        assert!(proc.take_stdout().is_none());
668    }
669
670    #[tokio::test]
671    async fn exec_kill_stops_sleeper() {
672        let dir = tmp();
673        let os = LocalOs::new(dir.path().to_path_buf());
674        let mut proc = os
675            .exec(ExecSpec {
676                program: "sh".into(),
677                args: vec!["-c".into(), "sleep 30".into()],
678                cwd: dir.path().to_path_buf(),
679                env: vec![],
680            })
681            .await
682            .unwrap();
683        proc.kill().await.unwrap();
684        let status = proc.wait().await.unwrap();
685        assert!(!status.success());
686        assert_eq!(status.code, None, "signal-killed has no exit code");
687    }
688
689    #[tokio::test]
690    async fn exec_captures_stderr_and_env() {
691        let dir = tmp();
692        let os = LocalOs::new(dir.path().to_path_buf());
693        let mut proc = os
694            .exec(ExecSpec {
695                program: "sh".into(),
696                args: vec!["-c".into(), "echo err-$MARKER 1>&2".into()],
697                cwd: dir.path().to_path_buf(),
698                env: vec![("MARKER".into(), "tagged".into())],
699            })
700            .await
701            .unwrap();
702        let mut err = String::new();
703        let mut stream = proc.take_stderr().expect("stderr stream");
704        use futures::StreamExt;
705        while let Some(chunk) = stream.next().await {
706            err.push_str(&String::from_utf8_lossy(&chunk.unwrap()));
707        }
708        let _ = proc.wait().await.unwrap();
709        assert!(err.contains("err-tagged"));
710    }
711}