diff --git a/src/dir.rs b/src/dir.rs index bc782bb..971abe0 100644 --- a/src/dir.rs +++ b/src/dir.rs @@ -630,6 +630,10 @@ pub struct Dir { } impl Dir { + pub(crate) fn belongs_to(&self, fs: &Ext4) -> bool { + PtrPrimitive::ptr_eq(&self.fs.0, &fs.0) + } + /// Create and initialize a new directory. #[maybe_async::maybe_async] pub async fn init( @@ -669,6 +673,120 @@ impl Dir { get_dir_entry_inode_by_name(&self.fs, &self.inode, name).await } + /// Update this directory's existing ".." entry from `old_parent` to + /// `new_parent`, preserving its entries and any htree index. + /// + /// This is a raw building block for directory rename. The caller must + /// serialize namespace changes, prevent directory cycles, move the name + /// between the parent directories, and update their link counts. This + /// method only changes ".." and the directory block checksum; it does + /// not change inode metadata or allocate blocks. + /// + /// # Errors + /// + /// Returns [`Ext4Error::Corrupt`] if the dot entries or block checksum + /// are invalid, ".." no longer points to `old_parent`, or a parent index + /// is outside the filesystem. + /// Returns [`Ext4Error::NotADirectory`] if `new_parent` is not a directory, + /// [`Ext4Error::Encrypted`] for encrypted directories, and + /// [`Ext4Error::Readonly`] if the directory is immutable or the filesystem + /// cannot be written. Storage errors are returned as [`Ext4Error::Io`]. + /// If a block write fails, this method attempts to restore the original + /// on-disk block before returning the storage error. + #[maybe_async::maybe_async] + pub async fn reparent( + &mut self, + old_parent: InodeIndex, + new_parent: InodeIndex, + ) -> Result<(), Ext4Error> { + if self.inode.flags().contains(InodeFlags::DIRECTORY_ENCRYPTED) { + return Err(Ext4Error::Encrypted); + } + if self.inode.flags().contains(InodeFlags::IMMUTABLE) { + return Err(Ext4Error::Readonly); + } + if self.fs.0.writer.is_none() { + return Err(Ext4Error::Readonly); + } + let inodes_count = self.fs.0.superblock.inodes_count(); + if old_parent.get() > inodes_count || new_parent.get() > inodes_count { + return Err(dir_entry_error(self.inode.index)); + } + + let block_size = self.fs.0.superblock.block_size().to_usize(); + if self.inode.size_in_bytes() < u64::try_from(block_size).unwrap() { + return Err(dir_entry_error(self.inode.index)); + } + let block_index = crate::file_blocks::FileBlocks::from_inode( + &self.inode, + self.fs.clone(), + )? + .get_block(0) + .await?; + if block_index == 0 { + return Err(dir_entry_error(self.inode.index)); + } + let dir_block = DirBlock { + fs: &self.fs, + block_index, + is_first: true, + dir_inode: self.inode.index, + has_htree: self.inode.flags().contains(InodeFlags::DIRECTORY_HTREE), + checksum_base: self.inode.checksum_base().clone(), + }; + let mut block = vec![0; block_size]; + dir_block.read(&mut block).await?; + dir_block.validate_reparent_layout(&block)?; + + let dotdot_len = usize::from(read_u16le(&block, 16)); + let mut dotdot_max = + checked_sub_usize(block.len(), 12, self.inode.index)?; + if !dir_block.has_htree && self.fs.has_metadata_checksums() { + dotdot_max = checked_sub_usize(dotdot_max, 12, self.inode.index)?; + } + if read_u32le(&block, 0) != self.inode.index.get() + || read_u16le(&block, 4) != 12 + || block[6] != 1 + || block[7] != FileType::Directory.to_dir_entry() + || block[8] != b'.' + || read_u32le(&block, 12) != old_parent.get() + || dotdot_len < 12 + || dotdot_len > dotdot_max + || dotdot_len % 4 != 0 + || (dir_block.has_htree && dotdot_len != dotdot_max) + || block[18] != 2 + || block[19] != FileType::Directory.to_dir_entry() + || &block[20..22] != b".." + { + return Err(dir_entry_error(self.inode.index)); + } + if !Inode::read(&self.fs, new_parent) + .await? + .file_type() + .is_dir() + { + return Err(Ext4Error::NotADirectory); + } + if old_parent == new_parent { + return Ok(()); + } + + // Save the exact home-block bytes; a loaded journal overlay can + // differ from the on-disk contents that must be restored on failure. + let mut original = vec![0; block_size]; + self.fs + .read_from_block_raw(block_index, 0, &mut original) + .await?; + write_u32le(&mut block, 12, new_parent.get()); + dir_block.update_checksum(&mut block)?; + if let Err(error) = self.fs.write_to_block(block_index, 0, &block).await + { + self.fs.write_to_block(block_index, 0, &original).await?; + return Err(error); + } + Ok(()) + } + /// Create a new directory entry at `name` pointing to `target_inode`. /// Increments relevant link counts (`target_inode` always, and `self` if `target_inode` is a directory). /// @@ -1230,9 +1348,172 @@ pub(crate) async fn remove_dir_entry_htree( #[cfg(test)] mod tests { use super::*; - use crate::test_util::{load_test_disk1, load_test_disk1_rw}; + use crate::test_util::{ + load_compressed_filesystem_rw, load_test_disk1, load_test_disk1_rw, + }; use crate::{FollowSymlinks, InodeCreationOptions, InodeMode}; + #[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) + )] + async fn test_reparent_rejects_corrupt_blocks_without_writing() { + let (fs, data) = + load_compressed_filesystem_rw("test_disk1.bin.zst").await; + let root = fs.read_root_inode().await.unwrap().index; + let new_parent = fs + .path_to_inode("/dir1".try_into().unwrap(), FollowSymlinks::All) + .await + .unwrap() + .index; + let non_directory = fs + .path_to_inode( + "/small_file".try_into().unwrap(), + FollowSymlinks::All, + ) + .await + .unwrap() + .index; + for name in ["/dir1", "/medium_dir"] { + let inode = fs + .path_to_inode(name.try_into().unwrap(), FollowSymlinks::All) + .await + .unwrap(); + let block_index = + crate::file_blocks::FileBlocks::from_inode(&inode, fs.clone()) + .unwrap() + .get_block(0) + .await + .unwrap(); + let block_size = fs.superblock().block_size().to_usize(); + let dir_block = DirBlock { + fs: &fs, + block_index, + is_first: true, + dir_inode: inode.index, + has_htree: inode.flags().contains(InodeFlags::DIRECTORY_HTREE), + checksum_base: inode.checksum_base().clone(), + }; + let mut original = vec![0; block_size]; + dir_block.read(&mut original).await.unwrap(); + let mut dir = Dir::open_inode(&fs, inode).unwrap(); + for damage in 0..11 { + let mut block = original.clone(); + match damage { + 0 => block[8] = b'!', + 1 => write_u32le(&mut block, 12, new_parent.get()), + 2 => block[24] ^= 1, + 3 if dir_block.has_htree => { + // An invalid count must return an error, not panic + // while slicing the htree root for its checksum. + write_u16le(&mut block, 0x22, u16::MAX); + } + 4 if dir_block.has_htree => { + let limit = + read_u16le(&block, 0x20).checked_sub(1).unwrap(); + write_u16le(&mut block, 0x20, limit); + } + 5 if dir_block.has_htree => block[0x1d] = 9, + 6 if dir_block.has_htree => block[0x1f] = 1, + 7 if !dir_block.has_htree => { + write_u16le( + &mut block, + 16, + u16::try_from(block_size.checked_sub(12).unwrap()) + .unwrap(), + ); + } + 8 if !dir_block.has_htree => { + let marker = block_size.checked_sub(5).unwrap(); + block[marker] = 0; + } + 9 if dir_block.has_htree => { + // The root index is hidden inside the '..' record. + write_u16le(&mut block, 16, 12); + } + 10 => write_u32le(&mut block, 12, non_directory.get()), + _ => continue, + } + if damage != 2 && damage != 3 { + // Forge a valid checksum independently of the layout + // validator: structural corruption must still be rejected. + let mut checksum = dir_block.checksum_base.clone(); + if dir_block.has_htree { + let count = usize::from(read_u16le(&block, 0x22)); + let end = 32usize + .checked_add(count.checked_mul(8).unwrap()) + .unwrap(); + let tail = block_size.checked_sub(8).unwrap(); + checksum.update(&block[..end]); + checksum.update_u32_le(read_u32le(&block, tail)); + checksum.update_u32_le(0); + } else { + let tail = block_size.checked_sub(12).unwrap(); + checksum.update(&block[..tail]); + } + let offset = block_size.checked_sub(4).unwrap(); + write_u32le(&mut block, offset, checksum.finalize()); + } + fs.write_to_block(block_index, 0, &block).await.unwrap(); + let before = data.lock().unwrap().clone(); + let result = if damage == 10 { + dir.reparent(non_directory, non_directory).await + } else { + dir.reparent(root, new_parent).await + }; + if damage == 10 { + assert!(matches!(result, Err(Ext4Error::NotADirectory))); + } else { + assert!(matches!(result, Err(Ext4Error::Corrupt(_)))); + } + assert!( + *data.lock().unwrap() == before, + "reparent changed a corrupt image" + ); + } + fs.write_to_block(block_index, 0, &original).await.unwrap(); + } + } + + #[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) + )] + async fn test_reparent_checks_htree_layout_without_checksums() { + let (fs, data) = + load_compressed_filesystem_rw("test_disk_ext3.bin.zst").await; + assert!(!fs.has_metadata_checksums()); + let root = fs.read_root_inode().await.unwrap().index; + let inode = fs + .path_to_inode( + "/medium_dir".try_into().unwrap(), + FollowSymlinks::All, + ) + .await + .unwrap(); + let index = + crate::file_blocks::FileBlocks::from_inode(&inode, fs.clone()) + .unwrap() + .get_block(0) + .await + .unwrap(); + let mut original = vec![0; fs.superblock().block_size().to_usize()]; + fs.read_from_block(index, 0, &mut original).await.unwrap(); + let mut dir = Dir::open_inode(&fs, inode).unwrap(); + for (offset, value) in [(0x1d, 9), (0x1f, 1), (0x20, 0), (0x22, 0)] { + let mut block = original.clone(); + block[offset] = value; + fs.write_to_block(index, 0, &block).await.unwrap(); + let before = data.lock().unwrap().clone(); + let result = dir.reparent(root, root).await; + assert!(matches!(result, Err(Ext4Error::Corrupt(_)))); + assert!( + *data.lock().unwrap() == before, + "reparent changed an invalid htree" + ); + } + } + #[test] fn test_choose_htree_split_with_hash_collision() { let inode = InodeIndex::new(1).unwrap(); diff --git a/src/dir_block.rs b/src/dir_block.rs index 525a4d6..93a8175 100644 --- a/src/dir_block.rs +++ b/src/dir_block.rs @@ -58,18 +58,17 @@ impl DirBlock<'_> { assert_eq!(block.len(), block_size); self.fs.read_from_block(self.block_index, 0, block).await?; + let block_type = self.get_block_type(block); if !self.fs.has_metadata_checksums() { return Ok(()); } - let block_type = self.get_block_type(block); - let expected_checksum = self.read_expected_checksum(block); let actual_checksum = if block_type == DirBlockType::Leaf { self.calc_leaf_checksum(block) } else { - self.calc_internal_checksum(block, block_type) + self.calc_internal_checksum(block, block_type)? }; if actual_checksum.finalize() == expected_checksum { @@ -89,17 +88,16 @@ impl DirBlock<'_> { ) -> Result<(), Ext4Error> { let block_size = self.fs.0.superblock.block_size(); assert_eq!(block.len(), block_size); + let block_type = self.get_block_type(block); if !self.fs.has_metadata_checksums() { return Ok(()); } - let block_type = self.get_block_type(block); - let checksum = if block_type == DirBlockType::Leaf { self.calc_leaf_checksum(block) } else { - self.calc_internal_checksum(block, block_type) + self.calc_internal_checksum(block, block_type)? }; // Stored in the last four bytes of the block. @@ -109,6 +107,52 @@ impl DirBlock<'_> { Ok(()) } + /// Validate the first block before changing its existing ".." entry. + pub(crate) fn validate_reparent_layout( + &self, + block: &[u8], + ) -> Result<(), Ext4Error> { + debug_assert!(self.is_first); + let block_type = self.get_block_type(block); + let corrupt = || Ext4Error::from(CorruptKind::DirEntry(self.dir_inode)); + if block_type == DirBlockType::Leaf { + if self.fs.has_metadata_checksums() { + let start = block.len().checked_sub(12).ok_or_else(corrupt)?; + let end = start.checked_add(8).ok_or_else(corrupt)?; + if block[start..end] != [0, 0, 0, 0, 12, 0, 0, 0xde] { + return Err(corrupt()); + } + } + return Ok(()); + } + let entries_offset = if block_type == DirBlockType::Root { + if read_u32le(block, 0x18) != 0 + || block[0x1d] != 8 + || block[0x1f] != 0 + { + return Err(corrupt()); + } + 0x20usize + } else { + 0x8usize + }; + let count_offset = entries_offset.checked_add(2).ok_or_else(corrupt)?; + let limit = usize::from(read_u16le(block, entries_offset)); + let count = usize::from(read_u16le(block, count_offset)); + let tail_size = if self.fs.has_metadata_checksums() { + 8 + } else { + 0 + }; + let tail = block.len().checked_sub(tail_size).ok_or_else(corrupt)?; + let capacity = + tail.checked_sub(entries_offset).ok_or_else(corrupt)? / 8; + if limit != capacity || count == 0 || count > limit { + return Err(corrupt()); + } + Ok(()) + } + /// Get the stored checksum from the last four bytes of the block. fn read_expected_checksum(&self, block: &[u8]) -> u32 { // OK to unwrap: minimum block size is 1024. @@ -136,7 +180,7 @@ impl DirBlock<'_> { &self, block: &[u8], block_type: DirBlockType, - ) -> Checksum { + ) -> Result { let tail_entry_size = 8; // OK to unwrap: minimum block size is 1024. @@ -160,13 +204,17 @@ impl DirBlock<'_> { let num_bytes = limit_offset .checked_add(usize::from(count).checked_mul(8).unwrap()) .unwrap(); + let limit = read_u16le(block, limit_offset); + if count == 0 || count > limit || num_bytes > tail_entry_offset { + return Err(CorruptKind::DirEntry(self.dir_inode).into()); + } let mut checksum = self.checksum_base.clone(); checksum.update(&block[..num_bytes]); checksum.update_u32_le(read_u32le(block, tail_entry_offset)); checksum.update_u32_le(0); - checksum + Ok(checksum) } fn get_block_type(&self, block: &[u8]) -> DirBlockType { diff --git a/src/error.rs b/src/error.rs index bed68b0..a82959b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -46,6 +46,9 @@ pub enum Ext4Error { /// non-directory path. NotADirectory, + /// An operation used a directory from another filesystem instance. + CrossDevice, + /// An operation that requires a regular file (or a symlink to a /// regular file) was attempted on a special file (fifo, character /// device, block device, or socket). @@ -132,6 +135,9 @@ impl Display for Ext4Error { Self::NotFound => write!(f, "file not found"), Self::IsADirectory => write!(f, "path is a directory"), Self::NotADirectory => write!(f, "path is not a directory"), + Self::CrossDevice => { + write!(f, "operation spans multiple filesystems") + } Self::IsASpecialFile => write!(f, "path is a special file"), Self::FileTooLarge => { write!(f, "file is too large to store in memory") @@ -190,6 +196,7 @@ impl From for std::io::Error { Ext4Error::Io(inner) => Self::other(inner), Ext4Error::IsADirectory => IsADirectory.into(), Ext4Error::NotADirectory => NotADirectory.into(), + Ext4Error::CrossDevice => CrossesDevices.into(), Ext4Error::NotFound => NotFound.into(), Ext4Error::NotUtf8 => InvalidData.into(), Ext4Error::Encrypted => PermissionDenied.into(), diff --git a/src/inode.rs b/src/inode.rs index 9821698..9ad0504 100644 --- a/src/inode.rs +++ b/src/inode.rs @@ -183,6 +183,7 @@ pub struct Inode { impl Inode { const INLINE_DATA_LEN: usize = 60; + pub(crate) const MAX_INLINE_SYMLINK_LEN: usize = Self::INLINE_DATA_LEN - 1; const L_I_CHECKSUM_LO_OFFSET: usize = 0x74 + 0x8; const I_CHECKSUM_HI_OFFSET: usize = 0x82; @@ -300,7 +301,12 @@ impl Inode { let blocks = FileBlocks::initialize(&inode, ext4.clone())?; let inline_data = blocks.to_bytes()?; inode.set_inline_data(inline_data); - inode.write(ext4).await?; + if let Err(error) = inode.write(ext4).await { + // Reclaim the allocated inode if its first initialization write fails. + inode.zero(ext4).await?; + ext4.free_inode(inode).await?; + return Err(error); + } Ok(inode) } @@ -429,18 +435,19 @@ impl Inode { return Err(Ext4Error::NotASymlink); } - // An empty symlink target is not allowed. - if self.size_in_bytes() == 0 { + // Targets must fit, including their terminating NUL, in one block. + if self.size_in_bytes() == 0 + || self.size_in_bytes() >= ext4.0.superblock.block_size().to_u64() + { return Err(CorruptKind::SymlinkTarget(self.index).into()); } - // Symlink targets of up to 59 bytes are stored inline. Longer - // targets are stored as regular file data. - const MAX_INLINE_SYMLINK_LEN: u64 = 59; - - if self.size_in_bytes() <= MAX_INLINE_SYMLINK_LEN { - // OK to unwrap since we checked the size above. - let len = usize::try_from(self.size_in_bytes()).unwrap(); + if self.is_fast_symlink(ext4) { + let len = usize::try_from(self.size_in_bytes()) + .map_err(|_| CorruptKind::SymlinkTarget(self.index))?; + if len > Self::MAX_INLINE_SYMLINK_LEN { + return Err(CorruptKind::SymlinkTarget(self.index).into()); + } let target = &self.inline_data()[..len]; PathBuf::try_from(target) @@ -452,6 +459,23 @@ impl Inode { } } + /// A fast symlink stores its target in i_block, with no data blocks. + /// An external xattr block, if present, still contributes to i_blocks. + pub(crate) fn is_fast_symlink(&self, ext4: &Ext4) -> bool { + let xattr_blocks = if self.file_acl() == 0 { + 0 + } else if self.flags().contains(InodeFlags::HUGE_FILE) { + 1 + } else { + ext4.0.superblock.block_size().to_u64() / 512 + }; + self.file_type.is_symlink() + && !self + .flags() + .intersects(InodeFlags::EXTENTS | InodeFlags::INLINE_DATA) + && self.blocks() == xattr_blocks + } + /// Get the size of the inode fn entry_size(&self) -> NonZeroU16 { if self.inode_data.len() < 0x80 + 2 { @@ -586,8 +610,8 @@ impl Inode { #[must_use] pub fn blocks(&self) -> u64 { let i_blocks_lo = read_u32le(&self.inode_data, 0x1c); - let i_blocks_high = read_u32le(&self.inode_data, 0x74); - u64_from_hilo(i_blocks_high, i_blocks_lo) + let i_blocks_high = read_u16le(&self.inode_data, 0x74); + u64_from_hilo(u32::from(i_blocks_high), i_blocks_lo) } /// Set the number of blocks allocated to the inode. @@ -869,3 +893,36 @@ fn get_inode_location( Ok((block_index, offset_within_block)) } + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use crate::FollowSymlinks; + use crate::test_util::load_test_disk1; + + #[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) + )] + async fn test_fast_symlink_with_high_xattr_address() { + let fs = load_test_disk1().await; + let mut inode = fs + .path_to_inode( + "/sym_simple".try_into().unwrap(), + FollowSymlinks::ExcludeFinalComponent, + ) + .await + .unwrap(); + let expected = inode.symlink_target(&fs).await.unwrap(); + // Only mutate the in-memory inode: no huge filesystem is needed to + // exercise the adjacent high halves of i_blocks and i_file_acl. + let xattr_block = 0x2345_0000_0100; + inode.set_file_acl(xattr_block); + write_u16le(&mut inode.inode_data, 0x74, 0); + write_u32le(&mut inode.inode_data, 0x1c, 2); + assert_eq!(inode.blocks(), 2); + let actual = inode.symlink_target(&fs).await.unwrap(); + assert_eq!(actual, expected); + assert_eq!(inode.file_acl(), xattr_block); + } +} diff --git a/src/lib.rs b/src/lib.rs index bfa30b5..f51ad95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,7 +164,7 @@ use core::time::Duration; use dir::Dir; use error::{CorruptKind, Ext4Error}; use features::ReadOnlyCompatibleFeatures; -use file::{File, write_at}; +use file::File; use file_blocks::FileBlocks; use inode::{ Inode, InodeCreationOptions, InodeFlags, InodeIndex, InodeMode, @@ -382,7 +382,6 @@ impl Ext4 { /// Read data from the exact on-disk block index without applying journal /// remapping. - #[cfg_attr(not(test), expect(dead_code))] #[maybe_async::maybe_async] pub(crate) async fn read_from_block_raw( &self, @@ -1482,8 +1481,11 @@ impl Ext4 { &self, mut inode: Inode, ) -> Result<(), Ext4Error> { - let blocks = FileBlocks::from_inode(&inode, self.clone())?; - blocks.free_all().await?; + // A fast symlink stores target bytes in i_block, not block numbers. + if !inode.is_fast_symlink(self) { + let blocks = FileBlocks::from_inode(&inode, self.clone())?; + blocks.free_all().await?; + } inode.set_size_in_bytes(0); inode.set_links_count(0); inode.write(self).await?; @@ -1547,6 +1549,14 @@ impl Ext4 { /// # Errors /// See [`Dir::link`] for linking errors. Read-only filesystem cannot be written to. /// If an inode cannot be allocated an error is returned as well. + /// Empty targets return [`Ext4Error::MalformedPath`] without allocating an inode. + /// Targets must leave room for a terminating NUL in one filesystem block; + /// longer targets return [`Ext4Error::PathTooLong`] before allocation. + /// Existing names return [`Ext4Error::AlreadyExists`] before allocation. + /// A parent from another filesystem returns [`Ext4Error::CrossDevice`]. + /// The caller must serialize changes to the parent directory. + /// A storage error during directory insertion can occur after the name + /// has been published; in that case the entry and its inode are retained. #[maybe_async::maybe_async] pub async fn symlink( &self, @@ -1557,31 +1567,79 @@ impl Ext4 { gid: u32, time: Duration, ) -> Result { + #[maybe_async::maybe_async] + async fn write_target( + fs: &Ext4, + inode: &mut Inode, + target: &[u8], + ) -> Result<(), Ext4Error> { + if target.len() <= Inode::MAX_INLINE_SYMLINK_LEN { + let mut bytes = [0; 60]; + bytes[..target.len()].copy_from_slice(target); + inode.set_inline_data(bytes); + inode.set_size_in_bytes(u64_from_usize(target.len())); + inode.set_flags(inode.flags().difference(InodeFlags::EXTENTS)); + inode.write(fs).await + } else { + let mut blocks = FileBlocks::from_inode(inode, fs.clone())?; + let result = blocks.write_at(inode, target, 0).await; + // Preserve mappings allocated before a failed data write so + // the caller can reclaim the unpublished inode's blocks. + inode.set_inline_data(blocks.to_bytes()?); + match result { + Ok(count) if count == target.len() => Ok(()), + Ok(_) => Err(Ext4Error::NoSpace), + Err(error) => Err(error), + } + } + } + + if !parent_dir.belongs_to(self) { + return Err(Ext4Error::CrossDevice); + } + if parent_dir.inode().flags().contains(InodeFlags::IMMUTABLE) { + return Err(Ext4Error::Readonly); + } + if target.as_ref().is_empty() { + return Err(Ext4Error::MalformedPath); + } + if target.as_ref().len() >= self.0.superblock.block_size().to_usize() { + return Err(Ext4Error::PathTooLong); + } + match parent_dir.get_entry(name).await { + Ok(_) => return Err(Ext4Error::AlreadyExists), + Err(Ext4Error::NotFound) => (), + Err(error) => return Err(error), + } let mut inode = self .create_inode(InodeCreationOptions { file_type: FileType::Symlink, - mode: InodeMode::S_IFLNK, + mode: InodeMode::S_IFLNK | InodeMode::from_bits_retain(0o777), uid, gid, time, flags: InodeFlags::empty(), }) .await?; - if target.as_ref().len() <= 60 { - // Fast symlink: store the target in the inode itself. - let mut target_bytes = [0; 60]; - target_bytes[..target.as_ref().len()] - .copy_from_slice(target.as_ref()); - inode.set_inline_data(target_bytes); - inode.set_size_in_bytes(u64_from_usize(target.as_ref().len())); - inode.set_flags(inode.flags().difference(InodeFlags::EXTENTS)); - inode.write(self).await?; - } else { - // Slow symlink: store the target in a data block. - let target_bytes = target.as_ref(); - write_at(self, &mut inode, target_bytes, 0).await?; + if let Err(error) = + write_target(self, &mut inode, target.as_ref()).await + { + self.delete_file(inode).await?; + return Err(error); + } + if let Err(error) = parent_dir.link(name, &mut inode).await { + // An insertion can fail after publishing its entry. Only reclaim + // the inode when a lookup confirms that no name points to it. + let unpublished = match parent_dir.get_entry(name).await { + Err(Ext4Error::NotFound) => true, + Ok(existing) => existing.index != inode.index, + Err(_) => false, + }; + if unpublished { + self.delete_file(inode).await?; + } + return Err(error); } - parent_dir.link(name, &mut inode).await?; Ok(inode) } @@ -1971,6 +2029,211 @@ mod tests { }; use test_util::load_test_disk1; + struct RejectOneWrite { + inner: MemRw, + offset: Option, + prefix: Vec, + after_write: bool, + partial_len: Option, + rejected: core::sync::atomic::AtomicBool, + } + + #[cfg_attr(feature = "multi-threaded", maybe_async::maybe_async)] + #[cfg_attr(not(feature = "multi-threaded"), maybe_async::maybe_async(?Send))] + impl Ext4Write for RejectOneWrite { + async fn write( + &self, + start_byte: u64, + bytes: &[u8], + ) -> Result<(), crate::error::BoxedError> { + let matches = self.offset == Some(start_byte) + || (!self.prefix.is_empty() && bytes.starts_with(&self.prefix)); + if matches + && !self + .rejected + .swap(true, core::sync::atomic::Ordering::SeqCst) + { + if self.after_write { + let count = self + .partial_len + .unwrap_or(bytes.len()) + .min(bytes.len()); + self.inner.write(start_byte, &bytes[..count]).await?; + } + return Err(Box::new(std::io::Error::other( + "injected write failure", + ))); + } + self.inner.write(start_byte, bytes).await + } + } + + #[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) + )] + async fn test_symlink_write_failures_preserve_namespace() { + for failure in [ + "inode", + "data", + "entry", + "published_inline", + "published_block", + ] { + let published = + matches!(failure, "published_inline" | "published_block"); + let (mut fs, storage) = + load_compressed_filesystem_rw("test_disk1.bin.zst").await; + let root_inode = fs.read_root_inode().await.unwrap(); + let root_block = FileBlocks::from_inode(&root_inode, fs.clone()) + .unwrap() + .get_block(0) + .await + .unwrap(); + let offset = root_block + .checked_mul(fs.superblock().block_size().to_u64()) + .unwrap(); + let writer = RejectOneWrite { + inner: MemRw(storage.clone()), + offset: if failure == "entry" || published { + Some(offset) + } else { + None + }, + prefix: match failure { + "inode" => vec![0xff, 0xa1, 0, 0], + "data" => b"z/z/".to_vec(), + _ => Vec::new(), + }, + after_write: published, + partial_len: None, + rejected: core::sync::atomic::AtomicBool::new(false), + }; + PtrPrimitive::get_mut(&mut fs.0).unwrap().writer = + Some(Box::new(writer)); + let mut root = Dir::open_inode(&fs, root_inode).unwrap(); + let free_inodes = fs.superblock().free_inodes_count(); + let free_blocks = fs.superblock().free_blocks_count(); + let name = DirEntryName::try_from("failed_symlink").unwrap(); + let target = if matches!(failure, "data" | "published_block") { + "z/".repeat(80) + } else { + "short_target".into() + }; + let result = fs + .symlink( + &mut root, + name, + PathBuf::try_from(target.as_str()).unwrap(), + 0, + 0, + Duration::ZERO, + ) + .await; + assert!(matches!(result, Err(Ext4Error::Io(_)))); + let entry = root.get_entry(name).await; + if published { + let entry = entry.unwrap(); + assert_eq!(entry.links_count(), 1); + let observed = entry.symlink_target(&fs).await.unwrap(); + assert_eq!(observed.as_ref(), target.as_bytes()); + assert_eq!( + fs.superblock().free_inodes_count(), + free_inodes.checked_sub(1).unwrap() + ); + let expected_blocks = if failure == "published_block" { + free_blocks.checked_sub(1).unwrap() + } else { + free_blocks + }; + assert_eq!( + fs.superblock().free_blocks_count(), + expected_blocks + ); + // Verify the retained name also survives a fresh mount. + let reloaded_fs = + Ext4::load(Box::new(MemRw(storage.clone()))).await.unwrap(); + let reloaded_inode = reloaded_fs + .path_to_inode( + "/failed_symlink".try_into().unwrap(), + FollowSymlinks::ExcludeFinalComponent, + ) + .await + .unwrap(); + let observed = + reloaded_inode.symlink_target(&reloaded_fs).await.unwrap(); + assert_eq!(observed.as_ref(), target.as_bytes()); + } else { + assert!(matches!(entry, Err(Ext4Error::NotFound))); + assert_eq!(fs.superblock().free_inodes_count(), free_inodes); + assert_eq!(fs.superblock().free_blocks_count(), free_blocks); + } + test_util::fsck_ext4_arc_image(&storage); + } + } + + #[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) + )] + async fn test_reparent_write_failure_preserves_parent_block() { + for (source, destination) in + [("/dir1", "/medium_dir"), ("/medium_dir", "/dir1")] + { + for partial_len in [None, Some(24usize), Some(1024)] { + let (mut fs, storage) = + load_compressed_filesystem_rw("test_disk1.bin.zst").await; + let old_parent = fs.read_root_inode().await.unwrap().index; + let inode = fs + .path_to_inode( + source.try_into().unwrap(), + FollowSymlinks::All, + ) + .await + .unwrap(); + let new_parent = fs + .path_to_inode( + destination.try_into().unwrap(), + FollowSymlinks::All, + ) + .await + .unwrap() + .index; + let block_index = FileBlocks::from_inode(&inode, fs.clone()) + .unwrap() + .get_block(0) + .await + .unwrap(); + let position = block_index + .checked_mul(fs.superblock().block_size().to_u64()) + .unwrap(); + PtrPrimitive::get_mut(&mut fs.0).unwrap().writer = + Some(Box::new(RejectOneWrite { + inner: MemRw(storage.clone()), + offset: Some(position), + prefix: Vec::new(), + after_write: partial_len.is_some(), + partial_len, + rejected: core::sync::atomic::AtomicBool::new(false), + })); + let mut dir = Dir::open_inode(&fs, inode).unwrap(); + let before = storage.lock().unwrap().clone(); + let result = dir.reparent(old_parent, new_parent).await; + assert!(matches!(result, Err(Ext4Error::Io(_)))); + assert!( + *storage.lock().unwrap() == before, + "failed reparent changed a directory block" + ); + let parent = dir + .get_entry(DirEntryName::try_from("..").unwrap()) + .await + .unwrap(); + assert_eq!(parent.index, old_parent); + test_util::fsck_ext4_arc_image(&storage); + } + } + } + #[maybe_async::test( feature = "sync", async(not(feature = "sync"), tokio::test) diff --git a/src/superblock.rs b/src/superblock.rs index 15db52b..b2cfc51 100644 --- a/src/superblock.rs +++ b/src/superblock.rs @@ -343,6 +343,10 @@ impl Superblock { self.inode_size } + pub(crate) fn inodes_count(&self) -> u32 { + read_u32le(&self.data, 0) + } + /// Number of inodes in a block group, some could be unused. pub fn inodes_per_block_group(&self) -> NonZeroU32 { self.inodes_per_block_group diff --git a/tests/integration/write.rs b/tests/integration/write.rs index 1293298..64ba614 100644 --- a/tests/integration/write.rs +++ b/tests/integration/write.rs @@ -14,7 +14,7 @@ use ext4plus::path::PathBuf; #[cfg(not(feature = "sync"))] use ext4plus::prelude::AsyncIterator; use ext4plus::prelude::{ - Dir, DirEntryName, Ext4Error, File, FileType, FollowSymlinks, Inode, + Dir, DirEntryName, Ext4, Ext4Error, File, FileType, FollowSymlinks, Inode, InodeCreationOptions, InodeFlags, InodeMode, Path, truncate, write_at, }; @@ -25,6 +25,450 @@ pub async fn load_ext2_rw() -> Ext4Wrapper { Ext4Wrapper(fs, data) } +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_symlink_creation_boundaries() { + for fs in [load_test_disk1_rw().await, load_ext2_rw().await] { + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + for len in [1usize, 13, 59, 60, 61, 120, 1023] { + let name = format!("new_symlink_{len}"); + let mut target = "t/".repeat(len.div_ceil(2)); + target.truncate(len); + let inode = fs + .symlink( + &mut root, + DirEntryName::try_from(name.as_str()).unwrap(), + PathBuf::try_from(target.as_str()).unwrap(), + 0, + 0, + Default::default(), + ) + .await + .unwrap(); + let reloaded = Inode::read(&fs, inode.index).await.unwrap(); + assert_eq!(reloaded.mode().bits(), 0o120777); + let observed_target = reloaded.symlink_target(&fs).await.unwrap(); + assert_eq!(observed_target.as_ref(), target.as_bytes()); + assert_eq!(reloaded.fs_blocks(&fs).unwrap() == 0, len < 60); + } + } +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_symlink_unlink_preserves_hard_links() { + for fs in [load_test_disk1_rw().await, load_ext2_rw().await] { + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + let name = DirEntryName::try_from("new_symlink").unwrap(); + let alias = DirEntryName::try_from("symlink_hard_link").unwrap(); + for len in [1usize, 13, 59, 60, 61, 120, 1023] { + let mut target = "t/".repeat(len.div_ceil(2)); + target.truncate(len); + let mut inode = fs + .symlink( + &mut root, + name, + PathBuf::try_from(target.as_str()).unwrap(), + 0, + 0, + Default::default(), + ) + .await + .unwrap(); + root.link(alias, &mut inode).await.unwrap(); + let mut remaining = + root.unlink(name, inode).await.unwrap().unwrap(); + assert_eq!(remaining.links_count(), 1); + let removed = root.get_entry(name).await; + assert!(matches!(removed, Err(Ext4Error::NotFound))); + let reloaded = root.get_entry(alias).await.unwrap(); + assert_eq!(reloaded.index, remaining.index); + let observed_target = reloaded.symlink_target(&fs).await.unwrap(); + assert_eq!(observed_target.as_ref(), target.as_bytes()); + // The raw unlink API leaves deletion timestamps to its caller. + remaining.set_dtime(core::time::Duration::from_secs(5000)); + let remaining = root.unlink(alias, remaining).await.unwrap(); + assert!(remaining.is_none()); + let removed = root.get_entry(alias).await; + assert!(matches!(removed, Err(Ext4Error::NotFound))); + let contents = fs.read("/small_file").await.unwrap(); + assert_eq!(contents, b"hello, world!"); + } + } +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_symlink_empty_target_does_not_allocate() { + let fs = load_test_disk1_rw().await; + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + let before = fs.1.lock().unwrap().clone(); + let result = fs + .symlink( + &mut root, + DirEntryName::try_from("empty_symlink").unwrap(), + PathBuf::empty(), + 0, + 0, + Default::default(), + ) + .await; + assert!(matches!(result, Err(Ext4Error::MalformedPath))); + assert!( + *fs.1.lock().unwrap() == before, + "empty target changed the image" + ); +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_symlink_existing_name_does_not_allocate() { + let fs = load_test_disk1_rw().await; + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + for name in ["small_file", "dir1", "sym_simple", ".", ".."] { + let before = fs.1.lock().unwrap().clone(); + let result = fs + .symlink( + &mut root, + DirEntryName::try_from(name).unwrap(), + PathBuf::try_from("target").unwrap(), + 0, + 0, + Default::default(), + ) + .await; + assert!(matches!(result, Err(Ext4Error::AlreadyExists))); + assert!( + *fs.1.lock().unwrap() == before, + "existing name {name} changed the image" + ); + } +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_symlink_block_size_limit_does_not_allocate() { + for fs in [load_test_disk1_rw().await, load_ext2_rw().await] { + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + let before = fs.1.lock().unwrap().clone(); + for len in [1024usize, 1025, 2049] { + let mut target = "t/".repeat(len.div_ceil(2)); + target.truncate(len); + let result = fs + .symlink( + &mut root, + DirEntryName::try_from("too_long_symlink").unwrap(), + PathBuf::try_from(target.as_str()).unwrap(), + 0, + 0, + Default::default(), + ) + .await; + assert!(matches!(result, Err(Ext4Error::PathTooLong))); + assert!( + *fs.1.lock().unwrap() == before, + "oversized target changed the image" + ); + } + } +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_symlink_rejects_foreign_and_immutable_parent() { + let fs = load_test_disk1_rw().await; + let other = load_ext2_rw().await; + let before = fs.1.lock().unwrap().clone(); + let other_before = other.1.lock().unwrap().clone(); + let mut foreign = + Dir::open_inode(&other, other.read_root_inode().await.unwrap()) + .unwrap(); + let result = fs + .symlink( + &mut foreign, + DirEntryName::try_from("foreign_symlink").unwrap(), + PathBuf::try_from("target").unwrap(), + 0, + 0, + Default::default(), + ) + .await; + assert!(matches!(result, Err(Ext4Error::CrossDevice))); + assert!( + *other.1.lock().unwrap() == other_before, + "foreign parent changed the image" + ); + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + let flags = root.inode().flags(); + root.inode_mut().set_flags(flags | InodeFlags::IMMUTABLE); + let result = fs + .symlink( + &mut root, + DirEntryName::try_from("immutable_symlink").unwrap(), + PathBuf::try_from("target").unwrap(), + 0, + 0, + Default::default(), + ) + .await; + assert!(matches!(result, Err(Ext4Error::Readonly))); + assert!( + *fs.1.lock().unwrap() == before, + "invalid parent changed the image" + ); +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_short_block_backed_symlink() { + for fs in [load_test_disk1_rw().await, load_ext2_rw().await] { + let mut inode = fs + .create_inode(InodeCreationOptions { + file_type: FileType::Regular, + mode: InodeMode::S_IFREG | InodeMode::from_bits_retain(0o777), + uid: 0, + gid: 0, + time: Default::default(), + flags: InodeFlags::empty(), + }) + .await + .unwrap(); + let target = b"short_target"; + let written = write_at(&fs, &mut inode, target, 0).await.unwrap(); + assert_eq!(written, target.len()); + inode + .set_mode(InodeMode::S_IFLNK | InodeMode::from_bits_retain(0o777)) + .unwrap(); + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + let name = DirEntryName::try_from("block_backed_symlink").unwrap(); + root.link(name, &mut inode).await.unwrap(); + let mut inode = root.get_entry(name).await.unwrap(); + let observed_target = inode.symlink_target(&fs).await.unwrap(); + assert_eq!(observed_target.as_ref(), target); + inode.set_dtime(core::time::Duration::from_secs(5000)); + let remaining = root.unlink(name, inode).await.unwrap(); + assert!(remaining.is_none()); + } +} + +#[maybe_async::maybe_async] +async fn create_test_directory(fs: &Ext4, parent: &mut Dir, name: &str) -> Dir { + let inode = fs + .create_inode(InodeCreationOptions { + file_type: FileType::Directory, + mode: InodeMode::S_IFDIR | InodeMode::from_bits_retain(0o755), + uid: 0, + gid: 0, + time: Default::default(), + flags: InodeFlags::empty(), + }) + .await + .unwrap(); + let mut dir = Dir::init(fs.clone(), inode, parent.inode().index) + .await + .unwrap(); + parent + .link(DirEntryName::try_from(name).unwrap(), dir.inode_mut()) + .await + .unwrap(); + dir +} + +#[maybe_async::maybe_async] +async fn check_directory_move( + fs: &Ext4, + old_parent: &mut Dir, + new_parent: &mut Dir, + name: &str, + block_size: usize, +) { + let entry = DirEntryName::try_from(name).unwrap(); + let inode = old_parent.get_entry(entry).await.unwrap(); + let mut moved = Dir::open_inode(fs, inode).unwrap(); + let entries_before = moved.read_dir().unwrap().collect::>().await; + let bytes_before = fs.read_inode_file(moved.inode()).await.unwrap(); + let flags_before = moved.inode().flags(); + let blocks_before = moved.inode().blocks(); + + new_parent.link(entry, moved.inode_mut()).await.unwrap(); + *moved.inode_mut() = old_parent + .unlink(entry, moved.inode().clone()) + .await + .unwrap() + .unwrap(); + // The raw reparent API deliberately leaves parent link counts to callers. + let old_links = old_parent.inode().links_count(); + old_parent.inode_mut().set_links_count(old_links - 1); + old_parent.inode_mut().write(fs).await.unwrap(); + let new_links = new_parent.inode().links_count(); + let child_links = moved.inode().links_count(); + moved + .reparent(old_parent.inode().index, new_parent.inode().index) + .await + .unwrap(); + assert_eq!(new_parent.inode().links_count(), new_links); + assert_eq!(moved.inode().links_count(), child_links); + assert_eq!(moved.inode().flags(), flags_before); + assert_eq!(moved.inode().blocks(), blocks_before); + + let reloaded = Inode::read(fs, moved.inode().index).await.unwrap(); + let mut moved = Dir::open_inode(fs, reloaded).unwrap(); + let observed_parent = moved + .get_entry(DirEntryName::try_from("..").unwrap()) + .await + .unwrap(); + assert_eq!(observed_parent.index, new_parent.inode().index); + // Repeating a move to the current parent must also be harmless. + moved + .reparent(new_parent.inode().index, new_parent.inode().index) + .await + .unwrap(); + let entries_after = moved.read_dir().unwrap().collect::>().await; + assert_eq!(entries_before.len(), entries_after.len()); + for (before, after) in entries_before.into_iter().zip(entries_after) { + let before = before.unwrap(); + let after = after.unwrap(); + assert_eq!(before.file_name(), after.file_name()); + if after.file_name() != ".." { + assert_eq!(before.inode, after.inode); + // Exercise indexed lookups as well as directory iteration. + let looked_up = moved.get_entry(after.file_name()).await.unwrap(); + assert_eq!(looked_up.index, after.inode); + } + } + // No index nodes or directory records other than ".." may be rebuilt. + let bytes_after = fs.read_inode_file(moved.inode()).await.unwrap(); + // METADATA_CSUM in s_feature_ro_compat; the flag type is not exported. + let unchanged_end = if fs + .superblock() + .features() + .read_only_compatible() + .bits() + & 0x400 + != 0 + { + block_size.checked_sub(4).unwrap() + } else { + block_size + }; + assert_eq!(&bytes_before[..12], &bytes_after[..12]); + assert_eq!( + &bytes_before[16..unchanged_end], + &bytes_after[16..unchanged_end] + ); + assert_eq!(&bytes_before[block_size..], &bytes_after[block_size..]); +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_reparent_preserves_linear_and_indexed_directories() { + for (fs, indexed) in [ + (load_test_disk1_rw().await, true), + (load_ext2_rw().await, false), + ] { + let mut root = + Dir::open_inode(&fs, fs.read_root_inode().await.unwrap()).unwrap(); + let mut parent = + create_test_directory(&fs, &mut root, "move_parent").await; + create_test_directory(&fs, &mut root, "move_child").await; + check_directory_move(&fs, &mut root, &mut parent, "move_child", 1024) + .await; + if indexed { + for name in ["medium_dir", "big_dir"] { + let inode = root + .get_entry(DirEntryName::try_from(name).unwrap()) + .await + .unwrap(); + assert!(inode.flags().contains(InodeFlags::DIRECTORY_HTREE)); + check_directory_move(&fs, &mut root, &mut parent, name, 1024) + .await; + } + } + let image = fs.1.lock().unwrap().clone(); + let reloaded_fs = Ext4::load(Box::new(image)).await.unwrap(); + let parent_index = reloaded_fs + .path_to_inode( + Path::new("/move_parent/move_child/.."), + FollowSymlinks::All, + ) + .await + .unwrap() + .index; + assert_eq!(parent_index, parent.inode().index); + } +} + +#[maybe_async::test( + feature = "sync", + async(not(feature = "sync"), tokio::test) +)] +async fn test_reparent_rejects_invalid_requests_without_writing() { + let fs = load_test_disk1_rw().await; + let old_parent = fs.read_root_inode().await.unwrap().index; + let inode = fs + .path_to_inode(Path::new("/medium_dir"), FollowSymlinks::All) + .await + .unwrap(); + let file = fs + .path_to_inode(Path::new("/small_file"), FollowSymlinks::All) + .await + .unwrap(); + let mut dir = Dir::open_inode(&fs, inode.clone()).unwrap(); + let before = fs.1.lock().unwrap().clone(); + let result = dir.reparent(dir.inode().index, old_parent).await; + assert!(matches!(result, Err(Ext4Error::Corrupt(_)))); + let result = dir.reparent(old_parent, file.index).await; + assert!(matches!(result, Err(Ext4Error::NotADirectory))); + let invalid = core::num::NonZeroU32::new(u32::MAX).unwrap(); + let result = dir.reparent(old_parent, invalid).await; + assert!(matches!(result, Err(Ext4Error::Corrupt(_)))); + for (flag, encrypted) in [ + (InodeFlags::DIRECTORY_ENCRYPTED, true), + (InodeFlags::IMMUTABLE, false), + ] { + dir.inode_mut().set_flags(inode.flags() | flag); + let error = dir.reparent(old_parent, inode.index).await.unwrap_err(); + if encrypted { + assert!(matches!(error, Ext4Error::Encrypted)); + } else { + assert!(matches!(error, Ext4Error::Readonly)); + } + } + let readonly = Ext4::load(Box::new(before.clone())).await.unwrap(); + let mut dir = Dir::open_inode(&readonly, inode).unwrap(); + let result = dir.reparent(old_parent, dir.inode().index).await; + assert!(matches!(result, Err(Ext4Error::Readonly))); + let result = dir.reparent(old_parent, old_parent).await; + assert!(matches!(result, Err(Ext4Error::Readonly))); + assert!( + *fs.1.lock().unwrap() == before, + "invalid request changed the image" + ); +} + #[maybe_async::test( feature = "sync", async(not(feature = "sync"), tokio::test)