Spaces:
Running
Running
| pub mod inode; | |
| use crate::auth::perms::{has_setgid, Access}; | |
| use crate::auth::registry::UserRegistry; | |
| use crate::auth::session::Session; | |
| use crate::auth::{Gid, Uid, ROOT_GID, ROOT_UID}; | |
| use crate::config::CompatibilityTarget; | |
| use crate::error::VfsError; | |
| use inode::{Inode, InodeId, InodeKind}; | |
| use std::collections::BTreeMap; | |
| use std::collections::{HashMap, HashSet}; | |
| pub type HandleId = u64; | |
| pub struct FsOptions { | |
| pub compatibility_target: CompatibilityTarget, | |
| } | |
| impl Default for FsOptions { | |
| fn default() -> Self { | |
| Self { | |
| compatibility_target: CompatibilityTarget::Markdown, | |
| } | |
| } | |
| } | |
| struct OpenHandle { | |
| inode_id: InodeId, | |
| cursor: usize, | |
| writable: bool, | |
| directory: bool, | |
| } | |
| pub struct VirtualFs { | |
| inodes: HashMap<InodeId, Inode>, | |
| root: InodeId, | |
| cwd: InodeId, | |
| next_id: InodeId, | |
| cwd_path: Vec<(String, InodeId)>, | |
| options: FsOptions, | |
| next_handle: HandleId, | |
| handles: HashMap<HandleId, OpenHandle>, | |
| pending_delete: HashSet<InodeId>, | |
| pub registry: UserRegistry, | |
| } | |
| impl VirtualFs { | |
| pub fn new() -> Self { | |
| Self::new_with_options(FsOptions::default()) | |
| } | |
| pub fn new_posix() -> Self { | |
| Self::new_with_options(FsOptions { | |
| compatibility_target: CompatibilityTarget::Posix, | |
| }) | |
| } | |
| pub fn new_with_options(options: FsOptions) -> Self { | |
| let root_id = 0; | |
| let root = Inode::new_dir(root_id, ROOT_UID, ROOT_GID); | |
| let mut inodes = HashMap::new(); | |
| inodes.insert(root_id, root); | |
| VirtualFs { | |
| inodes, | |
| root: root_id, | |
| cwd: root_id, | |
| next_id: 1, | |
| cwd_path: Vec::new(), | |
| options, | |
| next_handle: 1, | |
| handles: HashMap::new(), | |
| pending_delete: HashSet::new(), | |
| registry: UserRegistry::new(), | |
| } | |
| } | |
| fn alloc_id(&mut self) -> InodeId { | |
| let id = self.next_id; | |
| self.next_id += 1; | |
| id | |
| } | |
| fn alloc_handle_id(&mut self) -> HandleId { | |
| let id = self.next_handle; | |
| self.next_handle += 1; | |
| id | |
| } | |
| pub fn options(&self) -> FsOptions { | |
| self.options | |
| } | |
| pub fn compatibility_target(&self) -> CompatibilityTarget { | |
| self.options.compatibility_target | |
| } | |
| pub fn pwd(&self) -> String { | |
| if self.cwd_path.is_empty() { | |
| "/".to_string() | |
| } else { | |
| let mut path = String::new(); | |
| for (name, _) in &self.cwd_path { | |
| path.push('/'); | |
| path.push_str(name); | |
| } | |
| path | |
| } | |
| } | |
| pub fn get_inode(&self, id: InodeId) -> Result<&Inode, VfsError> { | |
| self.inodes.get(&id).ok_or_else(|| VfsError::NotFound { | |
| path: format!("<inode {id}>"), | |
| }) | |
| } | |
| fn get_inode_mut(&mut self, id: InodeId) -> Result<&mut Inode, VfsError> { | |
| self.inodes.get_mut(&id).ok_or_else(|| VfsError::NotFound { | |
| path: format!("<inode {id}>"), | |
| }) | |
| } | |
| fn dir_entries(&self, id: InodeId) -> Result<&BTreeMap<String, InodeId>, VfsError> { | |
| match &self.get_inode(id)?.kind { | |
| InodeKind::Directory { entries } => Ok(entries), | |
| _ => Err(VfsError::NotDirectory { | |
| path: format!("<inode {id}>"), | |
| }), | |
| } | |
| } | |
| fn dir_entries_mut( | |
| &mut self, | |
| id: InodeId, | |
| ) -> Result<&mut BTreeMap<String, InodeId>, VfsError> { | |
| match &mut self.get_inode_mut(id)?.kind { | |
| InodeKind::Directory { entries } => Ok(entries), | |
| _ => Err(VfsError::NotDirectory { | |
| path: format!("<inode {id}>"), | |
| }), | |
| } | |
| } | |
| /// Resolve a path without permission checks (for internal/VCS use). | |
| pub fn resolve_path(&self, path: &str) -> Result<InodeId, VfsError> { | |
| let (start, components) = self.parse_path(path); | |
| let mut current = start; | |
| for component in &components { | |
| match component.as_str() { | |
| "." => {} | |
| ".." => { | |
| current = self.parent_of(current); | |
| } | |
| name => { | |
| let entries = self.dir_entries(current)?; | |
| current = *entries.get(name).ok_or_else(|| VfsError::NotFound { | |
| path: path.to_string(), | |
| })?; | |
| } | |
| } | |
| } | |
| Ok(current) | |
| } | |
| /// Resolve a path with Execute permission checks on every directory. | |
| pub fn resolve_path_checked( | |
| &self, | |
| path: &str, | |
| session: &Session, | |
| ) -> Result<InodeId, VfsError> { | |
| let (start, components) = self.parse_path(path); | |
| let mut current = start; | |
| // Check execute on starting directory | |
| let start_inode = self.get_inode(current)?; | |
| if !session.has_permission(start_inode, Access::Execute) { | |
| return Err(VfsError::PermissionDenied { | |
| path: path.to_string(), | |
| }); | |
| } | |
| for component in &components { | |
| match component.as_str() { | |
| "." => {} | |
| ".." => { | |
| current = self.parent_of(current); | |
| } | |
| name => { | |
| let entries = self.dir_entries(current)?; | |
| current = *entries.get(name).ok_or_else(|| VfsError::NotFound { | |
| path: path.to_string(), | |
| })?; | |
| // Check execute on intermediate directories | |
| let inode = self.get_inode(current)?; | |
| if inode.is_dir() && !session.has_permission(inode, Access::Execute) { | |
| return Err(VfsError::PermissionDenied { | |
| path: path.to_string(), | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| Ok(current) | |
| } | |
| /// Resolve parent with permission checks. | |
| pub fn resolve_parent_checked( | |
| &self, | |
| path: &str, | |
| session: &Session, | |
| ) -> Result<(InodeId, String), VfsError> { | |
| let (start, components) = self.parse_path(path); | |
| if components.is_empty() { | |
| return Err(VfsError::InvalidPath { | |
| path: path.to_string(), | |
| }); | |
| } | |
| let name = components.last().unwrap().clone(); | |
| let parent_path = if components.len() == 1 { | |
| if path.starts_with('/') { | |
| "/".to_string() | |
| } else { | |
| ".".to_string() | |
| } | |
| } else { | |
| let parent_components = &components[..components.len() - 1]; | |
| if path.starts_with('/') { | |
| format!("/{}", parent_components.join("/")) | |
| } else { | |
| parent_components.join("/") | |
| } | |
| }; | |
| let _ = start; // used via parse_path in resolve_path_checked | |
| let parent_id = self.resolve_path_checked(&parent_path, session)?; | |
| Ok((parent_id, name)) | |
| } | |
| fn resolve_parent(&self, path: &str) -> Result<(InodeId, String), VfsError> { | |
| let (start, components) = self.parse_path(path); | |
| if components.is_empty() { | |
| return Err(VfsError::InvalidPath { | |
| path: path.to_string(), | |
| }); | |
| } | |
| let name = components.last().unwrap().clone(); | |
| let mut current = start; | |
| for component in &components[..components.len() - 1] { | |
| match component.as_str() { | |
| "." => {} | |
| ".." => { | |
| current = self.parent_of(current); | |
| } | |
| n => { | |
| let entries = self.dir_entries(current)?; | |
| current = *entries.get(n).ok_or_else(|| VfsError::NotFound { | |
| path: path.to_string(), | |
| })?; | |
| } | |
| } | |
| } | |
| Ok((current, name)) | |
| } | |
| fn parse_path(&self, path: &str) -> (InodeId, Vec<String>) { | |
| let (start, path_str) = if path.starts_with('/') { | |
| (self.root, &path[1..]) | |
| } else { | |
| (self.cwd, path) | |
| }; | |
| let components: Vec<String> = path_str | |
| .split('/') | |
| .filter(|s| !s.is_empty()) | |
| .map(String::from) | |
| .collect(); | |
| (start, components) | |
| } | |
| fn parent_of(&self, id: InodeId) -> InodeId { | |
| if id == self.root { | |
| return self.root; | |
| } | |
| for i in (0..self.cwd_path.len()).rev() { | |
| if self.cwd_path[i].1 == id && i > 0 { | |
| return self.cwd_path[i - 1].1; | |
| } | |
| } | |
| for inode in self.inodes.values() { | |
| if let InodeKind::Directory { entries } = &inode.kind { | |
| if entries.values().any(|&child| child == id) { | |
| return inode.id; | |
| } | |
| } | |
| } | |
| self.root | |
| } | |
| /// Determine the gid for a new child in this directory. | |
| /// If the parent has setgid, inherit parent's gid; otherwise use caller's gid. | |
| fn effective_gid(&self, parent_id: InodeId, caller_gid: Gid) -> Gid { | |
| if let Ok(parent) = self.get_inode(parent_id) { | |
| if has_setgid(parent.mode) { | |
| return parent.gid; | |
| } | |
| } | |
| caller_gid | |
| } | |
| fn validate_regular_path(&self, path: &str) -> Result<(), VfsError> { | |
| if self.compatibility_target() == CompatibilityTarget::Posix { | |
| return Ok(()); | |
| } | |
| validate_markdown_filename(path) | |
| } | |
| fn mark_accessed(&mut self, id: InodeId) -> Result<(), VfsError> { | |
| let inode = self.get_inode_mut(id)?; | |
| inode.touch_access(); | |
| Ok(()) | |
| } | |
| fn unlink_inode_if_needed(&mut self, id: InodeId) { | |
| let open = self | |
| .handles | |
| .values() | |
| .any(|handle| handle.inode_id == id); | |
| if let Some(inode) = self.inodes.get(&id) { | |
| if inode.nlink == 0 { | |
| if open { | |
| self.pending_delete.insert(id); | |
| } else { | |
| self.pending_delete.remove(&id); | |
| self.inodes.remove(&id); | |
| } | |
| } | |
| } | |
| } | |
| fn release_orphan_if_needed(&mut self, id: InodeId) { | |
| if !self.pending_delete.contains(&id) { | |
| return; | |
| } | |
| let still_open = self | |
| .handles | |
| .values() | |
| .any(|handle| handle.inode_id == id); | |
| if !still_open { | |
| self.pending_delete.remove(&id); | |
| self.inodes.remove(&id); | |
| } | |
| } | |
| // ───── Commands ───── | |
| pub fn ls(&self, path: Option<&str>) -> Result<Vec<LsEntry>, VfsError> { | |
| let id = match path { | |
| Some(p) => self.resolve_path(p)?, | |
| None => self.cwd, | |
| }; | |
| let inode = self.get_inode(id)?; | |
| match &inode.kind { | |
| InodeKind::Directory { entries } => { | |
| let mut result = Vec::with_capacity(entries.len()); | |
| for (name, &child_id) in entries { | |
| let child = self.get_inode(child_id)?; | |
| result.push(LsEntry { | |
| name: name.clone(), | |
| is_dir: child.is_dir(), | |
| is_symlink: matches!(child.kind, InodeKind::Symlink { .. }), | |
| size: child.size(), | |
| mode: child.mode, | |
| uid: child.uid, | |
| gid: child.gid, | |
| modified: child.modified, | |
| }); | |
| } | |
| Ok(result) | |
| } | |
| InodeKind::File { .. } => Ok(vec![LsEntry { | |
| name: path.unwrap_or("").to_string(), | |
| is_dir: false, | |
| is_symlink: false, | |
| size: inode.size(), | |
| mode: inode.mode, | |
| uid: inode.uid, | |
| gid: inode.gid, | |
| modified: inode.modified, | |
| }]), | |
| InodeKind::Symlink { .. } => Ok(vec![LsEntry { | |
| name: path.unwrap_or("").to_string(), | |
| is_dir: false, | |
| is_symlink: true, | |
| size: inode.size(), | |
| mode: inode.mode, | |
| uid: inode.uid, | |
| gid: inode.gid, | |
| modified: inode.modified, | |
| }]), | |
| } | |
| } | |
| pub fn cd(&mut self, path: &str) -> Result<(), VfsError> { | |
| if path == "/" { | |
| self.cwd = self.root; | |
| self.cwd_path.clear(); | |
| return Ok(()); | |
| } | |
| let target = self.resolve_path(path)?; | |
| let inode = self.get_inode(target)?; | |
| if !inode.is_dir() { | |
| return Err(VfsError::NotDirectory { | |
| path: path.to_string(), | |
| }); | |
| } | |
| if path.starts_with('/') { | |
| self.cwd_path.clear(); | |
| } | |
| for component in path.split('/').filter(|s| !s.is_empty()) { | |
| match component { | |
| "." => {} | |
| ".." => { | |
| self.cwd_path.pop(); | |
| } | |
| name => { | |
| let current_dir = self | |
| .cwd_path | |
| .last() | |
| .map(|(_, id)| *id) | |
| .unwrap_or(self.root); | |
| let entries = self.dir_entries(current_dir)?; | |
| if let Some(&child_id) = entries.get(name) { | |
| self.cwd_path.push((name.to_string(), child_id)); | |
| } | |
| } | |
| } | |
| } | |
| self.cwd = target; | |
| Ok(()) | |
| } | |
| pub fn mkdir(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { | |
| let (parent_id, name) = self.resolve_parent(path)?; | |
| let entries = self.dir_entries(parent_id)?; | |
| if entries.contains_key(&name) { | |
| return Err(VfsError::AlreadyExists { | |
| path: path.to_string(), | |
| }); | |
| } | |
| let effective_gid = self.effective_gid(parent_id, gid); | |
| let new_id = self.alloc_id(); | |
| let mut new_dir = Inode::new_dir(new_id, uid, effective_gid); | |
| // Inherit setgid bit from parent | |
| if let Ok(parent) = self.get_inode(parent_id) { | |
| if has_setgid(parent.mode) { | |
| new_dir.mode |= 0o2000; | |
| } | |
| } | |
| self.inodes.insert(new_id, new_dir); | |
| self.dir_entries_mut(parent_id)?.insert(name, new_id); | |
| let parent = self.get_inode_mut(parent_id)?; | |
| parent.nlink += 1; | |
| parent.touch_change(); | |
| Ok(()) | |
| } | |
| pub fn mkdir_p(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { | |
| let (start, components) = self.parse_path(path); | |
| let mut current = start; | |
| for component in &components { | |
| match component.as_str() { | |
| "." => {} | |
| ".." => { | |
| current = self.parent_of(current); | |
| } | |
| name => { | |
| let entries = self.dir_entries(current)?; | |
| if let Some(&existing) = entries.get(name) { | |
| if !self.get_inode(existing)?.is_dir() { | |
| return Err(VfsError::NotDirectory { | |
| path: name.to_string(), | |
| }); | |
| } | |
| current = existing; | |
| } else { | |
| let effective_gid = self.effective_gid(current, gid); | |
| let new_id = self.alloc_id(); | |
| let new_dir = Inode::new_dir(new_id, uid, effective_gid); | |
| self.inodes.insert(new_id, new_dir); | |
| self.dir_entries_mut(current)? | |
| .insert(name.to_string(), new_id); | |
| let parent = self.get_inode_mut(current)?; | |
| parent.nlink += 1; | |
| parent.touch_change(); | |
| current = new_id; | |
| } | |
| } | |
| } | |
| } | |
| Ok(()) | |
| } | |
| pub fn touch(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { | |
| self.validate_regular_path(path)?; | |
| if let Ok(id) = self.resolve_path(path) { | |
| let inode = self.get_inode_mut(id)?; | |
| inode.touch_modify(); | |
| return Ok(()); | |
| } | |
| let (parent_id, name) = self.resolve_parent(path)?; | |
| let effective_gid = self.effective_gid(parent_id, gid); | |
| let new_id = self.alloc_id(); | |
| let new_file = Inode::new_file(new_id, uid, effective_gid); | |
| self.inodes.insert(new_id, new_file); | |
| self.dir_entries_mut(parent_id)?.insert(name, new_id); | |
| Ok(()) | |
| } | |
| pub fn create_file( | |
| &mut self, | |
| path: &str, | |
| uid: Uid, | |
| gid: Gid, | |
| mode: Option<u16>, | |
| ) -> Result<InodeId, VfsError> { | |
| self.validate_regular_path(path)?; | |
| if self.resolve_path(path).is_ok() { | |
| return Err(VfsError::AlreadyExists { | |
| path: path.to_string(), | |
| }); | |
| } | |
| let (parent_id, name) = self.resolve_parent(path)?; | |
| let effective_gid = self.effective_gid(parent_id, gid); | |
| let new_id = self.alloc_id(); | |
| let mut new_file = Inode::new_file(new_id, uid, effective_gid); | |
| if let Some(mode) = mode { | |
| new_file.mode = mode; | |
| } | |
| self.inodes.insert(new_id, new_file); | |
| self.dir_entries_mut(parent_id)?.insert(name, new_id); | |
| Ok(new_id) | |
| } | |
| pub fn cat(&self, path: &str) -> Result<&[u8], VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode(id)?; | |
| match &inode.kind { | |
| InodeKind::File { content } => Ok(content), | |
| InodeKind::Directory { .. } => Err(VfsError::IsDirectory { | |
| path: path.to_string(), | |
| }), | |
| InodeKind::Symlink { target } => self.cat(target), | |
| } | |
| } | |
| pub fn cat_owned(&self, path: &str) -> Result<Vec<u8>, VfsError> { | |
| self.cat(path).map(|b| b.to_vec()) | |
| } | |
| pub fn write_file(&mut self, path: &str, content: Vec<u8>) -> Result<(), VfsError> { | |
| self.validate_regular_path(path)?; | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode_mut(id)?; | |
| match &mut inode.kind { | |
| InodeKind::File { content: c, .. } => { | |
| *c = content; | |
| inode.touch_modify(); | |
| Ok(()) | |
| } | |
| InodeKind::Directory { .. } => Err(VfsError::IsDirectory { | |
| path: path.to_string(), | |
| }), | |
| InodeKind::Symlink { target } => { | |
| let target = target.clone(); | |
| self.write_file(&target, content) | |
| } | |
| } | |
| } | |
| pub fn read_file_at( | |
| &mut self, | |
| path: &str, | |
| offset: usize, | |
| size: usize, | |
| ) -> Result<Vec<u8>, VfsError> { | |
| let id = self.resolve_path(path)?; | |
| self.read_inode_at(id, offset, size) | |
| } | |
| pub fn write_file_at( | |
| &mut self, | |
| path: &str, | |
| offset: usize, | |
| data: &[u8], | |
| ) -> Result<usize, VfsError> { | |
| self.validate_regular_path(path)?; | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode_mut(id)?; | |
| match &mut inode.kind { | |
| InodeKind::File { content } => { | |
| if offset > content.len() { | |
| content.resize(offset, 0); | |
| } | |
| let end = offset.saturating_add(data.len()); | |
| if end > content.len() { | |
| content.resize(end, 0); | |
| } | |
| content[offset..end].copy_from_slice(data); | |
| inode.touch_modify(); | |
| Ok(data.len()) | |
| } | |
| InodeKind::Directory { .. } => Err(VfsError::IsDirectory { | |
| path: path.to_string(), | |
| }), | |
| InodeKind::Symlink { target } => { | |
| let target = target.clone(); | |
| self.write_file_at(&target, offset, data) | |
| } | |
| } | |
| } | |
| pub fn truncate(&mut self, path: &str, size: usize) -> Result<(), VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode_mut(id)?; | |
| match &mut inode.kind { | |
| InodeKind::File { content } => { | |
| content.resize(size, 0); | |
| inode.touch_modify(); | |
| Ok(()) | |
| } | |
| InodeKind::Directory { .. } => Err(VfsError::IsDirectory { | |
| path: path.to_string(), | |
| }), | |
| InodeKind::Symlink { target } => { | |
| let target = target.clone(); | |
| self.truncate(&target, size) | |
| } | |
| } | |
| } | |
| pub fn rm(&mut self, path: &str) -> Result<(), VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode(id)?; | |
| if inode.is_dir() { | |
| return Err(VfsError::IsDirectory { | |
| path: path.to_string(), | |
| }); | |
| } | |
| let (parent_id, name) = self.resolve_parent(path)?; | |
| self.dir_entries_mut(parent_id)?.remove(&name); | |
| let inode = self.get_inode_mut(id)?; | |
| inode.nlink = inode.nlink.saturating_sub(1); | |
| inode.touch_change(); | |
| self.unlink_inode_if_needed(id); | |
| Ok(()) | |
| } | |
| pub fn rmdir(&mut self, path: &str) -> Result<(), VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode(id)?; | |
| match &inode.kind { | |
| InodeKind::Directory { entries } => { | |
| if !entries.is_empty() { | |
| return Err(VfsError::NotEmpty { | |
| path: path.to_string(), | |
| }); | |
| } | |
| } | |
| _ => { | |
| return Err(VfsError::NotDirectory { | |
| path: path.to_string(), | |
| }) | |
| } | |
| } | |
| if id == self.root { | |
| return Err(VfsError::InvalidPath { | |
| path: "cannot remove root".to_string(), | |
| }); | |
| } | |
| let (parent_id, name) = self.resolve_parent(path)?; | |
| self.dir_entries_mut(parent_id)?.remove(&name); | |
| let parent = self.get_inode_mut(parent_id)?; | |
| parent.nlink = parent.nlink.saturating_sub(1); | |
| parent.touch_change(); | |
| self.inodes.remove(&id); | |
| Ok(()) | |
| } | |
| pub fn rm_rf(&mut self, path: &str) -> Result<(), VfsError> { | |
| let id = self.resolve_path(path)?; | |
| if id == self.root { | |
| return Err(VfsError::InvalidPath { | |
| path: "cannot remove root".to_string(), | |
| }); | |
| } | |
| let (parent_id, name) = self.resolve_parent(path)?; | |
| self.dir_entries_mut(parent_id)?.remove(&name); | |
| self.remove_inode_recursive(id)?; | |
| let parent = self.get_inode_mut(parent_id)?; | |
| parent.touch_change(); | |
| Ok(()) | |
| } | |
| fn remove_inode_recursive(&mut self, id: InodeId) -> Result<(), VfsError> { | |
| let child_entries = match &self.get_inode(id)?.kind { | |
| InodeKind::Directory { entries } => entries.values().copied().collect::<Vec<_>>(), | |
| _ => Vec::new(), | |
| }; | |
| for child_id in child_entries { | |
| self.remove_inode_recursive(child_id)?; | |
| } | |
| let nlink = self.get_inode(id)?.nlink; | |
| if nlink > 0 { | |
| let inode = self.get_inode_mut(id)?; | |
| inode.nlink = inode.nlink.saturating_sub(1); | |
| inode.touch_change(); | |
| } | |
| self.unlink_inode_if_needed(id); | |
| Ok(()) | |
| } | |
| pub fn mv(&mut self, src: &str, dst: &str) -> Result<(), VfsError> { | |
| self.rename(src, dst) | |
| } | |
| pub fn rename(&mut self, src: &str, dst: &str) -> Result<(), VfsError> { | |
| let src_id = self.resolve_path(src)?; | |
| if src_id == self.root { | |
| return Err(VfsError::InvalidPath { | |
| path: "cannot rename root".to_string(), | |
| }); | |
| } | |
| if self.get_inode(src_id)?.is_file() { | |
| let dst_name = dst.rsplit('/').next().unwrap_or(dst); | |
| self.validate_regular_path(dst_name)?; | |
| } | |
| let (src_parent, src_name) = self.resolve_parent(src)?; | |
| if let Ok(dst_id) = self.resolve_path(dst) { | |
| if self.get_inode(dst_id)?.is_dir() { | |
| self.dir_entries_mut(src_parent)?.remove(&src_name); | |
| self.dir_entries_mut(dst_id)?.insert(src_name, src_id); | |
| let inode = self.get_inode_mut(src_id)?; | |
| inode.touch_change(); | |
| return Ok(()); | |
| } else { | |
| self.rm(dst)?; | |
| } | |
| } | |
| let (dst_parent, dst_name) = self.resolve_parent(dst)?; | |
| self.dir_entries_mut(src_parent)?.remove(&src_name); | |
| self.dir_entries_mut(dst_parent)?.insert(dst_name, src_id); | |
| let inode = self.get_inode_mut(src_id)?; | |
| inode.touch_change(); | |
| Ok(()) | |
| } | |
| pub fn cp(&mut self, src: &str, dst: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { | |
| let src_id = self.resolve_path(src)?; | |
| let src_inode = self.get_inode(src_id)?.clone(); | |
| if src_inode.is_dir() { | |
| return Err(VfsError::IsDirectory { | |
| path: src.to_string(), | |
| }); | |
| } | |
| let dst_name = dst.rsplit('/').next().unwrap_or(dst); | |
| if src_inode.is_file() { | |
| self.validate_regular_path(dst_name)?; | |
| } | |
| let (parent_id, name) = if let Ok(dst_id) = self.resolve_path(dst) { | |
| if self.get_inode(dst_id)?.is_dir() { | |
| let src_name = src.rsplit('/').next().unwrap_or(src); | |
| (dst_id, src_name.to_string()) | |
| } else { | |
| self.resolve_parent(dst)? | |
| } | |
| } else { | |
| self.resolve_parent(dst)? | |
| }; | |
| let new_id = self.alloc_id(); | |
| let mut new_inode = src_inode; | |
| new_inode.id = new_id; | |
| new_inode.uid = uid; // Copy is owned by the caller | |
| new_inode.gid = self.effective_gid(parent_id, gid); | |
| new_inode.nlink = 1; | |
| new_inode.touch_change(); | |
| self.inodes.insert(new_id, new_inode); | |
| self.dir_entries_mut(parent_id)?.insert(name, new_id); | |
| Ok(()) | |
| } | |
| pub fn stat(&self, path: &str) -> Result<StatInfo, VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode(id)?; | |
| Ok(StatInfo { | |
| inode_id: id, | |
| kind: match &inode.kind { | |
| InodeKind::File { .. } => "file", | |
| InodeKind::Directory { .. } => "directory", | |
| InodeKind::Symlink { .. } => "symlink", | |
| }, | |
| size: inode.size(), | |
| mode: inode.mode, | |
| uid: inode.uid, | |
| gid: inode.gid, | |
| nlink: inode.nlink, | |
| block_size: inode.block_size, | |
| blocks: inode.blocks(), | |
| created: inode.created_at.secs, | |
| modified: inode.modified_at.secs, | |
| accessed: inode.accessed_at.secs, | |
| changed: inode.changed_at.secs, | |
| created_nanos: inode.created_at.nanos, | |
| modified_nanos: inode.modified_at.nanos, | |
| accessed_nanos: inode.accessed_at.nanos, | |
| changed_nanos: inode.changed_at.nanos, | |
| }) | |
| } | |
| pub fn chmod(&mut self, path: &str, mode: u16) -> Result<(), VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode_mut(id)?; | |
| inode.mode = mode; | |
| inode.touch_change(); | |
| Ok(()) | |
| } | |
| pub fn chown(&mut self, path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode_mut(id)?; | |
| inode.uid = uid; | |
| inode.gid = gid; | |
| inode.touch_change(); | |
| Ok(()) | |
| } | |
| pub fn ln_s(&mut self, target: &str, link_path: &str, uid: Uid, gid: Gid) -> Result<(), VfsError> { | |
| let (parent_id, name) = self.resolve_parent(link_path)?; | |
| let entries = self.dir_entries(parent_id)?; | |
| if entries.contains_key(&name) { | |
| return Err(VfsError::AlreadyExists { | |
| path: link_path.to_string(), | |
| }); | |
| } | |
| let effective_gid = self.effective_gid(parent_id, gid); | |
| let new_id = self.alloc_id(); | |
| let symlink = Inode::new_symlink(new_id, target.to_string(), uid, effective_gid); | |
| self.inodes.insert(new_id, symlink); | |
| self.dir_entries_mut(parent_id)?.insert(name, new_id); | |
| Ok(()) | |
| } | |
| pub fn link(&mut self, target: &str, link_path: &str) -> Result<(), VfsError> { | |
| let target_id = self.resolve_path(target)?; | |
| if self.get_inode(target_id)?.is_dir() { | |
| return Err(VfsError::NotSupported { | |
| message: "hard links to directories".to_string(), | |
| }); | |
| } | |
| let (parent_id, name) = self.resolve_parent(link_path)?; | |
| if self.dir_entries(parent_id)?.contains_key(&name) { | |
| return Err(VfsError::AlreadyExists { | |
| path: link_path.to_string(), | |
| }); | |
| } | |
| self.dir_entries_mut(parent_id)?.insert(name, target_id); | |
| let inode = self.get_inode_mut(target_id)?; | |
| inode.nlink += 1; | |
| inode.touch_change(); | |
| Ok(()) | |
| } | |
| pub fn readlink(&self, path: &str) -> Result<String, VfsError> { | |
| let id = self.resolve_path(path)?; | |
| let inode = self.get_inode(id)?; | |
| match &inode.kind { | |
| InodeKind::Symlink { target } => Ok(target.clone()), | |
| _ => Err(VfsError::InvalidArgs { | |
| message: format!("not a symlink: {path}"), | |
| }), | |
| } | |
| } | |
| pub fn open(&mut self, path: &str, writable: bool) -> Result<HandleId, VfsError> { | |
| let inode_id = self.resolve_path(path)?; | |
| let directory = self.get_inode(inode_id)?.is_dir(); | |
| let handle_id = self.alloc_handle_id(); | |
| self.handles.insert( | |
| handle_id, | |
| OpenHandle { | |
| inode_id, | |
| cursor: 0, | |
| writable, | |
| directory, | |
| }, | |
| ); | |
| self.mark_accessed(inode_id)?; | |
| Ok(handle_id) | |
| } | |
| pub fn opendir(&mut self, path: &str) -> Result<HandleId, VfsError> { | |
| let inode_id = self.resolve_path(path)?; | |
| if !self.get_inode(inode_id)?.is_dir() { | |
| return Err(VfsError::NotDirectory { | |
| path: path.to_string(), | |
| }); | |
| } | |
| self.open(path, false) | |
| } | |
| pub fn release_handle(&mut self, handle: HandleId) -> Result<(), VfsError> { | |
| let open = self | |
| .handles | |
| .remove(&handle) | |
| .ok_or(VfsError::InvalidHandle { handle })?; | |
| self.release_orphan_if_needed(open.inode_id); | |
| Ok(()) | |
| } | |
| pub fn read_handle(&mut self, handle: HandleId, size: usize) -> Result<Vec<u8>, VfsError> { | |
| let (inode_id, cursor) = { | |
| let open = self | |
| .handles | |
| .get(&handle) | |
| .ok_or(VfsError::InvalidHandle { handle })?; | |
| if open.directory { | |
| return Err(VfsError::IsDirectory { | |
| path: format!("<handle {handle}>"), | |
| }); | |
| } | |
| (open.inode_id, open.cursor) | |
| }; | |
| let data = self.read_inode_at(inode_id, cursor, size)?; | |
| if let Some(open) = self.handles.get_mut(&handle) { | |
| open.cursor = open.cursor.saturating_add(data.len()); | |
| } | |
| Ok(data) | |
| } | |
| pub fn write_handle(&mut self, handle: HandleId, data: &[u8]) -> Result<usize, VfsError> { | |
| let (inode_id, cursor, writable) = { | |
| let open = self | |
| .handles | |
| .get(&handle) | |
| .ok_or(VfsError::InvalidHandle { handle })?; | |
| (open.inode_id, open.cursor, open.writable) | |
| }; | |
| if !writable { | |
| return Err(VfsError::PermissionDenied { | |
| path: format!("<handle {handle}>"), | |
| }); | |
| } | |
| let written = self.write_inode_at(inode_id, cursor, data)?; | |
| if let Some(open) = self.handles.get_mut(&handle) { | |
| open.cursor = open.cursor.saturating_add(written); | |
| } | |
| Ok(written) | |
| } | |
| fn write_inode_at( | |
| &mut self, | |
| inode_id: InodeId, | |
| offset: usize, | |
| data: &[u8], | |
| ) -> Result<usize, VfsError> { | |
| let inode = self.get_inode_mut(inode_id)?; | |
| match &mut inode.kind { | |
| InodeKind::File { content } => { | |
| if offset > content.len() { | |
| content.resize(offset, 0); | |
| } | |
| let end = offset.saturating_add(data.len()); | |
| if end > content.len() { | |
| content.resize(end, 0); | |
| } | |
| content[offset..end].copy_from_slice(data); | |
| inode.touch_modify(); | |
| Ok(data.len()) | |
| } | |
| InodeKind::Directory { .. } => Err(VfsError::IsDirectory { | |
| path: format!("<inode {inode_id}>"), | |
| }), | |
| InodeKind::Symlink { target } => { | |
| let target = target.clone(); | |
| self.write_file_at(&target, offset, data) | |
| } | |
| } | |
| } | |
| fn read_inode_at( | |
| &mut self, | |
| inode_id: InodeId, | |
| offset: usize, | |
| size: usize, | |
| ) -> Result<Vec<u8>, VfsError> { | |
| let inode = self.get_inode_mut(inode_id)?; | |
| match &mut inode.kind { | |
| InodeKind::File { content } => { | |
| let start = offset.min(content.len()); | |
| let end = start.saturating_add(size).min(content.len()); | |
| let out = content[start..end].to_vec(); | |
| inode.touch_access(); | |
| Ok(out) | |
| } | |
| InodeKind::Directory { .. } => Err(VfsError::IsDirectory { | |
| path: format!("<inode {inode_id}>"), | |
| }), | |
| InodeKind::Symlink { target } => { | |
| let target = target.clone(); | |
| self.read_file_at(&target, offset, size) | |
| } | |
| } | |
| } | |
| pub fn tree( | |
| &self, | |
| path: Option<&str>, | |
| prefix: &str, | |
| session: Option<&Session>, | |
| ) -> Result<String, VfsError> { | |
| let id = match path { | |
| Some(p) => self.resolve_path(p)?, | |
| None => self.cwd, | |
| }; | |
| let mut output = String::new(); | |
| if prefix.is_empty() { | |
| output.push_str(".\n"); | |
| } | |
| self.tree_recursive(id, prefix, &mut output, session)?; | |
| Ok(output) | |
| } | |
| fn tree_recursive( | |
| &self, | |
| id: InodeId, | |
| prefix: &str, | |
| output: &mut String, | |
| session: Option<&Session>, | |
| ) -> Result<(), VfsError> { | |
| let entries = self.dir_entries(id)?; | |
| // Filter entries by read permission | |
| let visible: Vec<_> = entries | |
| .iter() | |
| .filter(|&(_, child_id)| self.is_visible(*child_id, session)) | |
| .collect(); | |
| let count = visible.len(); | |
| for (i, (name, child_id)) in visible.iter().enumerate() { | |
| let is_last = i == count - 1; | |
| let connector = if is_last { | |
| "\u{2514}\u{2500}\u{2500} " | |
| } else { | |
| "\u{251c}\u{2500}\u{2500} " | |
| }; | |
| let child = self.get_inode(**child_id)?; | |
| output.push_str(prefix); | |
| output.push_str(connector); | |
| output.push_str(name); | |
| if child.is_dir() { | |
| output.push('/'); | |
| } | |
| output.push('\n'); | |
| if child.is_dir() { | |
| let new_prefix = if is_last { | |
| format!("{prefix} ") | |
| } else { | |
| format!("{prefix}\u{2502} ") | |
| }; | |
| self.tree_recursive(**child_id, &new_prefix, output, session)?; | |
| } | |
| } | |
| Ok(()) | |
| } | |
| pub fn find( | |
| &self, | |
| path: Option<&str>, | |
| pattern: Option<&str>, | |
| session: Option<&Session>, | |
| ) -> Result<Vec<String>, VfsError> { | |
| let id = match path { | |
| Some(p) => self.resolve_path(p)?, | |
| None => self.cwd, | |
| }; | |
| let base = match path { | |
| Some(p) => p.to_string(), | |
| None => ".".to_string(), | |
| }; | |
| let mut results = Vec::new(); | |
| self.find_recursive(id, &base, pattern, &mut results, session)?; | |
| Ok(results) | |
| } | |
| fn find_recursive( | |
| &self, | |
| id: InodeId, | |
| current_path: &str, | |
| pattern: Option<&str>, | |
| results: &mut Vec<String>, | |
| session: Option<&Session>, | |
| ) -> Result<(), VfsError> { | |
| let entries = self.dir_entries(id)?; | |
| for (name, &child_id) in entries { | |
| if !self.is_visible(child_id, session) { | |
| continue; | |
| } | |
| let child_path = if current_path == "." { | |
| format!("./{name}") | |
| } else { | |
| format!("{current_path}/{name}") | |
| }; | |
| let child = self.get_inode(child_id)?; | |
| let matches = match pattern { | |
| Some(pat) => glob_match(pat, name), | |
| None => true, | |
| }; | |
| if matches { | |
| results.push(child_path.clone()); | |
| } | |
| if child.is_dir() { | |
| self.find_recursive(child_id, &child_path, pattern, results, session)?; | |
| } | |
| } | |
| Ok(()) | |
| } | |
| pub fn grep( | |
| &self, | |
| pattern: &str, | |
| path: Option<&str>, | |
| recursive: bool, | |
| session: Option<&Session>, | |
| ) -> Result<Vec<GrepResult>, VfsError> { | |
| let re = regex::Regex::new(pattern).map_err(|e| VfsError::InvalidArgs { | |
| message: format!("invalid regex: {e}"), | |
| })?; | |
| let id = match path { | |
| Some(p) => self.resolve_path(p)?, | |
| None => self.cwd, | |
| }; | |
| let base = path.unwrap_or("."); | |
| let mut results = Vec::new(); | |
| let inode = self.get_inode(id)?; | |
| match &inode.kind { | |
| InodeKind::File { content } => { | |
| let text = String::from_utf8_lossy(content); | |
| for (line_num, line) in text.lines().enumerate() { | |
| if re.is_match(line) { | |
| results.push(GrepResult { | |
| file: base.to_string(), | |
| line_num: line_num + 1, | |
| line: line.to_string(), | |
| }); | |
| } | |
| } | |
| } | |
| InodeKind::Directory { .. } if recursive => { | |
| self.grep_recursive(id, base, &re, &mut results, session)?; | |
| } | |
| InodeKind::Directory { .. } => { | |
| return Err(VfsError::IsDirectory { | |
| path: base.to_string(), | |
| }); | |
| } | |
| _ => {} | |
| } | |
| Ok(results) | |
| } | |
| fn grep_recursive( | |
| &self, | |
| id: InodeId, | |
| base: &str, | |
| re: ®ex::Regex, | |
| results: &mut Vec<GrepResult>, | |
| session: Option<&Session>, | |
| ) -> Result<(), VfsError> { | |
| let entries = self.dir_entries(id)?; | |
| for (name, &child_id) in entries { | |
| if !self.is_visible(child_id, session) { | |
| continue; | |
| } | |
| let child_path = if base == "." { | |
| format!("./{name}") | |
| } else { | |
| format!("{base}/{name}") | |
| }; | |
| let child = self.get_inode(child_id)?; | |
| match &child.kind { | |
| InodeKind::File { content } => { | |
| let text = String::from_utf8_lossy(content); | |
| for (line_num, line) in text.lines().enumerate() { | |
| if re.is_match(line) { | |
| results.push(GrepResult { | |
| file: child_path.clone(), | |
| line_num: line_num + 1, | |
| line: line.to_string(), | |
| }); | |
| } | |
| } | |
| } | |
| InodeKind::Directory { .. } => { | |
| self.grep_recursive(child_id, &child_path, re, results, session)?; | |
| } | |
| _ => {} | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Check if a user can see this inode (has read permission). | |
| /// If session is None, everything is visible (internal/root use). | |
| /// Respects delegation: both principal and delegate must have read access. | |
| fn is_visible(&self, id: InodeId, session: Option<&Session>) -> bool { | |
| let session = match session { | |
| Some(s) => s, | |
| None => return true, | |
| }; | |
| if session.can_read_anywhere() { | |
| return self.get_inode(id).is_ok(); | |
| } | |
| let inode = match self.get_inode(id) { | |
| Ok(i) => i, | |
| Err(_) => return false, | |
| }; | |
| session.has_permission(inode, Access::Read) | |
| } | |
| pub fn all_inodes(&self) -> &HashMap<InodeId, Inode> { | |
| &self.inodes | |
| } | |
| pub fn root_id(&self) -> InodeId { | |
| self.root | |
| } | |
| pub fn cwd_id(&self) -> InodeId { | |
| self.cwd | |
| } | |
| pub fn next_inode_id(&self) -> InodeId { | |
| self.next_id | |
| } | |
| pub fn cwd_path_clone(&self) -> Vec<(String, InodeId)> { | |
| self.cwd_path.clone() | |
| } | |
| pub fn from_persisted( | |
| inodes: HashMap<InodeId, Inode>, | |
| root: InodeId, | |
| cwd: InodeId, | |
| next_id: InodeId, | |
| cwd_path: Vec<(String, InodeId)>, | |
| options: FsOptions, | |
| registry: UserRegistry, | |
| ) -> Self { | |
| VirtualFs { | |
| inodes, | |
| root, | |
| cwd, | |
| next_id, | |
| cwd_path, | |
| options, | |
| next_handle: 1, | |
| handles: HashMap::new(), | |
| pending_delete: HashSet::new(), | |
| registry, | |
| } | |
| } | |
| } | |
| fn glob_match(pattern: &str, name: &str) -> bool { | |
| let pat = pattern | |
| .replace('.', "\\.") | |
| .replace('*', ".*") | |
| .replace('?', "."); | |
| regex::Regex::new(&format!("^{pat}$")) | |
| .map(|re| re.is_match(name)) | |
| .unwrap_or(false) | |
| } | |
| fn validate_markdown_filename(path: &str) -> Result<(), VfsError> { | |
| let filename = path.rsplit('/').next().unwrap_or(path); | |
| if !filename.ends_with(".md") { | |
| return Err(VfsError::InvalidExtension { | |
| name: filename.to_string(), | |
| }); | |
| } | |
| Ok(()) | |
| } | |
| pub struct LsEntry { | |
| pub name: String, | |
| pub is_dir: bool, | |
| pub is_symlink: bool, | |
| pub size: u64, | |
| pub mode: u16, | |
| pub uid: u32, | |
| pub gid: u32, | |
| pub modified: u64, | |
| } | |
| pub struct StatInfo { | |
| pub inode_id: InodeId, | |
| pub kind: &'static str, | |
| pub size: u64, | |
| pub mode: u16, | |
| pub uid: u32, | |
| pub gid: u32, | |
| pub nlink: u64, | |
| pub block_size: u64, | |
| pub blocks: u64, | |
| pub created: u64, | |
| pub modified: u64, | |
| pub accessed: u64, | |
| pub changed: u64, | |
| pub created_nanos: u32, | |
| pub modified_nanos: u32, | |
| pub accessed_nanos: u32, | |
| pub changed_nanos: u32, | |
| } | |
| pub struct GrepResult { | |
| pub file: String, | |
| pub line_num: usize, | |
| pub line: String, | |
| } | |