1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum WriteMode {
26 Truncate,
27 Append,
28 Prepend,
29}
30
31#[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#[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
54pub type ByteStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, OsError>> + Send + Sync>>;
56
57#[async_trait::async_trait]
59pub trait OsProcess: Send + Sync {
60 fn take_stdout(&mut self) -> Option<ByteStream>;
62 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#[async_trait::async_trait]
74pub trait Os: Send + Sync {
75 fn name(&self) -> &str;
76 fn cwd(&self) -> &Path;
77 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
98pub 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 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 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 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
215fn 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
239struct 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
269pub 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 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 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 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(); let os = LocalOs::new(dir.path().to_path_buf());
570 assert_eq!(
572 os.read_text(Path::new("cjk.txt"), Some(4)).await.unwrap(),
573 "你"
574 );
575 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 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 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 #[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 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 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}