From 527fae29762aef9cf0e7ac7b1c7b607c774a5941 Mon Sep 17 00:00:00 2001 From: flowerishere <1991348185@qq.com> Date: Wed, 14 Oct 2026 10:07:47 +0800 Subject: [PATCH 1/2] Fix symlink storage and inode reclamation Use inode block accounting to distinguish fast symlinks from block-backed targets and skip block-map traversal when freeing inline targets. Store 60-byte targets in a data block and reject empty or oversized targets before allocating an inode. Validate the parent and check for existing names before creation. Reclaim unpublished inodes on initialization or target-write failures, while retaining entries already published when directory insertion reports an I/O error. Add ext2/ext4 boundary, hard-link, invalid-request, and injected-write failure coverage. Validate all eight std/sync/multi-threaded feature combinations with filesystem checks enabled. --- src/dir.rs | 4 + src/error.rs | 7 ++ src/inode.rs | 81 ++++++++++-- src/lib.rs | 232 +++++++++++++++++++++++++++++++--- tests/integration/write.rs | 247 +++++++++++++++++++++++++++++++++++++ 5 files changed, 541 insertions(+), 30 deletions(-) diff --git a/src/dir.rs b/src/dir.rs index bc782bb..a88dc4f 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( 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..9952495 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, @@ -1482,8 +1482,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 +1550,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 +1568,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 +2030,143 @@ mod tests { }; use test_util::load_test_disk1; + struct RejectOneWrite { + inner: MemRw, + offset: Option, + prefix: Vec, + after_write: bool, + 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 { + self.inner.write(start_byte, bytes).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, + 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) diff --git a/tests/integration/write.rs b/tests/integration/write.rs index 1293298..7ea3866 100644 --- a/tests/integration/write.rs +++ b/tests/integration/write.rs @@ -25,6 +25,253 @@ 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::test( feature = "sync", async(not(feature = "sync"), tokio::test) From 90aac330d9fe0d4fa617717810280f84e45a752f Mon Sep 17 00:00:00 2001 From: flowerishere <1991348185@qq.com> Date: Wed, 14 Oct 2026 10:10:05 +0800 Subject: [PATCH 2/2] Add directory reparenting while preserving htree indexes Expose Dir::reparent(old_parent, new_parent) as a raw directory rename building block. Validate the dot records, htree layout, and destination inode before changing only the .. inode number and first-block checksum. Leave namespace moves, cycle prevention, and parent link counts to callers. Reject corrupt htree counts before checksum slicing and attempt to restore the original disk block when the update reports a write failure. Cover linear directories, htree roots at two depths, invalid layouts and requests, no-op calls, and errors before or after partial/full writes. --- src/dir.rs | 279 ++++++++++++++++++++++++++++++++++++- src/dir_block.rs | 64 +++++++-- src/lib.rs | 71 +++++++++- src/superblock.rs | 4 + tests/integration/write.rs | 199 +++++++++++++++++++++++++- 5 files changed, 605 insertions(+), 12 deletions(-) diff --git a/src/dir.rs b/src/dir.rs index a88dc4f..971abe0 100644 --- a/src/dir.rs +++ b/src/dir.rs @@ -673,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). /// @@ -1234,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/lib.rs b/src/lib.rs index 9952495..f51ad95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, @@ -2035,6 +2034,7 @@ mod tests { offset: Option, prefix: Vec, after_write: bool, + partial_len: Option, rejected: core::sync::atomic::AtomicBool, } @@ -2054,7 +2054,11 @@ mod tests { .swap(true, core::sync::atomic::Ordering::SeqCst) { if self.after_write { - self.inner.write(start_byte, bytes).await?; + 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", @@ -2102,6 +2106,7 @@ mod tests { _ => Vec::new(), }, after_write: published, + partial_len: None, rejected: core::sync::atomic::AtomicBool::new(false), }; PtrPrimitive::get_mut(&mut fs.0).unwrap().writer = @@ -2167,6 +2172,68 @@ mod tests { } } + #[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 7ea3866..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, }; @@ -272,6 +272,203 @@ async fn test_short_block_backed_symlink() { } } +#[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)