use anyhow::{Result, anyhow};
use futures_util::{Stream, StreamExt};
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio::fs;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use uuid::Uuid;
const MAX_SUBPATHS_COUNT: u64 = 1000;
const TEXT_PROBE_BYTES: usize = 64 * 1024;
pub fn expand_home(path: &str) -> String {
if path == "~" || path.starts_with("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
path.replacen('~', &home, 1)
} else {
path.to_string()
}
}
pub fn dir_exists(path: &str) -> bool {
Path::new(&expand_home(path)).is_dir()
}
pub fn sanitize_path(base: &Path, requested: &str) -> Result<PathBuf> {
sanitize_path_inner(base, requested, false)
}
pub fn sanitize_path_allow_escape(base: &Path, requested: &str) -> Result<PathBuf> {
sanitize_path_inner(base, requested, true)
}
fn sanitize_path_inner(base: &Path, requested: &str, allow_escape: bool) -> Result<PathBuf> {
let joined = join_and_validate(base, requested)?;
if !joined.exists() {
return Err(anyhow!("path does not exist: {}", joined.display()));
}
let canonical = joined.canonicalize().map_err(|e| anyhow!("path resolution failed: {}", e))?;
if !allow_escape {
let canonical_base =
base.canonicalize().map_err(|e| anyhow!("base path resolution failed: {}", e))?;
if !canonical.starts_with(&canonical_base) {
if is_inside_git_toplevel(&canonical) {
return Ok(canonical);
}
return Err(anyhow!("access denied: path escapes workspace root"));
}
}
Ok(canonical)
}
pub fn sanitize_path_new(base: &Path, requested: &str) -> Result<PathBuf> {
sanitize_path_new_inner(base, requested, false)
}
pub fn sanitize_path_new_allow_escape(base: &Path, requested: &str) -> Result<PathBuf> {
sanitize_path_new_inner(base, requested, true)
}
fn sanitize_path_new_inner(base: &Path, requested: &str, allow_escape: bool) -> Result<PathBuf> {
let joined = join_and_validate(base, requested)?;
let canonical_base =
base.canonicalize().map_err(|e| anyhow!("base path resolution failed: {}", e))?;
let mut check = joined.as_path();
let mut tail = Vec::new();
loop {
if check.exists() {
let canonical =
check.canonicalize().map_err(|e| anyhow!("path resolution failed: {}", e))?;
if !allow_escape && !canonical.starts_with(&canonical_base) {
if is_inside_git_toplevel(&canonical) {
let mut result = canonical;
for component in tail.into_iter().rev() {
result = result.join(component);
}
return Ok(result);
}
return Err(anyhow!("access denied: path escapes workspace root"));
}
let mut result = canonical;
for component in tail.into_iter().rev() {
result = result.join(component);
}
return Ok(result);
}
match check.file_name() {
Some(name) => {
tail.push(name.to_owned());
check = check.parent().unwrap_or(check);
}
None => {
let mut result = canonical_base;
for component in tail.into_iter().rev() {
result = result.join(component);
}
return Ok(result);
}
}
}
}
fn is_inside_git_toplevel(path: &Path) -> bool {
use std::process::{Command, Stdio};
let Some(path_str) = path.to_str() else {
return false;
};
let output = Command::new("git")
.args(["-C", path_str, "rev-parse", "--show-toplevel"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
let Ok(out) = output else { return false };
if !out.status.success() {
return false;
}
let Ok(s) = String::from_utf8(out.stdout) else { return false };
let trimmed = s.trim();
if trimmed.is_empty() {
return false;
}
let Ok(toplevel) = std::path::Path::new(trimmed).canonicalize() else {
return false;
};
path.starts_with(&toplevel)
}
fn join_and_validate(base: &Path, requested: &str) -> Result<PathBuf> {
if requested.as_bytes().contains(&0) {
return Err(anyhow!("invalid path: contains null byte"));
}
let requested_path = Path::new(requested);
let joined = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
base.join(requested_path)
};
Ok(joined)
}
fn sanitize_read(base: &Path, requested: &str, allow_escape: bool) -> Result<PathBuf> {
if allow_escape {
sanitize_path_allow_escape(base, requested)
} else {
sanitize_path(base, requested)
}
}
fn sanitize_new(base: &Path, requested: &str, allow_escape: bool) -> Result<PathBuf> {
if allow_escape {
sanitize_path_new_allow_escape(base, requested)
} else {
sanitize_path_new(base, requested)
}
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub enum PathType {
#[serde(rename = "Dir")]
Dir,
#[serde(rename = "File")]
File,
#[serde(rename = "SymlinkDir")]
SymlinkDir,
#[serde(rename = "SymlinkFile")]
SymlinkFile,
}
#[derive(Debug, Serialize)]
pub struct FileEntry {
pub path_type: PathType,
pub name: String,
pub mtime: u64,
pub size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub rel_path: Option<String>,
}
impl FileEntry {
pub fn is_dir(&self) -> bool {
self.path_type == PathType::Dir || self.path_type == PathType::SymlinkDir
}
}
fn to_timestamp(time: &SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
}
pub fn display_path(path: &Path) -> String {
display_path_str(&path.to_string_lossy())
}
pub fn display_path_str(path: &str) -> String {
if !cfg!(windows) {
return path.to_string();
}
let s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/UNC/") {
format!("//{rest}")
} else if let Some(rest) = s.strip_prefix("//?/") {
rest.to_string()
} else {
s
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Name,
Mtime,
Size,
}
pub async fn list_dir(
base: &Path,
rel_path: &str,
sort: SortKey,
desc: bool,
) -> Result<Vec<FileEntry>> {
let dir = sanitize_path(base, rel_path)?;
let mut entries = Vec::new();
let mut read_dir = fs::read_dir(&dir).await?;
while let Some(entry) = read_dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
let Ok(meta) = fs::metadata(entry.path()).await else {
continue;
};
let Ok(meta2) = fs::symlink_metadata(entry.path()).await else {
continue;
};
let is_symlink = meta2.is_symlink();
let is_dir = meta.is_dir();
let path_type = match (is_symlink, is_dir) {
(true, true) => PathType::SymlinkDir,
(false, true) => PathType::Dir,
(true, false) => PathType::SymlinkFile,
(false, false) => PathType::File,
};
let mtime = meta
.modified()
.ok()
.or_else(|| meta.created().ok())
.map(|t| to_timestamp(&t))
.unwrap_or(0);
let size = if is_dir {
let mut count: u64 = 0;
if let Ok(mut sub) = fs::read_dir(entry.path()).await {
while let Ok(Some(_sub_entry)) = sub.next_entry().await {
count += 1;
if count >= MAX_SUBPATHS_COUNT {
break;
}
}
}
count
} else {
meta.len()
};
entries.push(FileEntry { path_type, name, mtime, size, rel_path: None });
}
entries.sort_by(|a, b| {
let dir_cmp = b.is_dir().cmp(&a.is_dir());
if dir_cmp != std::cmp::Ordering::Equal {
return dir_cmp;
}
let key_cmp = match sort {
SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortKey::Mtime => a.mtime.cmp(&b.mtime),
SortKey::Size => a.size.cmp(&b.size),
};
if desc { key_cmp.reverse() } else { key_cmp }
});
Ok(entries)
}
fn probe_is_utf8_text(buf: &[u8]) -> bool {
if buf.contains(&0) {
return false;
}
let mut end = buf.len();
while end > 0 && (buf[end - 1] & 0xC0) == 0x80 {
end -= 1;
}
if end > 0 {
let b = buf[end - 1];
if b & 0xE0 == 0xC0 || b & 0xF0 == 0xE0 || b & 0xF8 == 0xF0 {
end -= 1;
}
}
std::str::from_utf8(&buf[..end]).is_ok()
}
pub async fn read_text_file(path: &Path) -> Result<Option<String>> {
let mut file = fs::File::open(path).await?;
let mut probe = vec![0u8; TEXT_PROBE_BYTES];
let n = file.read(&mut probe).await?;
probe.truncate(n);
if !probe_is_utf8_text(&probe) {
return Ok(None);
}
let mut bytes = probe;
file.read_to_end(&mut bytes).await?;
if bytes.contains(&0) {
return Ok(None);
}
match String::from_utf8(bytes) {
Ok(s) => Ok(Some(s)),
Err(_) => Ok(None),
}
}
pub async fn read_file(base: &Path, rel_path: &str) -> Result<Option<String>> {
let path = sanitize_path(base, rel_path)?;
read_text_file(&path).await
}
pub async fn write_file(base: &Path, rel_path: &str, content: &[u8]) -> Result<()> {
write_file_impl(base, rel_path, content, false).await
}
pub async fn write_file_allow_escape(base: &Path, rel_path: &str, content: &[u8]) -> Result<()> {
write_file_impl(base, rel_path, content, true).await
}
async fn write_file_impl(
base: &Path,
rel_path: &str,
content: &[u8],
allow_escape: bool,
) -> Result<()> {
let path = sanitize_new(base, rel_path, allow_escape)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&path, content).await?;
Ok(())
}
#[derive(Debug)]
pub enum StreamWriteError {
TooLarge { limit: u64 },
Path(anyhow::Error),
Read(anyhow::Error),
Io(std::io::Error),
}
impl std::fmt::Display for StreamWriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StreamWriteError::TooLarge { limit } => {
write!(f, "content exceeds max size of {limit} bytes")
}
StreamWriteError::Path(e) => write!(f, "{e:#}"),
StreamWriteError::Read(e) => write!(f, "read stream failed: {e:#}"),
StreamWriteError::Io(e) => write!(f, "write file failed: {e}"),
}
}
}
impl std::error::Error for StreamWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
StreamWriteError::Path(e) | StreamWriteError::Read(e) => Some(e.as_ref()),
StreamWriteError::Io(e) => Some(e),
StreamWriteError::TooLarge { .. } => None,
}
}
}
pub async fn write_file_stream<S, T>(
base: &Path,
rel_path: &str,
allow_escape: bool,
max_bytes: u64,
stream: &mut S,
) -> Result<u64, StreamWriteError>
where
S: Stream<Item = anyhow::Result<T>> + Unpin,
T: AsRef<[u8]>,
{
let target = sanitize_new(base, rel_path, allow_escape).map_err(StreamWriteError::Path)?;
let Some(parent) = target.parent() else {
return Err(StreamWriteError::Path(anyhow!("path has no parent: {}", target.display())));
};
fs::create_dir_all(parent).await.map_err(StreamWriteError::Io)?;
let file_name = target.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
StreamWriteError::Path(anyhow!("invalid file name: {}", target.display()))
})?;
let temp = parent.join(format!(".{file_name}.omniterm-upload-{}.tmp", Uuid::new_v4()));
let written = match stream_to_file(&temp, max_bytes, stream).await {
Ok(n) => n,
Err(e) => {
let _ = fs::remove_file(&temp).await;
return Err(e);
}
};
#[cfg(windows)]
if fs::try_exists(&target).await.unwrap_or(false) {
fs::remove_file(&target).await.map_err(StreamWriteError::Io)?;
}
if let Err(e) = fs::rename(&temp, &target).await {
let _ = fs::remove_file(&temp).await;
return Err(StreamWriteError::Io(e));
}
Ok(written)
}
async fn stream_to_file<S, T>(
path: &Path,
max_bytes: u64,
stream: &mut S,
) -> Result<u64, StreamWriteError>
where
S: Stream<Item = anyhow::Result<T>> + Unpin,
T: AsRef<[u8]>,
{
let mut file = fs::File::create(path).await.map_err(StreamWriteError::Io)?;
let mut total: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(StreamWriteError::Read)?;
let chunk = chunk.as_ref();
if total + chunk.len() as u64 > max_bytes {
return Err(StreamWriteError::TooLarge { limit: max_bytes });
}
file.write_all(chunk).await.map_err(StreamWriteError::Io)?;
total += chunk.len() as u64;
}
file.flush().await.map_err(StreamWriteError::Io)?;
Ok(total)
}
pub async fn create_dir(base: &Path, rel_path: &str) -> Result<()> {
create_dir_impl(base, rel_path, false).await
}
pub async fn create_dir_allow_escape(base: &Path, rel_path: &str) -> Result<()> {
create_dir_impl(base, rel_path, true).await
}
async fn create_dir_impl(base: &Path, rel_path: &str, allow_escape: bool) -> Result<()> {
let path = sanitize_new(base, rel_path, allow_escape)?;
fs::create_dir_all(&path).await?;
Ok(())
}
pub async fn delete_path(base: &Path, rel_path: &str) -> Result<()> {
delete_path_impl(base, rel_path, false).await
}
pub async fn delete_path_allow_escape(base: &Path, rel_path: &str) -> Result<()> {
delete_path_impl(base, rel_path, true).await
}
async fn delete_path_impl(base: &Path, rel_path: &str, allow_escape: bool) -> Result<()> {
let path = sanitize_read(base, rel_path, allow_escape)?;
let metadata = fs::metadata(&path).await?;
if metadata.is_dir() {
fs::remove_dir_all(&path).await?;
} else {
fs::remove_file(&path).await?;
}
Ok(())
}
pub async fn move_path(base: &Path, old_rel: &str, new_rel: &str) -> Result<()> {
move_path_impl(base, old_rel, new_rel, false).await
}
pub async fn move_path_allow_escape(base: &Path, old_rel: &str, new_rel: &str) -> Result<()> {
move_path_impl(base, old_rel, new_rel, true).await
}
async fn move_path_impl(
base: &Path,
old_rel: &str,
new_rel: &str,
allow_escape: bool,
) -> Result<()> {
let old = sanitize_read(base, old_rel, allow_escape)?;
let new = sanitize_new(base, new_rel, allow_escape)?;
if let Some(parent) = new.parent() {
fs::create_dir_all(parent).await?;
}
fs::rename(&old, &new).await?;
Ok(())
}
pub async fn copy_paths(base: &Path, paths: &[String], dest: &str) -> Result<()> {
copy_paths_impl(base, paths, dest, false).await
}
pub async fn copy_paths_allow_escape(base: &Path, paths: &[String], dest: &str) -> Result<()> {
copy_paths_impl(base, paths, dest, true).await
}
async fn copy_paths_impl(
base: &Path,
paths: &[String],
dest: &str,
allow_escape: bool,
) -> Result<()> {
let dest_dir = sanitize_new(base, dest, allow_escape)?;
fs::create_dir_all(&dest_dir).await?;
for p in paths {
let src = sanitize_read(base, p, allow_escape)?;
let file_name = src.file_name().ok_or_else(|| anyhow!("invalid path"))?;
let target = dest_dir.join(file_name);
let metadata = fs::metadata(&src).await?;
if metadata.is_dir() {
copy_dir_recursive(&src, &target).await?;
} else {
fs::copy(&src, &target).await?;
}
}
Ok(())
}
async fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest).await?;
let mut read_dir = fs::read_dir(src).await?;
while let Some(entry) = read_dir.next_entry().await? {
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
Box::pin(copy_dir_recursive(&src_path, &dest_path)).await?;
} else {
fs::copy(&src_path, &dest_path).await?;
}
}
Ok(())
}
pub async fn search_files(base: &Path, rel_path: &str, query: &str) -> Result<Vec<FileEntry>> {
let dir = if Path::new(rel_path).is_absolute() {
PathBuf::from(rel_path)
} else if rel_path.is_empty() || rel_path == "." {
base.to_path_buf()
} else {
sanitize_path(base, rel_path)?
};
let query_lower = query.to_lowercase();
let mut results = Vec::new();
search_recursive(&dir, &query_lower, "", &mut results, 100, 8).await?;
Ok(results)
}
const SKIP_DIRS: &[&str] = &[
"node_modules",
".git",
"target",
"__pycache__",
".venv",
"venv",
".next",
".nuxt",
"dist",
"build",
".cache",
"vendor",
];
fn search_recursive<'a>(
dir: &'a Path,
query: &'a str,
prefix: &'a str,
results: &'a mut Vec<FileEntry>,
max_results: usize,
max_depth: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
if results.len() >= max_results || max_depth == 0 {
return Ok(());
}
let mut read_dir = match fs::read_dir(dir).await {
Ok(rd) => rd,
Err(_) => return Ok(()), };
while let Some(entry) = read_dir.next_entry().await? {
if results.len() >= max_results {
break;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_str()) {
continue;
}
let rel = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };
let meta = match fs::metadata(entry.path()).await {
Ok(m) => m,
Err(_) => continue, };
let meta2 = fs::symlink_metadata(entry.path()).await;
let is_symlink = meta2.map(|m| m.is_symlink()).unwrap_or(false);
let is_dir = meta.is_dir();
if name.to_lowercase().contains(query) {
let path_type = match (is_symlink, is_dir) {
(true, true) => PathType::SymlinkDir,
(false, true) => PathType::Dir,
(true, false) => PathType::SymlinkFile,
(false, false) => PathType::File,
};
let mtime = meta
.modified()
.ok()
.or_else(|| meta.created().ok())
.map(|t| to_timestamp(&t))
.unwrap_or(0);
results.push(FileEntry {
path_type,
name,
mtime,
size: if is_dir { 0 } else { meta.len() },
rel_path: Some(rel.clone()),
});
}
if is_dir && !is_symlink {
search_recursive(&entry.path(), query, &rel, results, max_results, max_depth - 1)
.await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_sanitize_valid() {
let base = Path::new("/tmp/omniterm_test");
fs::create_dir_all(base.join("foo/bar")).unwrap();
assert!(sanitize_path(base, "foo/bar").is_ok());
}
#[test]
fn test_sanitize_traversal() {
let base = Path::new("/tmp/omniterm_test");
fs::create_dir_all(base).unwrap();
let err = sanitize_path(base, "../../../etc/passwd").unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
}
#[test]
fn test_sanitize_null_byte() {
let base = Path::new("/tmp/omniterm_test");
assert!(sanitize_path(base, "foo\0bar").is_err());
}
fn escape_fixture(tag: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let root = std::env::temp_dir().join(format!("omniterm_fs_escape_{tag}"));
let base = root.join("base");
let outside = root.join("outside");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&base).unwrap();
fs::create_dir_all(&outside).unwrap();
(base, outside)
}
#[test]
fn test_sanitize_allow_escape_reads_outside_base() {
let (base, outside) = escape_fixture("read");
let err = sanitize_path(&base, "../outside").unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
let resolved = sanitize_path_allow_escape(&base, "../outside").unwrap();
assert_eq!(resolved, outside.canonicalize().unwrap());
}
#[cfg(unix)]
#[test]
fn test_sanitize_allow_escape_etc_passwd() {
let base = Path::new("/tmp/omniterm_test");
fs::create_dir_all(base).unwrap();
let resolved = sanitize_path_allow_escape(base, "../../../etc/passwd").unwrap();
assert_eq!(resolved, Path::new("/etc/passwd"));
}
#[test]
fn test_sanitize_new_allow_escape_creates_outside_base() {
let (base, outside) = escape_fixture("new");
let err = sanitize_path_new(&base, "../outside/newfile").unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
let target = sanitize_path_new_allow_escape(&base, "../outside/newfile").unwrap();
assert_eq!(target, outside.join("newfile"));
}
type TestChunk = anyhow::Result<Vec<u8>>;
fn stream_fixture(tag: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("omniterm_fs_stream_{tag}"));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
fn dir_entries(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[tokio::test]
async fn test_write_file_stream_writes_chunks_and_creates_parents() {
let base = stream_fixture("ok");
let mut stream = futures_util::stream::iter(vec![
Ok(b"hello ".to_vec()) as TestChunk,
Ok(b"world".to_vec()),
]);
let n = write_file_stream(&base, "sub/a.txt", false, 100, &mut stream).await.unwrap();
assert_eq!(n, 11);
assert_eq!(fs::read(base.join("sub/a.txt")).unwrap(), b"hello world");
assert_eq!(dir_entries(&base.join("sub")), vec!["a.txt".to_string()]);
}
#[tokio::test]
async fn test_write_file_stream_overwrites_existing_target() {
let base = stream_fixture("overwrite");
fs::write(base.join("a.txt"), b"old").unwrap();
let mut stream = futures_util::stream::iter(vec![Ok(b"new".to_vec()) as TestChunk]);
write_file_stream(&base, "a.txt", false, 100, &mut stream).await.unwrap();
assert_eq!(fs::read(base.join("a.txt")).unwrap(), b"new");
}
#[tokio::test]
async fn test_write_file_stream_too_large_keeps_target_and_cleans_temp() {
let base = stream_fixture("too_large");
fs::write(base.join("a.txt"), b"previous").unwrap();
let mut stream = futures_util::stream::iter(vec![
Ok(vec![0u8; 6]) as TestChunk,
Ok(vec![0u8; 6]), ]);
let err = write_file_stream(&base, "a.txt", false, 10, &mut stream).await.unwrap_err();
assert!(matches!(err, StreamWriteError::TooLarge { limit: 10 }));
assert_eq!(fs::read(base.join("a.txt")).unwrap(), b"previous");
assert_eq!(dir_entries(&base), vec!["a.txt".to_string()]);
}
#[tokio::test]
async fn test_write_file_stream_read_error_cleans_up() {
let base = stream_fixture("read_err");
let mut stream = futures_util::stream::iter(vec![
Ok(b"partial".to_vec()) as TestChunk,
Err(anyhow!("upstream aborted")),
]);
let err = write_file_stream(&base, "a.txt", false, 100, &mut stream).await.unwrap_err();
assert!(matches!(err, StreamWriteError::Read(_)));
assert!(dir_entries(&base).is_empty());
}
#[tokio::test]
async fn test_write_file_stream_rejects_traversal() {
let base = stream_fixture("traversal");
let mut stream = futures_util::stream::iter(Vec::<TestChunk>::new());
let err =
write_file_stream(&base, "../escape.txt", false, 100, &mut stream).await.unwrap_err();
assert!(matches!(err, StreamWriteError::Path(_)));
assert!(dir_entries(&base).is_empty());
}
#[test]
fn test_sanitize_new_nested_missing_components_keep_order() {
let (base, _outside) = escape_fixture("nested");
std::fs::create_dir_all(base.join("a")).unwrap();
let resolved = sanitize_path_new(&base, "a/b/c").unwrap();
let expected = base.canonicalize().unwrap().join("a").join("b").join("c");
assert_eq!(resolved, expected);
}
#[cfg(windows)]
#[test]
fn test_display_path_windows() {
assert_eq!(display_path_str(r"\\?\G:\Codes\ot"), "G:/Codes/ot");
assert_eq!(display_path_str(r"G:\Codes\ot"), "G:/Codes/ot");
assert_eq!(display_path_str(r"\\?\UNC\server\share\x"), "//server/share/x");
assert_eq!(display_path_str("G:/already/normal"), "G:/already/normal");
}
#[cfg(unix)]
#[test]
fn test_display_path_unix_passthrough() {
assert_eq!(display_path_str("/home/user/proj"), "/home/user/proj");
}
#[tokio::test]
async fn test_write_file_allow_escape_creates_outside_base() {
let (base, outside) = escape_fixture("op_write");
let err = write_file(&base, "../outside/strict.txt", b"x").await.unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
assert!(!outside.join("strict.txt").exists());
write_file_allow_escape(&base, "../outside/allowed.txt", b"hi").await.unwrap();
assert_eq!(fs::read_to_string(outside.join("allowed.txt")).unwrap(), "hi");
}
#[tokio::test]
async fn test_delete_path_allow_escape_deletes_outside_base() {
let (base, outside) = escape_fixture("op_delete");
let target = outside.join("to_delete.txt");
fs::write(&target, b"x").unwrap();
let err = delete_path(&base, "../outside/to_delete.txt").await.unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
assert!(target.exists());
delete_path_allow_escape(&base, "../outside/to_delete.txt").await.unwrap();
assert!(!target.exists());
}
#[tokio::test]
async fn test_create_dir_allow_escape_creates_outside_base() {
let (base, outside) = escape_fixture("op_mkdir");
let err = create_dir(&base, "../outside/strict").await.unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
assert!(!outside.join("strict").exists());
create_dir_allow_escape(&base, "../outside/allowed").await.unwrap();
assert!(outside.join("allowed").is_dir());
}
#[tokio::test]
async fn test_move_path_allow_escape_moves_outside_base() {
let (base, outside) = escape_fixture("op_move");
fs::write(base.join("src.txt"), b"x").unwrap();
let err = move_path(&base, "src.txt", "../outside/dst.txt").await.unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
assert!(base.join("src.txt").exists());
move_path_allow_escape(&base, "src.txt", "../outside/dst.txt").await.unwrap();
assert!(outside.join("dst.txt").exists());
assert!(!base.join("src.txt").exists());
}
#[tokio::test]
async fn test_copy_paths_allow_escape_copies_outside_base() {
let (base, outside) = escape_fixture("op_copy");
fs::write(base.join("a.txt"), b"x").unwrap();
let err = copy_paths(&base, &["a.txt".to_string()], "../outside").await.unwrap_err();
assert!(err.to_string().contains("access denied: path escapes workspace root"));
assert!(!outside.join("a.txt").exists());
copy_paths_allow_escape(&base, &["a.txt".to_string()], "../outside").await.unwrap();
assert!(outside.join("a.txt").exists());
}
#[test]
fn test_expand_home() {
assert_eq!(expand_home("/home/user/proj"), "/home/user/proj");
assert_eq!(expand_home(""), "");
assert_eq!(expand_home("relative/path"), "relative/path");
if let Ok(home) = std::env::var("HOME") {
assert_eq!(expand_home("~"), home);
assert_eq!(expand_home("~/proj"), format!("{home}/proj"));
}
}
#[test]
fn test_dir_exists() {
let (base, _outside) = escape_fixture("dir_exists");
let base_str = base.to_str().unwrap();
assert!(dir_exists(base_str));
assert!(!dir_exists(&format!("{base_str}/nope")));
let f = base.join("file.txt");
fs::write(&f, b"x").unwrap();
assert!(!dir_exists(f.to_str().unwrap()));
}
#[test]
fn test_probe_is_utf8_text_ascii() {
assert!(probe_is_utf8_text(b"hello world"));
assert!(probe_is_utf8_text(b""));
}
#[test]
fn test_probe_is_utf8_text_multibyte() {
assert!(probe_is_utf8_text("中文".as_bytes()));
let cut = &"中文".as_bytes()[..4]; assert!(probe_is_utf8_text(cut));
assert!(probe_is_utf8_text(&[0xE4, 0xB8])); }
#[test]
fn test_probe_is_utf8_text_binary() {
assert!(!probe_is_utf8_text(&[0x00, 0x01, 0x02]));
assert!(!probe_is_utf8_text(b"\x89PNG\x0d\x0a\x1a\x0a"));
assert!(!probe_is_utf8_text(&[0xC3, 0x28]));
assert!(!probe_is_utf8_text(b"abc\xffxyz"));
}
#[tokio::test]
async fn test_read_text_file_utf8_vs_binary() {
let (base, _outside) = escape_fixture("read_text_file");
let text = base.join("a.txt");
let bin = base.join("a.bin");
fs::write(&text, "hello 中文").unwrap();
fs::write(&bin, [0x89, b'P', b'N', b'G', 0x00, 0x01]).unwrap();
assert_eq!(read_text_file(&text).await.unwrap(), Some("hello 中文".to_string()));
assert_eq!(read_text_file(&bin).await.unwrap(), None);
}
#[tokio::test]
async fn test_read_text_file_full_null_rejected() {
let (base, _outside) = escape_fixture("read_text_file_null");
let mut content = vec![b'a'; TEXT_PROBE_BYTES];
content.push(0);
let f = base.join("tail_null.txt");
fs::write(&f, &content).unwrap();
assert_eq!(read_text_file(&f).await.unwrap(), None);
}
}