use std::sync::Arc;
use bytes::Bytes;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::backend::Backend;
use crate::{
MicrosandboxResult,
sandbox::fs::{FsEntry, FsMetadata},
};
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
pub struct VolumeFs<'a> {
backend: Arc<dyn Backend>,
name: &'a str,
}
pub struct VolumeFsReadStream {
file: tokio::fs::File,
buf: Vec<u8>,
}
impl VolumeFsReadStream {
pub(crate) fn from_file(file: tokio::fs::File) -> Self {
Self {
file,
buf: vec![0u8; STREAM_CHUNK_SIZE],
}
}
}
pub struct VolumeFsWriteSink {
file: tokio::fs::File,
}
impl VolumeFsWriteSink {
pub(crate) fn from_file(file: tokio::fs::File) -> Self {
Self { file }
}
}
impl<'a> VolumeFs<'a> {
pub(crate) fn new(backend: Arc<dyn Backend>, name: &'a str) -> Self {
Self { backend, name }
}
pub fn with_backend(backend: Arc<dyn Backend>, name: &'a str) -> Self {
Self { backend, name }
}
pub async fn read(&self, path: &str) -> MicrosandboxResult<Bytes> {
self.backend.volumes().fs_read(self.name, path).await
}
pub async fn read_to_string(&self, path: &str) -> MicrosandboxResult<String> {
self.backend
.volumes()
.fs_read_to_string(self.name, path)
.await
}
pub async fn read_stream(&self, path: &str) -> MicrosandboxResult<VolumeFsReadStream> {
self.backend.volumes().fs_read_stream(self.name, path).await
}
pub async fn write(&self, path: &str, data: impl AsRef<[u8]>) -> MicrosandboxResult<()> {
let bytes = data.as_ref().to_vec();
self.backend
.volumes()
.fs_write(self.name, path, bytes)
.await
}
pub async fn write_stream(&self, path: &str) -> MicrosandboxResult<VolumeFsWriteSink> {
self.backend
.volumes()
.fs_write_stream(self.name, path)
.await
}
pub async fn list(&self, path: &str) -> MicrosandboxResult<Vec<FsEntry>> {
self.backend.volumes().fs_list(self.name, path).await
}
pub async fn mkdir(&self, path: &str) -> MicrosandboxResult<()> {
self.backend.volumes().fs_mkdir(self.name, path).await
}
pub async fn remove_dir(&self, path: &str) -> MicrosandboxResult<()> {
self.backend
.volumes()
.fs_remove(self.name, path, true)
.await
}
pub async fn remove(&self, path: &str) -> MicrosandboxResult<()> {
self.backend
.volumes()
.fs_remove(self.name, path, false)
.await
}
pub async fn copy(&self, from: &str, to: &str) -> MicrosandboxResult<()> {
self.backend.volumes().fs_copy(self.name, from, to).await
}
pub async fn rename(&self, from: &str, to: &str) -> MicrosandboxResult<()> {
self.backend.volumes().fs_rename(self.name, from, to).await
}
pub async fn stat(&self, path: &str) -> MicrosandboxResult<FsMetadata> {
self.backend.volumes().fs_stat(self.name, path).await
}
pub async fn exists(&self, path: &str) -> MicrosandboxResult<bool> {
self.backend.volumes().fs_exists(self.name, path).await
}
}
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(())
}
}
pub(crate) mod local {
use std::path::{Path, PathBuf};
use bytes::Bytes;
use crate::{
MicrosandboxError, MicrosandboxResult,
backend::LocalBackend,
sandbox::fs::{FsEntry, FsEntryKind, FsMetadata},
};
use super::{VolumeFsReadStream, VolumeFsWriteSink};
pub(crate) fn resolve_relative(root: &Path, path: &str) -> MicrosandboxResult<PathBuf> {
let clean = path.strip_prefix('/').unwrap_or(path);
let joined = root.join(clean);
let canonical = if joined.exists() {
joined
.canonicalize()
.map_err(|e| MicrosandboxError::SandboxFsOps(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::SandboxFsOps(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::SandboxFsOps(format!("resolve root: {e}")))?
} else {
root.to_path_buf()
};
if !canonical.starts_with(&canon_root) {
return Err(MicrosandboxError::SandboxFsOps(
"path traversal outside volume root".into(),
));
}
Ok(canonical)
}
fn volume_root(local: &LocalBackend, name: &str) -> PathBuf {
local.volume_path(name)
}
fn resolve(local: &LocalBackend, name: &str, path: &str) -> MicrosandboxResult<PathBuf> {
resolve_relative(&volume_root(local, name), path)
}
pub(crate) async fn read(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<Bytes> {
let full = resolve(local, name, path)?;
let data = tokio::fs::read(&full).await?;
Ok(Bytes::from(data))
}
pub(crate) async fn read_to_string(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<String> {
let full = resolve(local, name, path)?;
let data = tokio::fs::read_to_string(&full).await?;
Ok(data)
}
pub(crate) async fn write(
local: &LocalBackend,
name: &str,
path: &str,
data: &[u8],
) -> MicrosandboxResult<()> {
let full = resolve(local, name, path)?;
if let Some(parent) = full.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&full, data).await?;
Ok(())
}
pub(crate) async fn list(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<Vec<FsEntry>> {
let root = volume_root(local, name);
let full = resolve_relative(&root, 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(&root).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(crate) async fn mkdir(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<()> {
let full = resolve(local, name, path)?;
tokio::fs::create_dir_all(&full).await?;
Ok(())
}
pub(crate) async fn remove(
local: &LocalBackend,
name: &str,
path: &str,
recursive: bool,
) -> MicrosandboxResult<()> {
let root = volume_root(local, name);
let full = resolve_relative(&root, path)?;
if recursive {
ensure_not_volume_root(&root, &full, "remove_dir")?;
tokio::fs::remove_dir_all(&full).await?;
} else {
tokio::fs::remove_file(&full).await?;
}
Ok(())
}
pub(crate) async fn copy(
local: &LocalBackend,
name: &str,
from: &str,
to: &str,
) -> MicrosandboxResult<()> {
let src = resolve(local, name, from)?;
let dst = resolve(local, name, to)?;
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::copy(&src, &dst).await?;
Ok(())
}
pub(crate) async fn rename(
local: &LocalBackend,
name: &str,
from: &str,
to: &str,
) -> MicrosandboxResult<()> {
let src = resolve(local, name, from)?;
let dst = resolve(local, name, to)?;
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::rename(&src, &dst).await?;
Ok(())
}
pub(crate) async fn stat(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<FsMetadata> {
let full = resolve(local, name, path)?;
let meta = tokio::fs::symlink_metadata(&full).await?;
Ok(std_metadata_to_fs(&meta))
}
pub(crate) async fn exists(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<bool> {
let full = resolve(local, name, path)?;
Ok(tokio::fs::try_exists(&full).await.unwrap_or(false))
}
pub(crate) async fn read_stream(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<VolumeFsReadStream> {
let full = resolve(local, name, path)?;
let file = tokio::fs::File::open(&full).await?;
Ok(VolumeFsReadStream::from_file(file))
}
pub(crate) async fn write_stream(
local: &LocalBackend,
name: &str,
path: &str,
) -> MicrosandboxResult<VolumeFsWriteSink> {
let full = resolve(local, name, path)?;
if let Some(parent) = full.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = tokio::fs::File::create(&full).await?;
Ok(VolumeFsWriteSink::from_file(file))
}
fn ensure_not_volume_root(root: &Path, path: &Path, operation: &str) -> MicrosandboxResult<()> {
let canon_root = if root.exists() {
root.canonicalize()
.map_err(|e| MicrosandboxError::SandboxFsOps(format!("resolve root: {e}")))?
} else {
root.to_path_buf()
};
if path == canon_root {
return Err(MicrosandboxError::SandboxFsOps(format!(
"{operation} cannot target the volume root"
)));
}
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),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::LocalBackend;
#[tokio::test]
async fn remove_dir_rejects_slash_volume_root() {
let (_temp, backend) = local_backend().await;
local::write(&backend, "vol", "nested/file.txt", b"data")
.await
.unwrap();
let root = backend.volume_path("vol");
let err = local::remove(&backend, "vol", "/", true).await.unwrap_err();
assert!(
err.to_string().contains("volume root"),
"unexpected error: {err}"
);
assert!(root.is_dir());
assert!(root.join("nested/file.txt").is_file());
}
#[tokio::test]
async fn remove_dir_rejects_empty_volume_root() {
let (_temp, backend) = local_backend().await;
local::write(&backend, "vol", "nested/file.txt", b"data")
.await
.unwrap();
let root = backend.volume_path("vol");
let err = local::remove(&backend, "vol", "", true).await.unwrap_err();
assert!(
err.to_string().contains("volume root"),
"unexpected error: {err}"
);
assert!(root.is_dir());
assert!(root.join("nested/file.txt").is_file());
}
#[tokio::test]
async fn remove_dir_removes_child_directory() {
let (_temp, backend) = local_backend().await;
local::write(&backend, "vol", "nested/file.txt", b"data")
.await
.unwrap();
let root = backend.volume_path("vol");
local::remove(&backend, "vol", "nested", true)
.await
.unwrap();
assert!(root.is_dir());
assert!(!root.join("nested").exists());
}
async fn local_backend() -> (tempfile::TempDir, LocalBackend) {
let temp = tempfile::tempdir().unwrap();
let backend = LocalBackend::builder()
.home(temp.path())
.build()
.await
.unwrap();
tokio::fs::create_dir_all(backend.volume_path("vol"))
.await
.unwrap();
(temp, backend)
}
}