use std::path::{Path, PathBuf};
use std::pin::Pin;
use futures::Stream;
use crate::floor_char_boundary;
use crate::path::PathError;
#[derive(Debug, thiserror::Error)]
pub enum OsError {
#[error("{0}")]
Path(#[from] PathError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteMode {
Truncate,
Append,
Prepend,
}
#[derive(Debug, Clone)]
pub struct ExecSpec {
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
pub env: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExitInfo {
pub code: Option<i32>,
}
impl ExitInfo {
#[must_use]
pub fn success(&self) -> bool {
self.code == Some(0)
}
}
pub type ByteStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>, OsError>> + Send + Sync>>;
#[async_trait::async_trait]
pub trait OsProcess: Send + Sync {
fn take_stdout(&mut self) -> Option<ByteStream>;
fn take_stderr(&mut self) -> Option<ByteStream>;
async fn wait(&mut self) -> Result<ExitInfo, OsError>;
async fn kill(&mut self) -> Result<(), OsError>;
}
#[async_trait::async_trait]
pub trait Os: Send + Sync {
fn name(&self) -> &str;
fn cwd(&self) -> &Path;
fn resolve(&self, path: &Path) -> Result<PathBuf, OsError>;
async fn read(&self, path: &Path, limit: Option<usize>) -> Result<Vec<u8>, OsError>;
async fn read_text(&self, path: &Path, limit: Option<usize>) -> Result<String, OsError>;
async fn write(&self, path: &Path, data: &[u8], mode: WriteMode) -> Result<(), OsError>;
async fn mkdir(&self, path: &Path, parents: bool) -> Result<(), OsError>;
async fn exec(&self, spec: ExecSpec) -> Result<Box<dyn OsProcess>, OsError>;
}
pub struct LocalOs {
cwd: PathBuf,
}
impl LocalOs {
#[must_use]
pub fn new(cwd: PathBuf) -> Self {
Self { cwd }
}
fn full(&self, path: &Path) -> Result<PathBuf, OsError> {
self.resolve(path)
}
}
#[async_trait::async_trait]
impl Os for LocalOs {
fn name(&self) -> &str {
"local"
}
fn cwd(&self) -> &Path {
&self.cwd
}
fn resolve(&self, path: &Path) -> Result<PathBuf, OsError> {
Ok(crate::path::safe_resolve(&self.cwd, path)?)
}
async fn read(&self, path: &Path, limit: Option<usize>) -> Result<Vec<u8>, OsError> {
let mut data = tokio::fs::read(self.full(path)?).await?;
if let Some(cap) = limit
&& data.len() > cap
{
data.truncate(cap);
}
Ok(data)
}
async fn read_text(&self, path: &Path, limit: Option<usize>) -> Result<String, OsError> {
let bytes = tokio::fs::read(self.full(path)?).await?;
let mut text = String::from_utf8(bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(cap) = limit
&& text.len() > cap
{
text.truncate(floor_char_boundary(&text, cap));
}
Ok(text)
}
async fn write(&self, path: &Path, data: &[u8], mode: WriteMode) -> Result<(), OsError> {
let path = self.full(path)?;
match mode {
WriteMode::Truncate => tokio::fs::write(&path, data).await?,
WriteMode::Append => {
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)
.await?;
f.write_all(data).await?;
f.shutdown().await?;
}
WriteMode::Prepend => {
let existing = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.into()),
};
let mut combined = data.to_vec();
combined.extend_from_slice(&existing);
tokio::fs::write(&path, combined).await?;
}
}
Ok(())
}
async fn mkdir(&self, path: &Path, parents: bool) -> Result<(), OsError> {
let path = self.full(path)?;
if parents {
tokio::fs::create_dir_all(&path).await?;
} else {
tokio::fs::create_dir(&path).await?;
}
Ok(())
}
async fn exec(&self, spec: ExecSpec) -> Result<Box<dyn OsProcess>, OsError> {
let mut cmd = tokio::process::Command::new(&spec.program);
cmd.args(&spec.args)
.current_dir(&spec.cwd)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().map(child_stream);
let stderr = child.stderr.take().map(child_stream);
Ok(Box::new(LocalProcess {
child,
stdout,
stderr,
}))
}
}
fn child_stream<R>(mut reader: R) -> ByteStream
where
R: tokio::io::AsyncRead + Send + Sync + Unpin + 'static,
{
use std::task::Poll;
Box::pin(futures::stream::poll_fn(move |cx| {
let mut buf = vec![0u8; 8192];
let mut rb = tokio::io::ReadBuf::new(&mut buf);
match Pin::new(&mut reader).poll_read(cx, &mut rb) {
Poll::Ready(Ok(())) => {
let n = rb.filled().len();
if n == 0 {
Poll::Ready(None)
} else {
Poll::Ready(Some(Ok(rb.filled().to_vec())))
}
}
Poll::Ready(Err(e)) => Poll::Ready(Some(Err(OsError::Io(e)))),
Poll::Pending => Poll::Pending,
}
}))
}
struct LocalProcess {
child: tokio::process::Child,
stdout: Option<ByteStream>,
stderr: Option<ByteStream>,
}
#[async_trait::async_trait]
impl OsProcess for LocalProcess {
fn take_stdout(&mut self) -> Option<ByteStream> {
self.stdout.take()
}
fn take_stderr(&mut self) -> Option<ByteStream> {
self.stderr.take()
}
async fn wait(&mut self) -> Result<ExitInfo, OsError> {
let status = self.child.wait().await?;
Ok(ExitInfo {
code: status.code(),
})
}
async fn kill(&mut self) -> Result<(), OsError> {
self.child.start_kill()?;
Ok(())
}
}
pub mod mock {
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OsCall {
Resolve(PathBuf),
Read(PathBuf),
ReadText(PathBuf),
Write(PathBuf, WriteMode),
Mkdir(PathBuf),
}
pub struct MockOs {
cwd: PathBuf,
files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
dirs: Arc<RwLock<Vec<PathBuf>>>,
calls: Arc<RwLock<Vec<OsCall>>>,
}
impl MockOs {
#[must_use]
pub fn new(cwd: PathBuf) -> Self {
Self {
cwd,
files: Arc::new(RwLock::new(HashMap::new())),
dirs: Arc::new(RwLock::new(Vec::new())),
calls: Arc::new(RwLock::new(Vec::new())),
}
}
fn resolve_lexical(&self, path: &Path) -> Result<PathBuf, OsError> {
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
return Err(PathError::Traversal(path.display().to_string()).into());
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(PathError::AbsolutePath(path.display().to_string()).into());
}
_ => {}
}
}
Ok(self.cwd.join(path))
}
#[must_use]
#[allow(clippy::expect_used)]
pub fn with_file(self, rel: &str, content: &[u8]) -> Self {
let abs = self
.resolve_lexical(Path::new(rel))
.expect("with_file path must resolve");
self.files
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(abs, content.to_vec());
self
}
#[must_use]
pub fn calls(&self) -> Vec<OsCall> {
self.calls
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
#[async_trait::async_trait]
impl Os for MockOs {
fn name(&self) -> &str {
"mock"
}
fn cwd(&self) -> &Path {
&self.cwd
}
fn resolve(&self, path: &Path) -> Result<PathBuf, OsError> {
let resolved = self.resolve_lexical(path)?;
self.calls
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(OsCall::Resolve(resolved.clone()));
Ok(resolved)
}
async fn read(&self, path: &Path, limit: Option<usize>) -> Result<Vec<u8>, OsError> {
let p = self.resolve(path)?;
self.calls
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(OsCall::Read(p.clone()));
let files = self
.files
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut data = files.get(&p).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, format!("{}", p.display()))
})?;
if let Some(cap) = limit
&& data.len() > cap
{
data.truncate(cap);
}
Ok(data)
}
async fn read_text(&self, path: &Path, limit: Option<usize>) -> Result<String, OsError> {
let p = self.resolve(path)?;
self.calls
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(OsCall::ReadText(p.clone()));
let files = self
.files
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let bytes = files.get(&p).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, format!("{}", p.display()))
})?;
drop(files);
let mut text = String::from_utf8(bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(cap) = limit
&& text.len() > cap
{
text.truncate(floor_char_boundary(&text, cap));
}
Ok(text)
}
async fn write(&self, path: &Path, data: &[u8], mode: WriteMode) -> Result<(), OsError> {
let p = self.resolve(path)?;
self.calls
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(OsCall::Write(p.clone(), mode));
let mut files = self
.files
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match mode {
WriteMode::Truncate => {
files.insert(p, data.to_vec());
}
WriteMode::Append => {
files.entry(p).or_default().extend_from_slice(data);
}
WriteMode::Prepend => {
let mut combined = data.to_vec();
combined.extend_from_slice(&files.get(&p).cloned().unwrap_or_default());
files.insert(p, combined);
}
}
Ok(())
}
async fn mkdir(&self, path: &Path, _parents: bool) -> Result<(), OsError> {
let p = self.resolve(path)?;
self.calls
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(OsCall::Mkdir(p.clone()));
self.dirs
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(p);
Ok(())
}
async fn exec(&self, _spec: ExecSpec) -> Result<Box<dyn OsProcess>, OsError> {
unimplemented!("MockOs::exec is fs-only; use real processes for exec tests")
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod mock_tests {
use super::mock::{self, MockOs};
use super::*;
#[tokio::test]
async fn mock_fs_roundtrip_and_recording() {
let os = MockOs::new(PathBuf::from("/wd")).with_file("a.txt", b"hello");
assert_eq!(os.read(Path::new("a.txt"), None).await.unwrap(), b"hello");
os.write(Path::new("b.txt"), b"x", WriteMode::Truncate)
.await
.unwrap();
os.write(Path::new("b.txt"), b"y", WriteMode::Append)
.await
.unwrap();
assert_eq!(os.read(Path::new("b.txt"), None).await.unwrap(), b"xy");
let missing = os.read(Path::new("nope"), None).await.unwrap_err();
assert!(matches!(&missing, OsError::Io(e) if e.kind() == std::io::ErrorKind::NotFound));
let calls = os.calls();
assert!(calls.contains(&mock::OsCall::Read(PathBuf::from("/wd/a.txt"))));
assert_eq!(
calls
.iter()
.filter(
|c| matches!(c, mock::OsCall::Write(p, _) if p == &PathBuf::from("/wd/b.txt"))
)
.count(),
2
);
}
#[tokio::test]
async fn mock_resolve_confines_like_local() {
let os = MockOs::new(PathBuf::from("/wd"));
assert!(os.resolve(Path::new("a")).is_ok());
assert!(os.resolve(Path::new("../x")).is_err());
}
#[tokio::test]
#[should_panic(expected = "MockOs::exec")]
async fn mock_exec_panics_loudly() {
let os = MockOs::new(PathBuf::from("/wd"));
let _ = os
.exec(ExecSpec {
program: "sh".into(),
args: vec![],
cwd: "/wd".into(),
env: vec![],
})
.await;
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use std::path::Path;
fn tmp() -> tempfile::TempDir {
tempfile::TempDir::new().unwrap()
}
#[test]
fn resolve_confines_to_cwd_like_safe_resolve() {
let dir = tmp();
let os = LocalOs::new(dir.path().to_path_buf());
assert_eq!(
os.resolve(Path::new("a/b.txt")).unwrap(),
dir.path().canonicalize().unwrap().join("a/b.txt")
);
let err = os.resolve(Path::new("../escape.txt")).unwrap_err();
assert!(matches!(
err,
OsError::Path(crate::path::PathError::Traversal(_))
));
let err = os.resolve(Path::new("/etc/passwd")).unwrap_err();
assert!(matches!(
err,
OsError::Path(crate::path::PathError::AbsolutePath(_))
));
}
#[tokio::test]
async fn read_and_read_text_with_limits() {
let dir = tmp();
std::fs::write(dir.path().join("a.txt"), "hello world").unwrap();
let os = LocalOs::new(dir.path().to_path_buf());
assert_eq!(
os.read(Path::new("a.txt"), None).await.unwrap(),
b"hello world"
);
assert_eq!(
os.read(Path::new("a.txt"), Some(5)).await.unwrap(),
b"hello"
);
assert_eq!(
os.read_text(Path::new("a.txt"), None).await.unwrap(),
"hello world"
);
assert_eq!(
os.read_text(Path::new("a.txt"), Some(5)).await.unwrap(),
"hello"
);
let missing = os.read(Path::new("nope.txt"), None).await.unwrap_err();
assert!(matches!(&missing, OsError::Io(e) if e.kind() == std::io::ErrorKind::NotFound));
}
#[tokio::test]
async fn read_text_limit_caps_at_char_boundary() {
let dir = tmp();
std::fs::write(dir.path().join("cjk.txt"), "你好世界").unwrap(); let os = LocalOs::new(dir.path().to_path_buf());
assert_eq!(
os.read_text(Path::new("cjk.txt"), Some(4)).await.unwrap(),
"你"
);
std::fs::write(dir.path().join("bad.txt"), [0xFF, 0xFE]).unwrap();
let err = os.read_text(Path::new("bad.txt"), None).await.unwrap_err();
assert!(
matches!(&err, OsError::Io(io) if io.kind() == std::io::ErrorKind::InvalidData),
"expected OsError::Io(InvalidData), got {err:?}"
);
}
#[tokio::test]
async fn write_modes() {
let dir = tmp();
let os = LocalOs::new(dir.path().to_path_buf());
os.write(Path::new("a.txt"), b"middle", WriteMode::Truncate)
.await
.unwrap();
os.write(Path::new("a.txt"), b"-end", WriteMode::Append)
.await
.unwrap();
os.write(Path::new("a.txt"), b"start-", WriteMode::Prepend)
.await
.unwrap();
assert_eq!(
std::fs::read(dir.path().join("a.txt")).unwrap(),
b"start-middle-end"
);
os.write(Path::new("b.txt"), b"new", WriteMode::Prepend)
.await
.unwrap();
assert_eq!(std::fs::read(dir.path().join("b.txt")).unwrap(), b"new");
os.write(Path::new("c.txt"), b"created", WriteMode::Append)
.await
.unwrap();
assert_eq!(std::fs::read(dir.path().join("c.txt")).unwrap(), b"created");
}
#[tokio::test]
async fn write_append_onto_non_utf8_existing_succeeds() {
let dir = tmp();
std::fs::write(dir.path().join("bin.dat"), [0xFF, 0xFE]).unwrap();
let os = LocalOs::new(dir.path().to_path_buf());
os.write(Path::new("bin.dat"), b"tail", WriteMode::Append)
.await
.unwrap();
assert_eq!(
std::fs::read(dir.path().join("bin.dat")).unwrap(),
[0xFF, 0xFE, b't', b'a', b'i', b'l']
);
}
#[tokio::test]
async fn mkdir_parents() {
let dir = tmp();
let os = LocalOs::new(dir.path().to_path_buf());
os.mkdir(Path::new("x/y/z"), true).await.unwrap();
assert!(dir.path().join("x/y/z").is_dir());
assert!(os.mkdir(Path::new("p/q"), false).await.is_err());
}
#[tokio::test]
async fn exec_streams_stdout_and_waits() {
let dir = tmp();
let os = LocalOs::new(dir.path().to_path_buf());
let mut proc = os
.exec(ExecSpec {
program: "sh".into(),
args: vec!["-c".into(), "echo hello-from-exec".into()],
cwd: dir.path().to_path_buf(),
env: vec![],
})
.await
.unwrap();
let mut out = String::new();
let mut stream = proc.take_stdout().expect("stdout stream");
use futures::StreamExt;
while let Some(chunk) = stream.next().await {
out.push_str(&String::from_utf8_lossy(&chunk.unwrap()));
}
let status = proc.wait().await.unwrap();
assert!(status.success());
assert_eq!(status.code, Some(0));
assert!(out.contains("hello-from-exec"));
assert!(proc.take_stdout().is_none());
}
#[tokio::test]
async fn exec_kill_stops_sleeper() {
let dir = tmp();
let os = LocalOs::new(dir.path().to_path_buf());
let mut proc = os
.exec(ExecSpec {
program: "sh".into(),
args: vec!["-c".into(), "sleep 30".into()],
cwd: dir.path().to_path_buf(),
env: vec![],
})
.await
.unwrap();
proc.kill().await.unwrap();
let status = proc.wait().await.unwrap();
assert!(!status.success());
assert_eq!(status.code, None, "signal-killed has no exit code");
}
#[tokio::test]
async fn exec_captures_stderr_and_env() {
let dir = tmp();
let os = LocalOs::new(dir.path().to_path_buf());
let mut proc = os
.exec(ExecSpec {
program: "sh".into(),
args: vec!["-c".into(), "echo err-$MARKER 1>&2".into()],
cwd: dir.path().to_path_buf(),
env: vec![("MARKER".into(), "tagged".into())],
})
.await
.unwrap();
let mut err = String::new();
let mut stream = proc.take_stderr().expect("stderr stream");
use futures::StreamExt;
while let Some(chunk) = stream.next().await {
err.push_str(&String::from_utf8_lossy(&chunk.unwrap()));
}
let _ = proc.wait().await.unwrap();
assert!(err.contains("err-tagged"));
}
}