Files
source-gamepanel/apps/daemon/src/filesystem/operations.rs
T

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(&current, 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(&current, 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,
}