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 { 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, 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, 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 { use std::os::unix::fs::MetadataExt; let mut cursor = Some(start); let mut fallback: Option = 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 { 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, }