use std::path::{Path, PathBuf};
use bytes::Bytes;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::{
MicrosandboxError, MicrosandboxResult,
sandbox::fs::{FsEntry, FsEntryKind, FsMetadata},
};
pub struct VolumeFs<'a> {
root: VolumeRoot<'a>,
}
enum VolumeRoot<'a> {
Borrowed(&'a Path),
Owned(PathBuf),
}
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
pub struct VolumeFsReadStream {
file: tokio::fs::File,
buf: Vec<u8>,
}
pub struct VolumeFsWriteSink {
file: tokio::fs::File,
}
impl<'a> VolumeFs<'a> {
pub(crate) fn from_path_ref(path: &'a Path) -> Self {
Self {
root: VolumeRoot::Borrowed(path),
}
}
pub fn from_path(path: PathBuf) -> Self {
Self {
root: VolumeRoot::Owned(path),
}
}
fn root_path(&self) -> &Path {
match &self.root {
VolumeRoot::Borrowed(p) => p,
VolumeRoot::Owned(p) => p,
}
}
pub async fn read(&self, path: &str) -> MicrosandboxResult<Bytes> {
let full = self.resolve(path)?;
let data = tokio::fs::read(&full).await?;
Ok(Bytes::from(data))
}
pub async fn read_to_string(&self, path: &str) -> MicrosandboxResult<String> {
let full = self.resolve(path)?;
let data = tokio::fs::read_to_string(&full).await?;
Ok(data)
}
pub async fn read_stream(&self, path: &str) -> MicrosandboxResult<VolumeFsReadStream> {
let full = self.resolve(path)?;
let file = tokio::fs::File::open(&full).await?;
Ok(VolumeFsReadStream {
file,
buf: vec![0u8; STREAM_CHUNK_SIZE],
})
}
pub async fn write(&self, path: &str, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> {
let full = self.resolve(path)?;
if let Some(parent) = full.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&full, data.as_ref()).await?;
Ok(())
}
pub async fn write_stream(&self, path: &str) -> MicrosandboxResult<VolumeFsWriteSink> {
let full = self.resolve(path)?;
if let Some(parent) = full.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = tokio::fs::File::create(&full).await?;
Ok(VolumeFsWriteSink { file })
}
pub async fn list(&self, path: &str) -> MicrosandboxResult<Vec<FsEntry>> {
let full = self.resolve(path)?;
let mut dir = tokio::fs::read_dir(&full).await?;
let mut entries = Vec::new();
while let Some(entry) = dir.next_entry().await? {
let entry_path = entry.path();
let rel_path = entry_path
.strip_prefix(self.root_path())
.unwrap_or(&entry_path);
match entry.metadata().await {
Ok(meta) => {
entries.push(metadata_to_entry(
&format!("/{}", rel_path.display()),
&meta,
));
}
Err(_) => {
entries.push(FsEntry {
path: format!("/{}", rel_path.display()),
kind: FsEntryKind::Other,
size: 0,
mode: 0,
modified: None,
});
}
}
}
Ok(entries)
}
pub async fn mkdir(&self, path: &str) -> MicrosandboxResult<()> {
let full = self.resolve(path)?;
tokio::fs::create_dir_all(&full).await?;
Ok(())
}
pub async fn remove_dir(&self, path: &str) -> MicrosandboxResult<()> {
let full = self.resolve(path)?;
tokio::fs::remove_dir_all(&full).await?;
Ok(())
}
pub async fn remove(&self, path: &str) -> MicrosandboxResult<()> {
let full = self.resolve(path)?;
tokio::fs::remove_file(&full).await?;
Ok(())
}
pub async fn copy(&self, from: &str, to: &str) -> MicrosandboxResult<()> {
let src = self.resolve(from)?;
let dst = self.resolve(to)?;
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::copy(&src, &dst).await?;
Ok(())
}
pub async fn rename(&self, from: &str, to: &str) -> MicrosandboxResult<()> {
let src = self.resolve(from)?;
let dst = self.resolve(to)?;
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::rename(&src, &dst).await?;
Ok(())
}
pub async fn stat(&self, path: &str) -> MicrosandboxResult<FsMetadata> {
let full = self.resolve(path)?;
let meta = tokio::fs::symlink_metadata(&full).await?;
Ok(std_metadata_to_fs(&meta))
}
pub async fn exists(&self, path: &str) -> MicrosandboxResult<bool> {
let full = self.resolve(path)?;
Ok(tokio::fs::try_exists(&full).await.unwrap_or(false))
}
}
impl VolumeFs<'_> {
fn resolve(&self, path: &str) -> MicrosandboxResult<PathBuf> {
let root = self.root_path();
let clean = path.strip_prefix('/').unwrap_or(path);
let joined = root.join(clean);
let canonical = if joined.exists() {
joined
.canonicalize()
.map_err(|e| MicrosandboxError::SandboxFs(format!("resolve path: {e}")))?
} else {
let mut ancestor = joined.as_path();
loop {
if let Some(parent) = ancestor.parent() {
if parent.exists() {
let canon_parent = parent.canonicalize().map_err(|e| {
MicrosandboxError::SandboxFs(format!("resolve parent: {e}"))
})?;
let remainder = joined.strip_prefix(parent).unwrap_or(Path::new(""));
break canon_parent.join(remainder);
}
ancestor = parent;
} else {
break joined.clone();
}
}
};
let canon_root = if root.exists() {
root.canonicalize()
.map_err(|e| MicrosandboxError::SandboxFs(format!("resolve root: {e}")))?
} else {
root.to_path_buf()
};
if !canonical.starts_with(&canon_root) {
return Err(MicrosandboxError::SandboxFs(
"path traversal outside volume root".into(),
));
}
Ok(canonical)
}
}
impl VolumeFsReadStream {
pub async fn recv(&mut self) -> MicrosandboxResult<Option<Bytes>> {
let n = self.file.read(&mut self.buf).await?;
if n == 0 {
Ok(None)
} else {
Ok(Some(Bytes::copy_from_slice(&self.buf[..n])))
}
}
pub async fn collect(mut self) -> MicrosandboxResult<Bytes> {
let mut data = Vec::new();
let mut buf = vec![0u8; STREAM_CHUNK_SIZE];
loop {
let n = self.file.read(&mut buf).await?;
if n == 0 {
break;
}
data.extend_from_slice(&buf[..n]);
}
Ok(Bytes::from(data))
}
}
impl VolumeFsWriteSink {
pub async fn write(&mut self, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> {
self.file.write_all(data.as_ref()).await?;
Ok(())
}
pub async fn close(mut self) -> MicrosandboxResult<()> {
self.file.flush().await?;
Ok(())
}
}
fn std_kind(meta: &std::fs::Metadata) -> FsEntryKind {
if meta.is_file() {
FsEntryKind::File
} else if meta.is_dir() {
FsEntryKind::Directory
} else if meta.is_symlink() {
FsEntryKind::Symlink
} else {
FsEntryKind::Other
}
}
fn std_modified(meta: &std::fs::Metadata) -> Option<chrono::DateTime<chrono::Utc>> {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_default())
}
fn metadata_to_entry(path: &str, meta: &std::fs::Metadata) -> FsEntry {
use std::os::unix::fs::MetadataExt;
FsEntry {
path: path.to_string(),
kind: std_kind(meta),
size: meta.len(),
mode: meta.mode(),
modified: std_modified(meta),
}
}
fn std_created(meta: &std::fs::Metadata) -> Option<chrono::DateTime<chrono::Utc>> {
meta.created()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_default())
}
fn std_metadata_to_fs(meta: &std::fs::Metadata) -> FsMetadata {
use std::os::unix::fs::MetadataExt;
FsMetadata {
kind: std_kind(meta),
size: meta.len(),
mode: meta.mode(),
readonly: meta.permissions().readonly(),
modified: std_modified(meta),
created: std_created(meta),
}
}