234 lines
7.1 KiB
Rust
234 lines
7.1 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use tokio::fs;
|
|
use tracing::debug;
|
|
|
|
use crate::error::DaemonError;
|
|
|
|
/// Filesystem operations with path jail enforcement.
|
|
pub struct FileSystem {
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl FileSystem {
|
|
pub fn new(root: PathBuf) -> Self {
|
|
Self { root }
|
|
}
|
|
|
|
/// Resolve a relative path within the jail. Prevents path traversal.
|
|
fn resolve(&self, relative: &str) -> Result<PathBuf, DaemonError> {
|
|
let clean = relative.trim_start_matches('/');
|
|
let resolved = self.root.join(clean);
|
|
|
|
// Canonicalize both to compare (handle .. and symlinks)
|
|
// For non-existent paths, check the parent
|
|
let check_path = if resolved.exists() {
|
|
resolved.canonicalize().map_err(DaemonError::Io)?
|
|
} else {
|
|
let parent = resolved
|
|
.parent()
|
|
.ok_or_else(|| DaemonError::PathTraversal(relative.to_string()))?;
|
|
if !parent.exists() {
|
|
// Parent doesn't exist either — check the root prefix
|
|
let normalized = self.root.join(clean);
|
|
if !normalized.starts_with(&self.root) {
|
|
return Err(DaemonError::PathTraversal(relative.to_string()));
|
|
}
|
|
return Ok(normalized);
|
|
}
|
|
let canonical_parent = parent.canonicalize().map_err(DaemonError::Io)?;
|
|
canonical_parent.join(resolved.file_name().unwrap_or_default())
|
|
};
|
|
|
|
let canonical_root = self.root.canonicalize().unwrap_or_else(|_| self.root.clone());
|
|
if !check_path.starts_with(&canonical_root) {
|
|
return Err(DaemonError::PathTraversal(relative.to_string()));
|
|
}
|
|
|
|
Ok(resolved)
|
|
}
|
|
|
|
/// List files in a directory.
|
|
pub async fn list_files(&self, path: &str) -> Result<Vec<FileEntry>, DaemonError> {
|
|
let resolved = self.resolve(path)?;
|
|
let mut entries = Vec::new();
|
|
|
|
let mut reader = fs::read_dir(&resolved).await.map_err(DaemonError::Io)?;
|
|
while let Some(entry) = reader.next_entry().await.map_err(DaemonError::Io)? {
|
|
let metadata = entry.metadata().await.map_err(DaemonError::Io)?;
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
let relative_path = format!(
|
|
"{}/{}",
|
|
path.trim_end_matches('/'),
|
|
&name
|
|
);
|
|
|
|
entries.push(FileEntry {
|
|
name,
|
|
path: relative_path,
|
|
is_directory: metadata.is_dir(),
|
|
size: metadata.len() as i64,
|
|
modified_at: metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0),
|
|
});
|
|
}
|
|
|
|
entries.sort_by(|a, b| {
|
|
// Directories first, then by name
|
|
b.is_directory.cmp(&a.is_directory).then(a.name.cmp(&b.name))
|
|
});
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Read file contents.
|
|
pub async fn read_file(&self, path: &str) -> Result<Vec<u8>, DaemonError> {
|
|
let resolved = self.resolve(path)?;
|
|
debug!(path = %resolved.display(), "Reading file");
|
|
fs::read(&resolved).await.map_err(DaemonError::Io)
|
|
}
|
|
|
|
/// Write file contents.
|
|
pub async fn write_file(&self, path: &str, data: &[u8]) -> Result<(), DaemonError> {
|
|
let resolved = self.resolve(path)?;
|
|
let owner = resolved
|
|
.parent()
|
|
.and_then(resolve_target_ownership);
|
|
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = resolved.parent() {
|
|
fs::create_dir_all(parent).await.map_err(DaemonError::Io)?;
|
|
if let Some(ref owner) = owner {
|
|
apply_ownership_to_path_chain(parent, &owner)?;
|
|
}
|
|
}
|
|
|
|
debug!(path = %resolved.display(), "Writing file");
|
|
fs::write(&resolved, data).await.map_err(DaemonError::Io)?;
|
|
if let Some(ref owner) = owner {
|
|
apply_ownership(&resolved, owner.uid, owner.gid)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete files or directories.
|
|
pub async fn delete_paths(&self, paths: &[String]) -> Result<(), DaemonError> {
|
|
for path in paths {
|
|
let resolved = self.resolve(path)?;
|
|
if resolved.is_dir() {
|
|
fs::remove_dir_all(&resolved).await.map_err(DaemonError::Io)?;
|
|
} else {
|
|
fs::remove_file(&resolved).await.map_err(DaemonError::Io)?;
|
|
}
|
|
debug!(path = %resolved.display(), "Deleted");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct OwnershipTarget {
|
|
anchor: PathBuf,
|
|
uid: u32,
|
|
gid: u32,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn resolve_target_ownership(start: &Path) -> Option<OwnershipTarget> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let mut cursor = Some(start);
|
|
let mut fallback: Option<OwnershipTarget> = None;
|
|
|
|
while let Some(path) = cursor {
|
|
if let Ok(metadata) = std::fs::metadata(path) {
|
|
let candidate = OwnershipTarget {
|
|
anchor: path.to_path_buf(),
|
|
uid: metadata.uid(),
|
|
gid: metadata.gid(),
|
|
};
|
|
|
|
if fallback.is_none() {
|
|
fallback = Some(candidate.clone());
|
|
}
|
|
|
|
if candidate.uid != 0 || candidate.gid != 0 {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
cursor = path.parent();
|
|
}
|
|
|
|
fallback
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn resolve_target_ownership(_start: &Path) -> Option<OwnershipTarget> {
|
|
None
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn apply_ownership_to_path_chain(target: &Path, owner: &OwnershipTarget) -> Result<(), DaemonError> {
|
|
if !target.starts_with(&owner.anchor) {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut current = owner.anchor.clone();
|
|
apply_ownership(¤t, owner.uid, owner.gid)?;
|
|
|
|
let remainder = match target.strip_prefix(&owner.anchor) {
|
|
Ok(path) => path,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
for component in remainder.components() {
|
|
current.push(component.as_os_str());
|
|
apply_ownership(¤t, owner.uid, owner.gid)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn apply_ownership_to_path_chain(_target: &Path, _owner: &OwnershipTarget) -> Result<(), DaemonError> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn apply_ownership(path: &Path, uid: u32, gid: u32) -> Result<(), DaemonError> {
|
|
use std::ffi::CString;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
|
|
let bytes = path.as_os_str().as_bytes();
|
|
let c_path = CString::new(bytes).map_err(|err| {
|
|
DaemonError::Io(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
format!("invalid path for chown: {err}"),
|
|
))
|
|
})?;
|
|
|
|
let result = unsafe { libc::chown(c_path.as_ptr(), uid, gid) };
|
|
if result != 0 {
|
|
return Err(DaemonError::Io(std::io::Error::last_os_error()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn apply_ownership(_path: &Path, _uid: u32, _gid: u32) -> Result<(), DaemonError> {
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FileEntry {
|
|
pub name: String,
|
|
pub path: String,
|
|
pub is_directory: bool,
|
|
pub size: i64,
|
|
pub modified_at: i64,
|
|
}
|