diff --git a/litebox/src/broker/mod.rs b/litebox/src/broker/mod.rs index cbcdc47d7c..467c1bc608 100644 --- a/litebox/src/broker/mod.rs +++ b/litebox/src/broker/mod.rs @@ -516,6 +516,13 @@ pub(crate) trait BrokerControl: Send + Sync { user: FileUser, ) -> core::result::Result, BrokerControlError>; + fn rename_file( + &self, + old_path: &str, + new_path: &str, + user: FileUser, + ) -> core::result::Result, BrokerControlError>; + fn mkdir_file( &self, path: &str, @@ -1580,6 +1587,21 @@ where self.request(|local| local.unlink_file(lease.sequence(), path, user)) } + fn rename_file( + &self, + old_path: &str, + new_path: &str, + user: FileUser, + ) -> core::result::Result, BrokerControlError> { + let length = old_path + .len() + .checked_add(new_path.len()) + .filter(|&length| length <= SHARED_BUFFER_SLOT_SIZE as usize) + .ok_or(BrokerControlError::Broker(ErrorCode::ResourceExhausted))?; + let lease = self.acquire_shared_buffer(length)?; + self.request(|local| local.rename_file(lease.sequence(), old_path, new_path, user)) + } + fn mkdir_file( &self, path: &str, diff --git a/litebox/src/fs/errors.rs b/litebox/src/fs/errors.rs index 3bb03c3af8..8c34184d1c 100644 --- a/litebox/src/fs/errors.rs +++ b/litebox/src/fs/errors.rs @@ -181,6 +181,32 @@ pub enum UnlinkError { PathError(#[from] PathError), } +/// Possible errors from [`crate::LiteBox::rename_file`]. +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum RenameError { + #[error("the parent directory does not allow write permission")] + NoWritePerms, + #[error("the path is in use by the system")] + Busy, + #[error("paths are on different filesystems")] + CrossDevice, + #[error("invalid rename argument")] + InvalidArgument, + #[error("the destination is a directory but the source is not")] + IsADirectory, + #[error("the source is a directory but the destination is not")] + NotADirectory, + #[error("the destination directory is not empty")] + NotEmpty, + #[error("the named file resides on a read-only filesystem")] + ReadOnlyFileSystem, + #[error("I/O error")] + Io, + #[error(transparent)] + PathError(#[from] PathError), +} + /// Possible errors from [`crate::LiteBox::mkdir_file`]. #[non_exhaustive] #[derive(Error, Debug)] diff --git a/litebox/src/fs/file.rs b/litebox/src/fs/file.rs index 9d97f40310..3ad405dd9b 100644 --- a/litebox/src/fs/file.rs +++ b/litebox/src/fs/file.rs @@ -30,8 +30,8 @@ use litebox_platform::buffer::{Buffer, ReadBuffer, WriteBuffer}; use super::errors::{ ChmodError, ChownError, FilePathError, FileStatusError, IsTerminalError, MkdirError, OpenError, - PathError, ReadDirError, ReadError, RmdirError, SeekError, StatusFlagsError, SymlinkError, - TerminalControlError, TruncateError, UnlinkError, WalkError, WriteError, + PathError, ReadDirError, ReadError, RenameError, RmdirError, SeekError, StatusFlagsError, + SymlinkError, TerminalControlError, TruncateError, UnlinkError, WalkError, WriteError, }; impl LiteBox { @@ -140,6 +140,40 @@ impl LiteBox .map_err(UnlinkError::from) } + /// Rename a file or directory, replacing an existing destination when permitted. + pub fn rename_file( + &self, + context: &Context, + old_path: &str, + new_path: &str, + ) -> Result<(), RenameError> { + if old_path.is_empty() || new_path.is_empty() { + return Err(PathError::NoSuchFileOrDirectory.into()); + } + // Not normalized: dot components and trailing slashes are meaningful to rename. + let absolute_path = |path: &str| { + if path.starts_with('/') { + return String::from(path); + } + // XXX(jayb): cwd is a path snapshot and goes stale if an ancestor is renamed. + let mut absolute = context.cwd().to_string(); + if !absolute.ends_with('/') { + absolute.push('/'); + } + absolute.push_str(path); + absolute + }; + self.broker_control() + .ok_or(RenameError::Io)? + .rename_file( + &absolute_path(old_path), + &absolute_path(new_path), + context.acting_user(), + ) + .map_err(|_| RenameError::Io)? + .map_err(RenameError::from) + } + /// Create a new directory. pub fn mkdir_file(&self, context: &Context, path: &str, mode: Mode) -> Result<(), MkdirError> { let path = Self::broker_path(context, path); @@ -823,6 +857,25 @@ impl From for UnlinkError { } } +impl From for RenameError { + fn from(error: FileError) -> Self { + if let Some(error) = path_error(error) { + return error.into(); + } + match error { + FileError::NoWritePermissions => Self::NoWritePerms, + FileError::Busy => Self::Busy, + FileError::CrossDevice => Self::CrossDevice, + FileError::InvalidArgument => Self::InvalidArgument, + FileError::IsDirectory => Self::IsADirectory, + FileError::NotDirectory => Self::NotADirectory, + FileError::NotEmpty => Self::NotEmpty, + FileError::ReadOnlyFs => Self::ReadOnlyFileSystem, + _ => Self::Io, + } + } +} + impl From for MkdirError { fn from(error: FileError) -> Self { if let Some(error) = path_error(error) { diff --git a/litebox_broker_core/src/fs/backend.rs b/litebox_broker_core/src/fs/backend.rs index 902e75d7ea..44bfa6352c 100644 --- a/litebox_broker_core/src/fs/backend.rs +++ b/litebox_broker_core/src/fs/backend.rs @@ -14,7 +14,7 @@ use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, ReadDirError, ReadError, - RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, + RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, }; use super::{DirEntry, FileStatus, FileType, Mode, OFlags, UserInfo}; use crate::readiness::ReadinessSource; @@ -220,6 +220,17 @@ pub trait Backend: Send + Sync + Any { // XXX(jayb): I don't like that unlink and rmdir exist separately, we should probably merge them. fn rmdir_at(&self, dir: DirHandle, name: &str) -> Result<(), RmdirError>; + /// Atomically move an entry, replacing a compatible destination without following final + /// symbolic links. Backends must reject directory cycles and preserve open file contents. + /// Directory handles retain the backend's documented object or path-locator semantics. + fn rename_at( + &self, + old_dir: DirHandle, + old_name: &str, + new_dir: DirHandle, + new_name: &str, + ) -> Result<(), RenameError>; + /// Update the permissions for the file/dir `h` refers to. fn chmod(&self, h: HandleRef<'_>, mode: Mode) -> Result<(), ChmodError>; diff --git a/litebox_broker_core/src/fs/composer.rs b/litebox_broker_core/src/fs/composer.rs index 37eab6de87..07350f4e51 100644 --- a/litebox_broker_core/src/fs/composer.rs +++ b/litebox_broker_core/src/fs/composer.rs @@ -19,7 +19,8 @@ use super::backend::{ }; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, + ReadError, RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, + WriteError, }; use super::inode_allocator::{InodeAllocator, InodeAllocators}; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; @@ -866,6 +867,50 @@ impl Backend for Composer { } } + fn rename_at( + &self, + old_dir: DirHandle, + old_name: &str, + new_dir: DirHandle, + new_name: &str, + ) -> Result<(), RenameError> { + if [old_name, new_name].iter().any(|name| { + name.is_empty() || matches!(*name, "." | "..") || name.contains(['/', '\0']) + }) { + return Err(PathError::InvalidPathname.into()); + } + let old_dir = old_dir.into_typed::().inner; + let new_dir = new_dir.into_typed::().inner; + for (dir, name) in [(&old_dir, old_name), (&new_dir, new_name)] { + let path = match dir { + ComposerDirHandleInner::Virtual { path } + | ComposerDirHandleInner::Mounted { path, .. } => path, + }; + self.checked_child_path(path.clone(), name, RenameError::Busy)?; + } + match (old_dir, new_dir) { + ( + ComposerDirHandleInner::Mounted { + mount_index: old_mount, + handle: old_handle, + .. + }, + ComposerDirHandleInner::Mounted { + mount_index: new_mount, + handle: new_handle, + .. + }, + ) if old_mount == new_mount => self.mounts[old_mount] + .backend + .rename_at(old_handle, old_name, new_handle, new_name), + (ComposerDirHandleInner::Virtual { .. }, ComposerDirHandleInner::Virtual { .. }) => { + Err(RenameError::ReadOnlyFileSystem) + } + // Different mounts are EXDEV even if they share a backend device. + _ => Err(RenameError::CrossDevice), + } + } + fn chmod(&self, h: HandleRef<'_>, mode: Mode) -> Result<(), ChmodError> { match h { HandleRef::File(h) => { diff --git a/litebox_broker_core/src/fs/devices.rs b/litebox_broker_core/src/fs/devices.rs index 0de7092fbb..f5dcb85135 100644 --- a/litebox_broker_core/src/fs/devices.rs +++ b/litebox_broker_core/src/fs/devices.rs @@ -22,7 +22,8 @@ use super::backend::{ }; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, + ReadError, RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, + WriteError, }; use super::inode_allocator::InodeAllocator; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; @@ -619,6 +620,16 @@ impl Backend for Devices { Err(UnlinkError::ReadOnlyFileSystem) } + fn rename_at( + &self, + _old_dir: DirHandle, + _old_name: &str, + _new_dir: DirHandle, + _new_name: &str, + ) -> Result<(), RenameError> { + Err(RenameError::ReadOnlyFileSystem) + } + fn rmdir_at(&self, _dir: DirHandle, _name: &str) -> Result<(), RmdirError> { Err(RmdirError::ReadOnlyFileSystem) } diff --git a/litebox_broker_core/src/fs/errors.rs b/litebox_broker_core/src/fs/errors.rs index 0fdbd6c0ec..2b3937c838 100644 --- a/litebox_broker_core/src/fs/errors.rs +++ b/litebox_broker_core/src/fs/errors.rs @@ -186,6 +186,31 @@ pub enum SymlinkError { PathError(#[from] PathError), } +/// Possible errors from [`super::resolver::Resolver::rename`]. +#[derive(Error, Debug)] +pub enum RenameError { + #[error("a parent directory does not allow write permission")] + NoWritePerms, + #[error("the path is a root, mount point, or otherwise busy")] + Busy, + #[error("the paths cannot be renamed across filesystems")] + CrossDevice, + #[error("a directory cannot be moved into itself or its descendants")] + InvalidArgument, + #[error("a non-directory cannot replace a directory")] + IsADirectory, + #[error("a directory cannot replace a non-directory")] + NotADirectory, + #[error("the destination directory is not empty")] + NotEmpty, + #[error("the named file resides on a read-only filesystem")] + ReadOnlyFileSystem, + #[error("I/O error")] + Io, + #[error(transparent)] + PathError(#[from] PathError), +} + /// Possible errors from [`super::resolver::Resolver::mkdir`] #[derive(Error, Debug)] pub enum MkdirError { diff --git a/litebox_broker_core/src/fs/in_mem.rs b/litebox_broker_core/src/fs/in_mem.rs index 274567ee83..54d10c1736 100644 --- a/litebox_broker_core/src/fs/in_mem.rs +++ b/litebox_broker_core/src/fs/in_mem.rs @@ -13,7 +13,7 @@ use litebox_platform::sync; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SymlinkError, TruncateError, UnlinkError, WriteError, + ReadError, RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WriteError, }; use super::inode_allocator::InodeAllocator; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, UserInfo}; @@ -29,6 +29,9 @@ pub struct InMem { // sync-primitives platform, as well as cost of mutexes and such? root: DirNode, inode_allocator: InodeAllocator, + // XXX(jayb): serializes directory moves so ancestry stays stable while rename chooses + // ancestor-first locks; should be cleaned up as part of broader atomicity fixups. + rename_lock: sync::Mutex, } impl InMem { @@ -36,6 +39,7 @@ impl InMem { #[must_use] pub fn new(inode_allocator: InodeAllocator) -> Self { let root = Arc::new(sync::RwLock::new(DirData { + instance: Arc::new(()), perms: Permissions { mode: Mode::RWXU | Mode::RGRP | Mode::XGRP | Mode::ROTH | Mode::XOTH, userinfo: UserInfo::ROOT, @@ -46,6 +50,7 @@ impl InMem { Self { root, inode_allocator, + rename_lock: sync::Mutex::new(()), } } @@ -165,6 +170,7 @@ impl InMem { (Some(_), _) => panic!("{path:?} already exists with a different type"), (None, InitialNode::Directory { mode, owner }) => { let child = Arc::new(sync::RwLock::new(DirData { + instance: dir.instance.clone(), perms: Permissions { mode, userinfo: owner, @@ -208,6 +214,21 @@ impl InMem { } } + fn contains_dir(ancestor: &DirNode, target: &DirNode) -> bool { + let mut pending = alloc::vec![ancestor.clone()]; + while let Some(dir) = pending.pop() { + if Arc::ptr_eq(&dir, target) { + return true; + } + // Drop this read guard before visiting a descendant, or a rename's later write locks. + pending.extend(dir.read().children.values().filter_map(|node| match node { + Node::Dir(child) => Some(child.clone()), + Node::File(_) => None, + })); + } + false + } + /// Initialize a primarily read-heavy file with static data. /// /// While this function could technically work with write-heavy files, it has performance @@ -632,6 +653,7 @@ impl super::backend::Backend for InMe return Err(MkdirError::AlreadyExists); } let child = Arc::new(sync::RwLock::new(DirData { + instance: parent.instance.clone(), perms: Permissions { mode: metadata.mode, userinfo: metadata.owner, @@ -684,6 +706,103 @@ impl super::backend::Backend for InMe } } + fn rename_at( + &self, + old_dir: super::backend::DirHandle, + old_name: &str, + new_dir: super::backend::DirHandle, + new_name: &str, + ) -> Result<(), RenameError> { + for name in [old_name, new_name] { + if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\0']) { + return Err(PathError::InvalidPathname.into()); + } + } + // XXX(jayb): racing rmdir can detach a resolved parent, as for create and mkdir; fix all + // three together as part of broader atomicity fixups. + let old_dir = old_dir.into_typed::().dir; + let new_dir = new_dir.into_typed::().dir; + let _rename = self.rename_lock.lock(); + let instance = self.root.read().instance.clone(); + let old_instance = old_dir.read().instance.clone(); + let new_instance = new_dir.read().instance.clone(); + if !Arc::ptr_eq(&old_instance, &instance) || !Arc::ptr_eq(&new_instance, &instance) { + return Err(RenameError::Io); + } + let source = old_dir + .read() + .children + .get(old_name) + .cloned() + .ok_or(PathError::NoSuchFileOrDirectory)?; + let destination = new_dir.read().children.get(new_name).cloned(); + let same_parent = Arc::ptr_eq(&old_dir, &new_dir); + let new_above_old = !same_parent && Self::contains_dir(&new_dir, &old_dir); + let source_has_new = match &source { + Node::Dir(dir) => Self::contains_dir(dir, &new_dir), + Node::File(_) => false, + }; + let destination_has_old = match &destination { + Some(Node::Dir(dir)) if new_above_old => Self::contains_dir(dir, &old_dir), + _ => false, + }; + let replace = |old: &mut DirData, new: Option<&mut DirData>| { + let current = old + .children + .get(old_name) + .ok_or(PathError::NoSuchFileOrDirectory)?; + let target = match &new { + Some(new) => new.children.get(new_name), + None => old.children.get(new_name), + }; + if target.is_some_and(|target| current.ptr_eq(target)) { + return Ok(()); + } + // Ancestry was checked before locking, so it only holds for unchanged entries; a + // concurrently created replacement cannot contain either existing parent. + if source_has_new && current.ptr_eq(&source) { + return Err(RenameError::InvalidArgument); + } + if let Some(target @ Node::Dir(dir)) = target + && (Arc::ptr_eq(dir, &old_dir) + || (destination_has_old + && destination + .as_ref() + .is_some_and(|prior| target.ptr_eq(prior)))) + { + return Err(RenameError::NotEmpty); + } + match (current, target) { + (Node::Dir(_), Some(Node::File(_))) => return Err(RenameError::NotADirectory), + (Node::File(_), Some(Node::Dir(_))) => return Err(RenameError::IsADirectory), + (Node::Dir(_), Some(Node::Dir(dir))) if !dir.read().children.is_empty() => { + return Err(RenameError::NotEmpty); + } + _ => {} + } + let moved = old + .children + .remove(old_name) + .expect("source was checked under lock"); + match new { + Some(new) => new.children.insert(new_name.into(), moved), + None => old.children.insert(new_name.into(), moved), + }; + Ok(()) + }; + // XXX(jayb): nested directory locks are taken ancestor-first, with ancestry checked before + // either write lock; revisit as part of broader atomicity fixups. + if same_parent { + replace(&mut old_dir.write(), None) + } else if new_above_old { + let mut new = new_dir.write(); + replace(&mut old_dir.write(), Some(&mut new)) + } else { + let mut old = old_dir.write(); + replace(&mut old, Some(&mut new_dir.write())) + } + } + fn chmod(&self, h: super::backend::HandleRef<'_>, mode: Mode) -> Result<(), ChmodError> { let mut perms = match h { super::backend::HandleRef::File(h) => { @@ -779,6 +898,14 @@ impl Clone for Node { } impl Node { + fn ptr_eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Dir(left), Self::Dir(right)) => Arc::ptr_eq(left, right), + (Self::File(left), Self::File(right)) => Arc::ptr_eq(left, right), + _ => false, + } + } + /// The directory entry for this node, named `name` in its parent. fn dir_entry(&self, name: &str) -> DirEntry { let (file_type, node_info) = match self { @@ -798,6 +925,7 @@ impl Node { type DirNode = Arc>>; struct DirData { + instance: Arc<()>, perms: Permissions, children: HashMap>, node_info: NodeInfo, diff --git a/litebox_broker_core/src/fs/mod.rs b/litebox_broker_core/src/fs/mod.rs index 1f56b62f5c..7321182a82 100644 --- a/litebox_broker_core/src/fs/mod.rs +++ b/litebox_broker_core/src/fs/mod.rs @@ -37,8 +37,8 @@ pub(crate) use service::{ }; pub use service::{ FileResult, FileService, UnsupportedFileService, chmod, chown, handle_path, handle_status, - is_terminal, mkdir, open, path_status, read, read_directory, read_link, resolve_path, rmdir, - seek, symlink, terminal_control, truncate, unlink, write, + is_terminal, mkdir, open, path_status, read, read_directory, read_link, rename, resolve_path, + rmdir, seek, symlink, terminal_control, truncate, unlink, write, }; bitflags! { diff --git a/litebox_broker_core/src/fs/nine_p/client.rs b/litebox_broker_core/src/fs/nine_p/client.rs index 8d7b7c9768..d7f57c4d2f 100644 --- a/litebox_broker_core/src/fs/nine_p/client.rs +++ b/litebox_broker_core/src/fs/nine_p/client.rs @@ -693,7 +693,6 @@ impl Client { } /// Rename a file - #[expect(dead_code)] pub(super) fn rename( &self, fid: &Fid, diff --git a/litebox_broker_core/src/fs/nine_p/mod.rs b/litebox_broker_core/src/fs/nine_p/mod.rs index cf5dac61a2..0aaf69cc29 100644 --- a/litebox_broker_core/src/fs/nine_p/mod.rs +++ b/litebox_broker_core/src/fs/nine_p/mod.rs @@ -24,8 +24,8 @@ use super::backend::{ }; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SeekError, SymlinkError, TruncateError, UnlinkError, WalkError, - WriteError, + ReadError, RenameError, RmdirError, SeekError, SymlinkError, TruncateError, UnlinkError, + WalkError, WriteError, }; use litebox_platform::buffer::{Fault, ReadBuffer, WriteBuffer}; use litebox_platform::sync; @@ -612,6 +612,31 @@ where Ok(self.remove_at(&dir.into_typed::(), name, false)?) } + fn rename_at( + &self, + old_dir: DirHandle, + old_name: &str, + new_dir: DirHandle, + new_name: &str, + ) -> Result<(), RenameError> { + if [old_name, new_name].iter().any(|name| { + name.is_empty() || matches!(*name, "." | "..") || name.contains(['/', '\0']) + }) { + return Err(PathError::InvalidPathname.into()); + } + let old_dir = old_dir.into_typed::(); + let new_dir = new_dir.into_typed::(); + // Walk without opening, so a final symlink itself is renamed. + let source = self.own( + self.client + .walk(&old_dir.fid.fid, &[old_name])? + .into_complete_fid()?, + ); + Ok(self + .client + .rename(&source.fid, &new_dir.fid.fid, new_name)?) + } + fn chmod(&self, h: HandleRef<'_>, mode: super::Mode) -> Result<(), ChmodError> { let fid = match h { HandleRef::File(h) => &h.get_typed::().fid, @@ -825,11 +850,14 @@ fn rgetattr_to_file_status( const EPERM: u32 = 1; const ENOENT: u32 = 2; const EACCES: u32 = 13; +const EBUSY: u32 = 16; const EEXIST: u32 = 17; +const EXDEV: u32 = 18; const ENOTDIR: u32 = 20; const EISDIR: u32 = 21; const EINVAL: u32 = 22; const ESPIPE: u32 = 29; +const EROFS: u32 = 30; const ENAMETOOLONG: u32 = 36; const ENOSYS: u32 = 38; const ENOTEMPTY: u32 = 39; @@ -956,6 +984,29 @@ impl From for UnlinkError { } } +impl From for RenameError { + fn from(error: Error) -> Self { + match error { + Error::InvalidPathname => Self::PathError(PathError::InvalidPathname), + Error::Remote(errno) => match errno { + ENOENT => Self::PathError(PathError::NoSuchFileOrDirectory), + EPERM | EACCES => Self::NoWritePerms, + EBUSY => Self::Busy, + // Unsupported atomic rename is EXDEV, never copy/delete. + EXDEV | ENOSYS | EOPNOTSUPP => Self::CrossDevice, + EINVAL => Self::InvalidArgument, + EISDIR => Self::IsADirectory, + ENOTDIR => Self::NotADirectory, + ENOTEMPTY | EEXIST => Self::NotEmpty, + EROFS => Self::ReadOnlyFileSystem, + ENAMETOOLONG => Self::PathError(PathError::InvalidPathname), + _ => Self::Io, + }, + Error::Io | Error::InvalidResponse | Error::Broker(_) => Self::Io, + } + } +} + impl From for RmdirError { fn from(e: Error) -> Self { match e { diff --git a/litebox_broker_core/src/fs/overlay.rs b/litebox_broker_core/src/fs/overlay.rs index 680432a661..2f295360cc 100644 --- a/litebox_broker_core/src/fs/overlay.rs +++ b/litebox_broker_core/src/fs/overlay.rs @@ -32,7 +32,8 @@ use super::backend::{ }; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, + ReadError, RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, + WriteError, }; use super::inode_allocator::InodeAllocator; use super::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; @@ -82,6 +83,7 @@ pub struct OverlayWalkingDir { #[derive(Clone)] pub struct OverlayDir { + // XXX(jayb): directory handles are path-based and go stale on directory rename. path: Vec, } @@ -91,9 +93,9 @@ pub struct OverlayFile { /// The layer this file was _opened_ against; a later copy-up can move it, which is what /// [`State::copied_up`] records. layer: OverlayFileLayer, - // TODO(jayb): the parent path plus name is how object-addressed operations (`chmod`/`chown`) - // find the file again in order to copy it up. This must be revisited when rename lands, since a - // rename invalidates the recorded location. + // Only upper files may be renamed. Their handles already address the + // object; copied-up lower handles migrate through `State::copied_up`, so neither needs this + // recorded location after moving. Lower-only files cannot move before copy-up. parent: Vec, name: String, } @@ -1277,6 +1279,116 @@ impl Backend for Overlay { Ok(()) } + fn rename_at( + &self, + old_dir: DirHandle, + old_name: &str, + new_dir: DirHandle, + new_name: &str, + ) -> Result<(), RenameError> { + fn open_to_rename_error(error: OpenError) -> RenameError { + match error { + OpenError::PathError(error) => RenameError::PathError(error), + OpenError::ReadOnlyFileSystem => RenameError::ReadOnlyFileSystem, + OpenError::NoWritePerms | OpenError::AccessNotAllowed => RenameError::NoWritePerms, + _ => RenameError::Io, + } + } + if [old_name, new_name].iter().any(|name| { + !valid(name) + || name.is_empty() + || matches!(*name, "." | "..") + || name.contains(['/', '\0']) + }) { + return Err(PathError::InvalidPathname.into()); + } + let old_path = old_dir.into_typed::().path; + let new_path = new_dir.into_typed::().path; + let locked = self.namespace.lock(); + let old_dir = self.resolve_dir(&old_path).map_err(open_to_rename_error)?; + let source = self + .entry(&old_dir, old_name) + .map_err(open_to_rename_error)? + .ok_or(PathError::NoSuchFileOrDirectory)?; + let directory = source.entry.file_type == FileType::Directory; + if old_path == new_path && old_name == new_name { + return Ok(()); + } + let mut source_path = old_path.clone(); + source_path.push(String::from(old_name)); + if directory && new_path.starts_with(&source_path) { + return Err(RenameError::InvalidArgument); + } + let mut destination_path = new_path.clone(); + destination_path.push(String::from(new_name)); + if old_path.starts_with(&destination_path) { + return Err(RenameError::NotEmpty); + } + let new_dir = self.resolve_dir(&new_path).map_err(open_to_rename_error)?; + let destination = self + .entry(&new_dir, new_name) + .map_err(open_to_rename_error)?; + if let Some(destination) = &destination { + match ( + directory, + destination.entry.file_type == FileType::Directory, + ) { + (true, false) => return Err(RenameError::NotADirectory), + (false, true) => return Err(RenameError::IsADirectory), + (true, true) => { + let (child, _) = self + .resolve_child_dir(&new_dir, new_name, destination) + .map_err(open_to_rename_error)?; + if !self + .entries(&child) + .map_err(open_to_rename_error)? + .is_empty() + { + return Err(RenameError::NotEmpty); + } + // XXX(jayb): a logically empty destination can still hold hidden markers; + // handle this by clearing them (with rollback) as Linux overlayfs does. + if let Some(upper) = &child.upper + && self + .upper + .list_dir_at(upper.clone()) + .map_err(|_| RenameError::Io)? + .iter() + .any(|entry| !valid(&entry.name)) + { + unimplemented!("rename over a directory with hidden overlay markers"); + } + } + (false, false) => {} + } + } + // Never emulate rename with copy/delete: lower-backed sources and directories with lower + // contributions at either endpoint cannot be moved atomically. + if !source.upper + || (directory + && (source.lower.is_some() + || destination + .as_ref() + .is_some_and(|entry| entry.lower.is_some()))) + { + unimplemented!("rename of lower-backed overlay entries"); + } + let old_upper = old_dir.upper.ok_or(RenameError::Io)?; + let new_upper = self + .ensure_upper_dir(&locked, &new_path) + .map_err(open_to_rename_error)?; + if source.lower.is_some() { + // XXX(jayb): hide the old lower entry before moving its upper replacement; not atomic, + // so fix as part of broader atomicity fixups. Failure leaves the source visible + // because an upper entry takes precedence over whiteouts. Keep any destination + // whiteout so moving away cannot reveal its lower. + self.create_marker(&locked, &old_upper, &whiteout(old_name)) + .map_err(open_to_rename_error)?; + } + self.upper + .rename_at(old_upper, old_name, new_upper, new_name) + } + fn chmod(&self, h: HandleRef<'_>, mode: Mode) -> Result<(), ChmodError> { if let HandleRef::File(_) = h && self.status(h).map_err(|_| ChmodError::Io)?.file_type == FileType::SymbolicLink diff --git a/litebox_broker_core/src/fs/resolver.rs b/litebox_broker_core/src/fs/resolver.rs index c3d0ef4a0e..ba505e28cb 100644 --- a/litebox_broker_core/src/fs/resolver.rs +++ b/litebox_broker_core/src/fs/resolver.rs @@ -14,8 +14,8 @@ use litebox_platform::buffer::{ReadBuffer, WriteBuffer}; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SeekError, SymlinkError, TruncateError, UnlinkError, WalkError, - WriteError, + ReadError, RenameError, RmdirError, SeekError, SymlinkError, TruncateError, UnlinkError, + WalkError, WriteError, }; use super::{ FileType, Mode, OFlags, @@ -1162,6 +1162,68 @@ impl Resolver Result<(), RenameError> { + let context = Context::new(user); + let map_walk_error = |error| match error { + WalkError::Io => RenameError::Io, + WalkError::PathError(error) => error.into(), + }; + let parent = |path: &str, require_directory: bool| { + if path.is_empty() { + return Err(PathError::NoSuchFileOrDirectory.into()); + } + if path.contains('\0') { + return Err(PathError::InvalidPathname.into()); + } + let Some((parent, name)) = self + .parent_dir_and_name(&context, path) + .map_err(map_walk_error)? + else { + return Err(RenameError::Busy); + }; + if matches!(name.as_str(), "." | "..") { + return Err(RenameError::Busy); + } + if !Self::can_change_entries_in_dir(&context, &parent) { + return Err(RenameError::NoWritePerms); + } + // Own this directory before walking the other parent: walking handles may retain locks. + let dir = self + .owned_parent_dir(parent.handle) + .map_err(map_walk_error)?; + // XXX(jayb): like unlink, this trailing-slash check isn't atomic with the rename; fix + // as part of broader atomicity fixups. + if require_directory { + let walking = self.backend.walking_dir_at(&dir).ok_or(RenameError::Io)?; + let outcome = self + .backend + .walk_directories(walking, &[&name]) + .map_err(map_walk_error)?; + if outcome.stop_reason != WalkStopReason::CompleteDirectory { + return Err(PathError::ComponentNotADirectory.into()); + } + } + Ok((dir, name)) + }; + // A trailing slash on either path requires the source to be a directory; backends already + // require a directory source to replace only a directory. + let (old_dir, old_name) = + parent(old_path, old_path.ends_with('/') || new_path.ends_with('/'))?; + let (new_dir, new_name) = parent(new_path, false)?; + // XXX(jayb): like unlink, rename omits sticky-bit checks and write permission on a moved + // directory for updating `..`. + self.backend + .rename_at(old_dir, &old_name, new_dir, &new_name) + } + /// Create a new directory pub fn mkdir(&self, user: UserInfo, path: &str, mode: Mode) -> Result<(), MkdirError> { let context = Context::new(user); diff --git a/litebox_broker_core/src/fs/service.rs b/litebox_broker_core/src/fs/service.rs index f966387dd0..fff9c3296e 100644 --- a/litebox_broker_core/src/fs/service.rs +++ b/litebox_broker_core/src/fs/service.rs @@ -19,8 +19,8 @@ use litebox_platform::sync::{RawSyncPrimitivesProvider, RwLock}; use super::OFlags; use super::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SeekError, SymlinkError, TruncateError, UnlinkError, WalkError, - WriteError, + ReadError, RenameError, RmdirError, SeekError, SymlinkError, TruncateError, UnlinkError, + WalkError, WriteError, }; use super::resolver::{Resolver, ResolverEntry}; use crate::object::{ObjectEntry, ObjectRights}; @@ -205,6 +205,10 @@ mod private { Err(BrokerError::UnsupportedOperation) } + fn rename(&self, _old_path: &str, _new_path: &str, _user: FileUser) -> ServiceResult<()> { + Err(BrokerError::UnsupportedOperation) + } + fn mkdir(&self, _path: &str, _user: FileUser, _mode: FileMode) -> ServiceResult<()> { Err(BrokerError::UnsupportedOperation) } @@ -511,6 +515,10 @@ where Ok(Resolver::unlink(self, user, path).map_err(file_unlink_error)) } + fn rename(&self, old_path: &str, new_path: &str, user: FileUser) -> ServiceResult<()> { + Ok(Resolver::rename(self, user, old_path, new_path).map_err(file_rename_error)) + } + fn mkdir(&self, path: &str, user: FileUser, mode: FileMode) -> ServiceResult<()> { Ok(Resolver::mkdir(self, user, path, mode & FileMode::SUPPORTED).map_err(file_mkdir_error)) } @@ -828,6 +836,20 @@ pub fn unlink(process: &BrokerProcess, path: &str, user: FileUser) -> Result Result> { + process.require_object_rights(ObjectRights::WRITE)?; + if let Err(error) = validate_path(old_path).and_then(|()| validate_path(new_path)) { + return Ok(Err(error)); + } + process.core.fs.rename(old_path, new_path, user) +} + /// Creates a directory at an absolute path. pub fn mkdir( process: &BrokerProcess, @@ -1056,6 +1078,21 @@ fn file_symlink_error(error: SymlinkError) -> FileError { } } +fn file_rename_error(error: RenameError) -> FileError { + match error { + RenameError::NoWritePerms => FileError::NoWritePermissions, + RenameError::Busy => FileError::Busy, + RenameError::CrossDevice => FileError::CrossDevice, + RenameError::InvalidArgument => FileError::InvalidArgument, + RenameError::IsADirectory => FileError::IsDirectory, + RenameError::NotADirectory => FileError::NotDirectory, + RenameError::NotEmpty => FileError::NotEmpty, + RenameError::ReadOnlyFileSystem => FileError::ReadOnlyFs, + RenameError::Io => FileError::Io, + RenameError::PathError(error) => file_path_error(error), + } +} + fn file_mkdir_error(error: MkdirError) -> FileError { match error { MkdirError::NoWritePerms => FileError::NoWritePermissions, diff --git a/litebox_broker_core/src/fs/tar_ro.rs b/litebox_broker_core/src/fs/tar_ro.rs index 4f6ed2e164..c501279ba1 100644 --- a/litebox_broker_core/src/fs/tar_ro.rs +++ b/litebox_broker_core/src/fs/tar_ro.rs @@ -37,7 +37,7 @@ use super::{ backend::{CreationMetadata, DirHandle, FileHandle, HandleRef, WalkingDirHandle}, errors::{ ChmodError, ChownError, MkdirError, OpenError, PathError, ReadDirError, ReadError, - RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, + RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, }, inode_allocator::InodeAllocator, }; @@ -308,6 +308,16 @@ impl super::backend::Backend for TarRo { } } + fn rename_at( + &self, + _old_dir: DirHandle, + _old_name: &str, + _new_dir: DirHandle, + _new_name: &str, + ) -> Result<(), RenameError> { + Err(RenameError::ReadOnlyFileSystem) + } + fn chmod(&self, _h: HandleRef<'_>, _mode: Mode) -> Result<(), ChmodError> { Err(ChmodError::ReadOnlyFileSystem) } diff --git a/litebox_broker_host/src/lib.rs b/litebox_broker_host/src/lib.rs index b231576135..064c832b6f 100644 --- a/litebox_broker_host/src/lib.rs +++ b/litebox_broker_host/src/lib.rs @@ -36,10 +36,10 @@ use litebox_broker_protocol::fs::{ FilePathResponse, HandleFilePathRequest, HandleFileStatusRequest, IsTerminalFileRequest, MAX_FILE_TRANSFER_SIZE, MkdirFileRequest, OpenFileRequest, OpenFileResponse, PathFileStatusRequest, ReadDirectoryRequest, ReadDirectoryResponse, ReadFileRequest, - ReadFileResponse, ReadLinkFileRequest, ResolvePathFileRequest, RmdirFileRequest, - SeekFileRequest, SeekFileResponse, SymlinkFileRequest, TerminalControlFileRequest, - TruncateFileRequest, UnlinkFileRequest, WriteFileRequest, WriteFileResponse, - encode_directory_entries_chunk, + ReadFileResponse, ReadLinkFileRequest, RenameFileRequest, ResolvePathFileRequest, + RmdirFileRequest, SeekFileRequest, SeekFileResponse, SymlinkFileRequest, + TerminalControlFileRequest, TruncateFileRequest, UnlinkFileRequest, WriteFileRequest, + WriteFileResponse, encode_directory_entries_chunk, }; use litebox_broker_protocol::message::{ BrokerHandshakeResponse, BrokerOperation, BrokerRequest, BrokerResponse, BrokerResult, @@ -940,6 +940,22 @@ fn handle_file_request( }, ) } + FileRequest::Rename(RenameFileRequest { + paths, + old_path_length, + user, + }) => { + let data = read_shared_buffer(shared_buffers, paths, SHARED_BUFFER_SLOT_SIZE)?; + let (old_path, new_path) = rename_file_paths(&data, old_path_length)?; + Ok( + match litebox_broker_core::fs::rename(process, old_path, new_path, user) + .map_err(RequestFailure::from)? + { + Ok(()) => FileResponse::Rename, + Err(error) => FileResponse::Failed(error), + }, + ) + } FileRequest::Rmdir(RmdirFileRequest { path, user }) => { let path = read_file_path(shared_buffers, path)?; Ok( @@ -1249,6 +1265,19 @@ fn shared_buffer_access_failure(error: SharedBufferError) -> RequestFailure { } } +fn rename_file_paths(data: &[u8], old_path_length: u32) -> RequestResult<(&str, &str)> { + let malformed = || RequestFailure::Abort(ErrorCode::MalformedRequest); + let split = usize::try_from(old_path_length).map_err(|_| malformed())?; + let old_path = + core::str::from_utf8(data.get(..split).ok_or_else(malformed)?).map_err(|_| malformed())?; + let new_path = + core::str::from_utf8(data.get(split..).ok_or_else(malformed)?).map_err(|_| malformed())?; + if !old_path.starts_with('/') || !new_path.starts_with('/') { + return Err(malformed()); + } + Ok((old_path, new_path)) +} + fn read_file_path( shared_buffers: &SharedBufferPool, buffer: SharedBufferSequence, diff --git a/litebox_broker_local/src/fs.rs b/litebox_broker_local/src/fs.rs index 1fd7185ff0..b85e67b318 100644 --- a/litebox_broker_local/src/fs.rs +++ b/litebox_broker_local/src/fs.rs @@ -12,9 +12,9 @@ use litebox_broker_protocol::fs::{ FileSeekWhence, FileStatus, FileUser, HandleFilePathRequest, HandleFileStatusRequest, IsTerminalFileRequest, MAX_FILE_TRANSFER_SIZE, MkdirFileRequest, OpenFileRequest, PathFileStatusRequest, ReadDirectoryRequest, ReadFileRequest, ReadLinkFileRequest, - ResolvePathFileRequest, RmdirFileRequest, SeekFileRequest, SymlinkFileRequest, - TerminalControlFileRequest, TruncateFileRequest, UnlinkFileRequest, WriteFileRequest, - try_decode_directory_entries, + RenameFileRequest, ResolvePathFileRequest, RmdirFileRequest, SeekFileRequest, + SymlinkFileRequest, TerminalControlFileRequest, TruncateFileRequest, UnlinkFileRequest, + WriteFileRequest, try_decode_directory_entries, }; use litebox_broker_protocol::message::{BrokerOperation, BrokerResult, FileRequest, FileResponse}; use litebox_broker_protocol::shared_buffer::{SHARED_BUFFER_SLOT_SIZE, SharedBufferSequence}; @@ -387,6 +387,39 @@ impl BrokerLocal { } } + /// Renames a file or directory between absolute paths. + /// + /// # Panics + /// + /// Panics if the buffer sequence is inconsistent or the broker returns + /// a response for another file operation. + pub fn rename_file( + &self, + paths_buffer: SharedBufferSequence, + old_path: &str, + new_path: &str, + user: FileUser, + ) -> Result, Channel::Error> { + let length = old_path + .len() + .checked_add(new_path.len()) + .ok_or(BrokerLocalError::Broker(ErrorCode::ResourceExhausted))?; + self.validate_file_buffer(paths_buffer, length, SHARED_BUFFER_SLOT_SIZE)?; + let old_path_length = u32::try_from(old_path.len()) + .map_err(|_| BrokerLocalError::Broker(ErrorCode::ResourceExhausted))?; + self.write_shared_buffer_at(paths_buffer, 0, old_path.as_bytes()); + self.write_shared_buffer_at(paths_buffer, old_path.len(), new_path.as_bytes()); + match self.request_file(FileRequest::Rename(RenameFileRequest { + paths: paths_buffer, + old_path_length, + user, + }))? { + FileResponse::Rename => Ok(Ok(())), + FileResponse::Failed(error) => Ok(Err(error)), + response => panic!("broker returned unexpected file rename response: {response:?}"), + } + } + /// Creates a directory at an absolute path. /// /// # Panics diff --git a/litebox_broker_protocol/src/fs.rs b/litebox_broker_protocol/src/fs.rs index 6cfc6d5595..54f605667e 100644 --- a/litebox_broker_protocol/src/fs.rs +++ b/litebox_broker_protocol/src/fs.rs @@ -152,6 +152,10 @@ pub enum FileError { NoSpace, #[error("operation is not supported")] NotSupported, + #[error("paths are on different filesystems")] + CrossDevice, + #[error("invalid argument")] + InvalidArgument, } /// Seek origin. @@ -560,6 +564,17 @@ pub struct UnlinkFileRequest { pub user: FileUser, } +/// Renames a file or directory by path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RenameFileRequest { + /// Shared-buffer region containing the old absolute UTF-8 path followed by the new path. + pub paths: SharedBufferSequence, + /// Length of the old path at the start of `paths`. + pub old_path_length: u32, + /// Caller identity for permission checks. + pub user: FileUser, +} + /// Creates a directory by path. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MkdirFileRequest { diff --git a/litebox_broker_protocol/src/message.rs b/litebox_broker_protocol/src/message.rs index ce2c432d53..3b2e8eb29c 100644 --- a/litebox_broker_protocol/src/message.rs +++ b/litebox_broker_protocol/src/message.rs @@ -11,7 +11,7 @@ use crate::fs::{ HandleFilePathRequest, HandleFileStatusRequest, IsTerminalFileRequest, MkdirFileRequest, OpenFileRequest, OpenFileResponse, PathFileStatusRequest, ReadDirectoryRequest, ReadDirectoryResponse, ReadFileRequest, ReadFileResponse, ReadLinkFileRequest, - ResolvePathFileRequest, RmdirFileRequest, SeekFileRequest, SeekFileResponse, + RenameFileRequest, ResolvePathFileRequest, RmdirFileRequest, SeekFileRequest, SeekFileResponse, SetStatusFlagsRequest, SymlinkFileRequest, TerminalControlFileRequest, TruncateFileRequest, UnlinkFileRequest, WriteFileRequest, WriteFileResponse, }; @@ -136,6 +136,7 @@ impl BrokerOperation { | FileRequest::Chmod(ChmodFileRequest { path: buffer, .. }) | FileRequest::Chown(ChownFileRequest { path: buffer, .. }) | FileRequest::Unlink(UnlinkFileRequest { path: buffer, .. }) + | FileRequest::Rename(RenameFileRequest { paths: buffer, .. }) | FileRequest::Mkdir(MkdirFileRequest { path: buffer, .. }) | FileRequest::Rmdir(RmdirFileRequest { path: buffer, .. }) | FileRequest::HandlePath(HandleFilePathRequest { buffer, .. }) @@ -829,6 +830,8 @@ pub enum FileRequest { ResolvePath(ResolvePathFileRequest), /// Create a symbolic link with an opaque target. Symlink(SymlinkFileRequest), + /// Rename a file or directory by path. + Rename(RenameFileRequest), } /// Broker-owned fs response. @@ -872,6 +875,8 @@ pub enum FileResponse { ResolvePath(FilePathResponse), /// Symbolic-link creation completed. Symlink, + /// Rename completed. + Rename, /// File operation failed with a guest-visible error. Failed(FileError), } diff --git a/litebox_broker_protocol/src/wire/fs.rs b/litebox_broker_protocol/src/wire/fs.rs index de1ac9d8c1..02385a5304 100644 --- a/litebox_broker_protocol/src/wire/fs.rs +++ b/litebox_broker_protocol/src/wire/fs.rs @@ -9,7 +9,7 @@ use crate::fs::{ HandleFilePathRequest, HandleFileStatusRequest, IsTerminalFileRequest, MkdirFileRequest, OpenFileRequest, OpenFileResponse, PathFileStatusRequest, ReadDirectoryRequest, ReadDirectoryResponse, ReadFileRequest, ReadFileResponse, ReadLinkFileRequest, - ResolvePathFileRequest, RmdirFileRequest, SeekFileRequest, SeekFileResponse, + RenameFileRequest, ResolvePathFileRequest, RmdirFileRequest, SeekFileRequest, SeekFileResponse, SetStatusFlagsRequest, SymlinkFileRequest, TerminalControlFileRequest, TruncateFileRequest, UnlinkFileRequest, WriteFileRequest, WriteFileResponse, file_type_from_raw, file_type_raw, }; @@ -39,6 +39,7 @@ const REQUEST_TAG_READ_LINK: u8 = 15; const REQUEST_TAG_RESOLVE_PATH: u8 = 16; const REQUEST_TAG_HANDLE_PATH: u8 = 17; const REQUEST_TAG_SYMLINK: u8 = 18; +const REQUEST_TAG_RENAME: u8 = 19; const RESPONSE_TAG_OPEN: u8 = 0; const RESPONSE_TAG_READ: u8 = 1; @@ -59,6 +60,7 @@ const RESPONSE_TAG_READ_LINK: u8 = 15; const RESPONSE_TAG_RESOLVE_PATH: u8 = 16; const RESPONSE_TAG_HANDLE_PATH: u8 = 17; const RESPONSE_TAG_SYMLINK: u8 = 18; +const RESPONSE_TAG_RENAME: u8 = 19; const RESPONSE_TAG_FAILED: u8 = u8::MAX; const _: () = { @@ -80,6 +82,7 @@ const _: () = { assert!(REQUEST_TAG_RESOLVE_PATH == RESPONSE_TAG_RESOLVE_PATH); assert!(REQUEST_TAG_HANDLE_PATH == RESPONSE_TAG_HANDLE_PATH); assert!(REQUEST_TAG_SYMLINK == RESPONSE_TAG_SYMLINK); + assert!(REQUEST_TAG_RENAME == RESPONSE_TAG_RENAME); }; pub(super) fn encode_fs_request(encoder: &mut Encoder, request: FileRequest) { @@ -182,6 +185,12 @@ pub(super) fn encode_fs_request(encoder: &mut Encoder, request: FileRequest) { encoder.u32(request.path_length); encode_user(encoder, request.user); } + FileRequest::Rename(request) => { + encoder.u8(REQUEST_TAG_RENAME); + encoder.shared_buffer_sequence(request.paths); + encoder.u32(request.old_path_length); + encode_user(encoder, request.user); + } FileRequest::HandlePath(request) => { encoder.u8(REQUEST_TAG_HANDLE_PATH); encoder.handle(request.handle); @@ -285,6 +294,11 @@ pub(super) fn decode_fs_request(decoder: &mut Decoder<'_>) -> Result Ok(FileRequest::Rename(RenameFileRequest { + paths: decoder.shared_buffer_sequence()?, + old_path_length: decoder.u32()?, + user: decode_user(decoder)?, + })), REQUEST_TAG_HANDLE_PATH => Ok(FileRequest::HandlePath(HandleFilePathRequest { handle: decoder.handle()?, buffer: decoder.shared_buffer_sequence()?, @@ -344,6 +358,7 @@ pub(super) fn encode_fs_response(encoder: &mut Encoder, response: FileResponse) FileResponse::Mkdir => encoder.u8(RESPONSE_TAG_MKDIR), FileResponse::Rmdir => encoder.u8(RESPONSE_TAG_RMDIR), FileResponse::Symlink => encoder.u8(RESPONSE_TAG_SYMLINK), + FileResponse::Rename => encoder.u8(RESPONSE_TAG_RENAME), FileResponse::ReadLink(FilePathResponse { length }) => { encoder.u8(RESPONSE_TAG_READ_LINK); encoder.u32(length); @@ -398,6 +413,7 @@ pub(super) fn decode_fs_response(decoder: &mut Decoder<'_>) -> Result Ok(FileResponse::Mkdir), RESPONSE_TAG_RMDIR => Ok(FileResponse::Rmdir), RESPONSE_TAG_SYMLINK => Ok(FileResponse::Symlink), + RESPONSE_TAG_RENAME => Ok(FileResponse::Rename), RESPONSE_TAG_READ_LINK => Ok(FileResponse::ReadLink(FilePathResponse { length: decoder.u32()?, })), @@ -442,6 +458,8 @@ pub(super) fn encode_file_error(encoder: &mut Encoder, error: FileError) { FileError::NoSuchDevice => 26, FileError::NoSpace => 27, FileError::NotSupported => 28, + FileError::CrossDevice => 29, + FileError::InvalidArgument => 30, }); } @@ -475,6 +493,8 @@ pub(super) fn decode_file_error(decoder: &mut Decoder<'_>) -> Result Ok(FileError::NoSuchDevice), 27 => Ok(FileError::NoSpace), 28 => Ok(FileError::NotSupported), + 29 => Ok(FileError::CrossDevice), + 30 => Ok(FileError::InvalidArgument), _ => Err(WireError::InvalidTag), } } diff --git a/litebox_broker_userland/src/fs/io_errors.rs b/litebox_broker_userland/src/fs/io_errors.rs index 18c83182c9..f18ffe895c 100644 --- a/litebox_broker_userland/src/fs/io_errors.rs +++ b/litebox_broker_userland/src/fs/io_errors.rs @@ -8,7 +8,7 @@ #[cfg(unix)] use litebox_broker_core::fs::errors::ChownError; use litebox_broker_core::fs::errors::{ - ChmodError, MkdirError, OpenError, PathError, RmdirError, UnlinkError, WalkError, + ChmodError, MkdirError, OpenError, PathError, RenameError, RmdirError, UnlinkError, WalkError, }; pub(super) fn invalid_path() -> std::io::Error { @@ -105,6 +105,23 @@ pub(super) fn rmdir_error(error: std::io::Error) -> RmdirError { } } +pub(super) fn rename_error(error: std::io::Error) -> RenameError { + match error.kind() { + std::io::ErrorKind::NotFound => PathError::NoSuchFileOrDirectory.into(), + std::io::ErrorKind::PermissionDenied => RenameError::NoWritePerms, + std::io::ErrorKind::ReadOnlyFilesystem => RenameError::ReadOnlyFileSystem, + std::io::ErrorKind::CrossesDevices => RenameError::CrossDevice, + std::io::ErrorKind::DirectoryNotEmpty | std::io::ErrorKind::AlreadyExists => { + RenameError::NotEmpty + } + std::io::ErrorKind::IsADirectory => RenameError::IsADirectory, + std::io::ErrorKind::NotADirectory => RenameError::NotADirectory, + std::io::ErrorKind::InvalidInput => RenameError::InvalidArgument, + std::io::ErrorKind::ResourceBusy => RenameError::Busy, + _ => unmapped(&error, RenameError::Io), + } +} + pub(super) fn chmod_error(error: std::io::Error) -> ChmodError { match error.kind() { std::io::ErrorKind::PermissionDenied => ChmodError::NotTheOwner, diff --git a/litebox_broker_userland/src/fs/mod.rs b/litebox_broker_userland/src/fs/mod.rs index 4183b07374..12f2146255 100644 --- a/litebox_broker_userland/src/fs/mod.rs +++ b/litebox_broker_userland/src/fs/mod.rs @@ -17,7 +17,9 @@ //! path retargets them. This tradeoff keeps the implementation portable and mostly //! platform-neutral; it is suitable only when host namespace administration is trusted. On //! Windows, std's native deletion can remain pending while an ordinary file handle is open, so the -//! same name may not be reusable until retained handles close. +//! same name may not be reusable until retained handles close. Rename uses the host's atomic +//! rename operation; in particular, replacing an empty directory may fail on Windows even though +//! the backend's type checks pass. use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; @@ -36,7 +38,7 @@ mod windows; #[cfg(unix)] use io_errors::chown_error; use io_errors::{ - chmod_error, invalid_path, mkdir_error, open_error, rmdir_error, unlink_error, + chmod_error, invalid_path, mkdir_error, open_error, rename_error, rmdir_error, unlink_error, unsupported_object, walk_error, }; @@ -46,7 +48,8 @@ use litebox_broker_core::fs::backend::{ }; use litebox_broker_core::fs::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, PathError, ReadDirError, - ReadError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, WriteError, + ReadError, RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, + WriteError, }; use litebox_broker_core::fs::inode_allocator::InodeAllocator; use litebox_broker_core::fs::{DirEntry, FileStatus, FileType, Mode, NodeInfo, OFlags, UserInfo}; @@ -230,6 +233,8 @@ fn identity(metadata: &fs::Metadata, canonical_path: &Path) -> HostIdentity { // preserve stable identity for ordinary names, but distinct hard links receive distinct // guest inode IDs, and an open old object plus a replacement at the same path can share // one guest inode ID. + // XXX: Windows identity is path-keyed, so a rename changes the inode ID seen through the + // new name; retained handles still use the old path. let _ = metadata; HostIdentity { canonical_path: canonical_path.to_owned(), @@ -947,6 +952,48 @@ impl Backend for LocalFs { fs::remove_dir(path).map_err(rmdir_error) } + fn rename_at( + &self, + old_dir: DirHandle, + old_name: &str, + new_dir: DirHandle, + new_name: &str, + ) -> Result<(), RenameError> { + validate_name(old_name)?; + validate_name(new_name)?; + let old_dir = old_dir.into_typed::(); + let new_dir = new_dir.into_typed::(); + if !self.valid_dir(&old_dir) || !self.valid_dir(&new_dir) { + return Err(RenameError::Io); + } + let old_path = old_dir.canonical_path.join(old_name); + let new_path = new_dir.canonical_path.join(new_name); + let source = fs::symlink_metadata(&old_path).map_err(rename_error)?; + let source_is_dir = source.is_dir() && !source.file_type().is_symlink(); + if old_path == new_path { + return Ok(()); + } + if source_is_dir && new_path.starts_with(&old_path) { + return Err(RenameError::InvalidArgument); + } + + match fs::symlink_metadata(&new_path) { + Ok(destination) => { + let destination_is_dir = + destination.is_dir() && !destination.file_type().is_symlink(); + if !source_is_dir && destination_is_dir { + return Err(RenameError::IsADirectory); + } + if source_is_dir && !destination_is_dir { + return Err(RenameError::NotADirectory); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(rename_error(error)), + } + fs::rename(old_path, new_path).map_err(rename_error) + } + fn chmod(&self, h: HandleRef<'_>, mode: Mode) -> Result<(), ChmodError> { #[cfg(windows)] litebox_util_log::debug!( diff --git a/litebox_broker_vm_kernel/src/share.rs b/litebox_broker_vm_kernel/src/share.rs index 8b3a3fc969..76215c4a61 100644 --- a/litebox_broker_vm_kernel/src/share.rs +++ b/litebox_broker_vm_kernel/src/share.rs @@ -16,8 +16,8 @@ use litebox_broker_core::fs::backend::{ }; use litebox_broker_core::fs::errors::{ ChmodError, ChownError, FileStatusError, MkdirError, OpenError, ReadDirError, - ReadError as FsReadError, RmdirError, SymlinkError, TruncateError, UnlinkError, WalkError, - WriteError as FsWriteError, + ReadError as FsReadError, RenameError, RmdirError, SymlinkError, TruncateError, UnlinkError, + WalkError, WriteError as FsWriteError, }; use litebox_broker_core::fs::inode_allocator::InodeAllocator; use litebox_broker_core::fs::nine_p::transport::{Read, ReadError, Write, WriteError}; @@ -290,6 +290,16 @@ impl Backend for ShareFs { Err(UnlinkError::ReadOnlyFileSystem) } + fn rename_at( + &self, + _old_dir: DirHandle, + _old_name: &str, + _new_dir: DirHandle, + _new_name: &str, + ) -> Result<(), RenameError> { + Err(RenameError::ReadOnlyFileSystem) + } + fn rmdir_at(&self, _dir: DirHandle, _name: &str) -> Result<(), RmdirError> { Err(RmdirError::ReadOnlyFileSystem) } diff --git a/litebox_common_linux/src/errno/mod.rs b/litebox_common_linux/src/errno/mod.rs index a5e0af8026..087db76685 100644 --- a/litebox_common_linux/src/errno/mod.rs +++ b/litebox_common_linux/src/errno/mod.rs @@ -238,6 +238,24 @@ impl From for Errno { } } +impl From for Errno { + fn from(value: litebox::fs::errors::RenameError) -> Self { + match value { + litebox::fs::errors::RenameError::NoWritePerms => Errno::EACCES, + litebox::fs::errors::RenameError::Busy => Errno::EBUSY, + litebox::fs::errors::RenameError::CrossDevice => Errno::EXDEV, + litebox::fs::errors::RenameError::InvalidArgument => Errno::EINVAL, + litebox::fs::errors::RenameError::IsADirectory => Errno::EISDIR, + litebox::fs::errors::RenameError::NotADirectory => Errno::ENOTDIR, + litebox::fs::errors::RenameError::NotEmpty => Errno::ENOTEMPTY, + litebox::fs::errors::RenameError::ReadOnlyFileSystem => Errno::EROFS, + litebox::fs::errors::RenameError::Io => Errno::EIO, + litebox::fs::errors::RenameError::PathError(path_error) => path_error.into(), + _ => unimplemented!(), + } + } +} + impl From for Errno { fn from(value: litebox::fs::errors::RmdirError) -> Self { match value { diff --git a/litebox_common_linux/src/lib.rs b/litebox_common_linux/src/lib.rs index b4d8fc058a..df696cd5ea 100644 --- a/litebox_common_linux/src/lib.rs +++ b/litebox_common_linux/src/lib.rs @@ -3029,6 +3029,13 @@ pub enum SyscallRequest { pathname: UserPtr, flags: AtFlags, }, + Renameat2 { + olddirfd: i32, + oldpath: UserPtr, + newdirfd: i32, + newpath: UserPtr, + flags: u32, + }, Newfstatat { dirfd: i32, pathname: UserPtr, @@ -3682,6 +3689,22 @@ impl SyscallRequest { mode_and_type: ctx.sys_req_arg(1), dev: ctx.sys_req_arg(2), }, + Sysno::renameat2 => sys_req!(Renameat2 { olddirfd,oldpath:*,newdirfd,newpath:*,flags }), + Sysno::renameat => SyscallRequest::Renameat2 { + olddirfd: ctx.sys_req_arg(0), + oldpath: ctx.sys_req_ptr(1), + newdirfd: ctx.sys_req_arg(2), + newpath: ctx.sys_req_ptr(3), + flags: 0, + }, + #[cfg(target_arch = "x86_64")] + Sysno::rename => SyscallRequest::Renameat2 { + olddirfd: AT_FDCWD, + oldpath: ctx.sys_req_ptr(0), + newdirfd: AT_FDCWD, + newpath: ctx.sys_req_ptr(1), + flags: 0, + }, Sysno::unlinkat => sys_req!(Unlinkat { dirfd,pathname:*,flags }), #[cfg(target_arch = "x86_64")] Sysno::unlink => { diff --git a/litebox_runner_linux_userland/tests/rename.c b/litebox_runner_linux_userland/tests/rename.c new file mode 100644 index 0000000000..69f9a5b45c --- /dev/null +++ b/litebox_runner_linux_userland/tests/rename.c @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "helpers.h" + +int main(void) { + char path[] = "/tmp/lb_rename_XXXXXX"; + char buffer[5]; + struct stat st; + + TEST_ASSERT(mkdtemp(path) != NULL, "mkdtemp failed"); + TEST_ASSERT(chdir(path) == 0, "chdir tmpdir failed"); + int fd = open("source", O_CREAT | O_EXCL | O_RDWR, 0600); + TEST_ASSERT(fd >= 0, "create source failed"); + TEST_ASSERT(write(fd, "hello", 5) == 5, "write source failed"); + + TEST_ASSERT(rename("source", "moved") == 0, "rename file failed"); + errno = 0; + TEST_ASSERT(stat("source", &st) == -1 && errno == ENOENT, + "old name should no longer exist"); + create_test_file("target", 0600); + TEST_ASSERT(renameat(AT_FDCWD, "moved", AT_FDCWD, "target") == 0, + "renameat should replace an existing file"); + TEST_ASSERT(syscall(SYS_renameat2, AT_FDCWD, "target", AT_FDCWD, "final", 0) == 0, + "renameat2 with zero flags failed"); + + int renamed = open("final", O_RDONLY); + TEST_ASSERT(renamed >= 0, "open renamed file failed"); + TEST_ASSERT(read(renamed, buffer, sizeof(buffer)) == 5, + "read renamed file failed"); + TEST_ASSERT(memcmp(buffer, "hello", sizeof(buffer)) == 0, + "renamed file contents changed"); + TEST_ASSERT(pread(fd, buffer, sizeof(buffer), 0) == 5, + "original file descriptor should remain usable"); + TEST_ASSERT(memcmp(buffer, "hello", sizeof(buffer)) == 0, + "original file descriptor contents changed"); + TEST_ASSERT(close(renamed) == 0 && close(fd) == 0, "close files failed"); + TEST_ASSERT(unlink("final") == 0, "cleanup file failed"); + + TEST_ASSERT(mkdir("directory", 0700) == 0, "mkdir failed"); + TEST_ASSERT(rename("directory", "renamed_directory") == 0, "rename directory failed"); + TEST_ASSERT(stat("renamed_directory", &st) == 0 && S_ISDIR(st.st_mode), + "renamed directory should exist"); + TEST_ASSERT(rmdir("renamed_directory") == 0, "cleanup directory failed"); + errno = 0; + TEST_ASSERT(rename("missing", "final") == -1 && errno == ENOENT, + "rename missing path should fail with ENOENT"); + + TEST_ASSERT(chdir("/") == 0, "leave tmpdir failed"); + TEST_ASSERT(rmdir(path) == 0, "cleanup tmpdir failed"); + puts("All rename tests passed."); + return 0; +} diff --git a/litebox_shim_linux/src/lib.rs b/litebox_shim_linux/src/lib.rs index 516f6045fb..99a6507f1c 100644 --- a/litebox_shim_linux/src/lib.rs +++ b/litebox_shim_linux/src/lib.rs @@ -1441,6 +1441,19 @@ impl Task { let path = path.to_str().map_err(|_| Errno::EINVAL)?; syscall!(sys_mknodat(dirfd, path, mode_and_type, dev)) }), + SyscallRequest::Renameat2 { + olddirfd, + oldpath, + newdirfd, + newpath, + flags, + } => { + let oldpath = oldpath.to_cstring::().ok_or(Errno::EFAULT)?; + let newpath = newpath.to_cstring::().ok_or(Errno::EFAULT)?; + let oldpath = oldpath.to_str().map_err(|_| Errno::EINVAL)?; + let newpath = newpath.to_str().map_err(|_| Errno::EINVAL)?; + syscall!(sys_renameat2(olddirfd, oldpath, newdirfd, newpath, flags)) + } SyscallRequest::Unlinkat { dirfd, pathname, diff --git a/litebox_shim_linux/src/syscalls/file.rs b/litebox_shim_linux/src/syscalls/file.rs index b1a4e486e3..3e6764a1c3 100644 --- a/litebox_shim_linux/src/syscalls/file.rs +++ b/litebox_shim_linux/src/syscalls/file.rs @@ -1208,6 +1208,28 @@ impl Task { Ok(()) } + /// Handle syscalls `rename`, `renameat`, and `renameat2`. + pub(crate) fn sys_renameat2( + &self, + olddirfd: i32, + oldpath: &str, + newdirfd: i32, + newpath: &str, + flags: u32, + ) -> Result<(), Errno> { + if flags != 0 { + unimplemented!("{flags:?}"); + } + let oldpath = self.resolve_path_at(olddirfd, oldpath)?; + let newpath = self.resolve_path_at(newdirfd, newpath)?; + let fs = self.fs.borrow(); + let context = fs.context.read(); + self.global + .litebox + .rename_file(&context, &oldpath, &newpath) + .map_err(Errno::from) + } + /// Handle syscall `unlinkat` pub(crate) fn sys_unlinkat( &self,