From 1c37c0544a559c1a419ad7edb3f26a84a5c8b20b Mon Sep 17 00:00:00 2001 From: zgyxyd666 <1170312156@qq.com> Date: Mon, 10 Aug 2026 17:29:30 +0800 Subject: [PATCH 1/3] fs: support ext4 extra isize xattr migration --- fs/filesystems/kext4/docs/design.md | 12 +- fs/filesystems/kext4/docs/security.md | 8 +- fs/filesystems/kext4/src/inode.rs | 9 +- fs/filesystems/kext4/src/jbd2/transaction.rs | 37 +- fs/filesystems/kext4/src/journal.rs | 2 +- fs/filesystems/kext4/src/xattr.rs | 639 ++++++++++++++++--- 6 files changed, 608 insertions(+), 99 deletions(-) diff --git a/fs/filesystems/kext4/docs/design.md b/fs/filesystems/kext4/docs/design.md index 1cbd754cd..2fab40d29 100644 --- a/fs/filesystems/kext4/docs/design.md +++ b/fs/filesystems/kext4/docs/design.md @@ -292,14 +292,22 @@ Create、mkdir、mknod 和 symlink 的 KVFS bridge callback 接收同一次操 也不提供固定 root owner 的运行态默认值;测试镜像构造必须显式传入其 fixture owner。 Xattr 修改会把 inline xattr 和 external xattr 解码一次,在同一份 mutation plan 中完成 -存在性检查、值更新、存储布局选择与 journal credits 计算,再选择 inode-body 或 single -external-block 存储,维护 `i_file_acl`、`i_blocks`、block checksum 和 refcount。 +存在性检查、值更新、存储布局选择与 journal credits 计算。布局规划器把 inode body 和 single +external block 作为两个可同时使用的容量区间,以 entry 编码大小做有界 subset-sum,避免 +“整组放 inode 或整组放 block”造成的假 `ENOSPC`;提交时同时维护 `i_file_acl`、`i_blocks`、 +block checksum 和 refcount。 `Ext4XattrSetMode` 表达无标志、create、replace 和 create+replace 四种组合;组合标志在属性 存在时返回 `EEXIST`,缺失时返回 `ENODATA`,不会通过 bridge 的锁外预查实现。允许替换时, 若现有值逐字节相同,core 在 journal handle、metadata write 和 ctime 更新前返回原 inode。 `Ext4Inode` 从磁盘 `i_flags` 暴露 immutable 和 append-only 状态;bridge 在 iget 时把它们映射 为 KVFS `NodeFlags`,使通用 xattr 权限层在进入 namespace 或 KExt4 mutation 前返回 `EPERM`。 +现有 inode 在 regular-file metadata 被写脏时会尝试把 `i_extra_isize` 扩到 superblock 的 +`want_extra_isize`。目标布局容纳不下全部 xattr 时退到 `min_extra_isize`,仍不成立则保留当前 +大小而继续普通 metadata 更新。需要缩小 inline 区域时,规划器在同一 journal transaction 中 +把选中的属性迁移到 external block,再移动 inode-body 编码并更新 `i_extra_isize`;handle 只为 +新增的 external metadata targets 扩展 credits,且扩展不得越过当前 journal transaction limit。 + `list_xattrs()` 使用 `Ext4XattrNameSink` 逐项借用已校验的磁盘名称,只验证 value range 而不 复制 value。KVFS bridge 在 sink 中添加 `user.*`、`trusted.*`、`security.*` 前缀并继续流式 传递,不构造 `Ext4Xattr` 或完整名称中间向量。成功 mutation 后 bridge 把 core inode ctime diff --git a/fs/filesystems/kext4/docs/security.md b/fs/filesystems/kext4/docs/security.md index 15d69c802..f00bbf594 100644 --- a/fs/filesystems/kext4/docs/security.md +++ b/fs/filesystems/kext4/docs/security.md @@ -100,6 +100,10 @@ KVFS bridge 信任 KVFS 已经提供内核拥有的 path name、dentry、inode ` e2fsck 修复后的合法块;启用 metadata checksum 时仍由完整 block checksum 检测意外修改。 - 相同 xattr value 的允许替换必须在 journal credit 准备和 metadata access 前短路,不能更新 inode ctime,也不能产生无意义的 journal 写入。 +- 扩大 `i_extra_isize` 前必须先证明全部 xattr 可在缩小后的 inode body 与一个 external block + 中完整分区;迁移、`i_file_acl`/`i_blocks` 和 inode checksum 更新必须属于同一 transaction。 + `want_extra_isize` 无法满足时只能退到 `min_extra_isize` 或保留当前值,不能丢弃属性或阻断 + 本来合法的普通 inode metadata 更新。 - 运行态 inode allocation 必须使用 bridge 已通过 `inode_init_owner()` 导出的显式 UID/GID;KExt4 core 不得把新 inode owner 默认为 root。 - FIEMAP 的 logical/physical/length 乘法和加法必须 checked;遍历必须同时受请求末端与 @@ -152,6 +156,7 @@ exclusive data lock 下先把 hole reservation 发布到同一个 delayed set, | T-12 | 磁盘 inode 的 immutable/append-only 状态在 bridge 中丢失,导致 xattr 被修改 | 中 | iget 构造 KVFS inode 时总是使用空 `NodeFlags` | core 以语义方法暴露 `EXT4_IMMUTABLE_FL/EXT4_APPEND_FL`;bridge 在发布 VFS inode identity 时映射为 KVFS flags,由通用 xattr 权限层在 mutation 前返回 `EPERM` | | T-13 | 同一 inode 出现 snapshot 分叉,或 inode number reuse 继承旧 transient state | 中 | KVFS 初始化/释放期间并发 `iget`,core namei 按编号重载 live child,或 orphan removal 为 resident 前驱解码临时对象 | KVFS `New/Live/Freeing` cache 是唯一 identity table;`New/Freeing` 等待并重试;bridge 向 unlink/rmdir/rename 传入既有 private state;legacy orphan next 只读写 journaled inode-table bytes;KExt4 无 resident cache;reuse 只能在 `Freeing` 完成并删除旧 slot 后发生 | | T-14 | shrink 后重新增长暴露旧 PageCache 数据 | 高 | ext4 backing prepare 提前把 VFS `i_size` 改成目标值,导致 `truncate_setsize()` 误判长度未变并跳过 folio 丢弃或 EOF 清零 | regular-file metadata publish 只更新 `i_disksize`;唯一 `i_size` 由 VFS 在 PageCache 顺序点发布;core prepare 与 KVFS shrink/regrow、partial-grow 回归测试共同约束该职责边界 | +| T-15 | 扩大 `i_extra_isize` 时覆盖或丢失 inode-body xattr | 高 | 新 extra fields 与旧 inline 区间重叠,或只校验单一区域容量后开始迁移 | 先对 inline/external 两区执行完整布局规划;目标值失败依次退到 minimum/current;同一 journal transaction 重写 external block、清理旧 inline 区、编码新布局并更新 inode checksum,单元测试覆盖 ext4/022 的 720/656/640 三种结果 | ## 故障模式与影响分析(FMEA) @@ -219,7 +224,8 @@ KExt4 会存储并返回 filesystem data 和 metadata,其中 xattr value 可 - metadata parser 是否拒绝 truncated、out-of-bounds、unsorted 或 checksum-invalid input? - xattr list 是否只借用名称,同时继续校验 value range 和 external block checksum?相同值 set 是否在 journal/ctime 更新前返回?磁盘 immutable/append-only flags 是否在 iget 时 - 映射到 KVFS,并在任何 xattr mutation 前返回 `EPERM`? + 映射到 KVFS,并在任何 xattr mutation 前返回 `EPERM`?inline/external 混合布局是否保存 + 全部属性,extra-isize 扩展失败是否安全回退而不影响普通 metadata 更新? - 每个 mutation 是否为所有可能 dirty 或 revoke 的 metadata block 预留了足够 journal credits? - 线性目录转 HTree 后立即 split 时,extent 预检是否把两次独立 block allocation 视为两个 最坏情况下不合并的 mapping,并在 `EXT4_INDEX_FL` 发布前预留 HTree credits? diff --git a/fs/filesystems/kext4/src/inode.rs b/fs/filesystems/kext4/src/inode.rs index 8550386ef..8d21622a1 100644 --- a/fs/filesystems/kext4/src/inode.rs +++ b/fs/filesystems/kext4/src/inode.rs @@ -423,6 +423,7 @@ pub(crate) struct Ext4InodeMetadata { flags: u32, block: [u8; disk_inode::INODE_BLOCK_BYTES], file_acl: u64, + extra_isize: u16, inline_xattr: Vec, generation: u32, links_count: u16, @@ -457,6 +458,7 @@ impl Ext4InodeMetadata { flags: raw.flags(), block: *raw.block(), file_acl: raw.file_acl(), + extra_isize: raw.extra_isize(), inline_xattr: Vec::from(raw.inline_xattr()), generation: raw.generation(), links_count: raw.links_count(), @@ -842,6 +844,10 @@ impl Ext4Inode { self.with_metadata(|metadata| metadata.file_acl) } + pub(crate) fn extra_isize(&self) -> u16 { + self.with_metadata(|metadata| metadata.extra_isize) + } + pub(crate) fn inline_xattr_bytes(&self) -> Vec { self.with_metadata(|metadata| metadata.inline_xattr.clone()) } @@ -1511,7 +1517,7 @@ impl Ext4Filesystem { } pub(crate) fn update_regular_inode_write_metadata( - &self, + &mut self, inode: &Ext4Inode, new_disk_size: u64, metadata: RegularWriteMetadata, @@ -1522,6 +1528,7 @@ impl Ext4Filesystem { return Err(Ext4Error::Unsupported(UnsupportedKind::FileSizeShrink)); } + self.try_expand_inode_extra_isize(inode, handle)?; self.update_regular_inode_size_metadata(inode, new_disk_size, metadata, handle) } diff --git a/fs/filesystems/kext4/src/jbd2/transaction.rs b/fs/filesystems/kext4/src/jbd2/transaction.rs index 0b181f6e3..db07227ea 100644 --- a/fs/filesystems/kext4/src/jbd2/transaction.rs +++ b/fs/filesystems/kext4/src/jbd2/transaction.rs @@ -91,7 +91,6 @@ impl RunningTransactionState { Ok(()) } - #[cfg(test)] fn reserve(&mut self, credits: JournalCredits) -> Ext4Result<()> { self.reserved_credits = self .reserved_credits @@ -918,10 +917,26 @@ impl<'a> JournalHandle<'a> { self.remaining_credits } - /// Reserves additional credits for this handle and transaction. - #[cfg(test)] - pub fn reserve_more(&mut self, credits: JournalCredits) -> Ext4Result<()> { + /// Reserves additional credits without exceeding the running transaction's + /// journal-space limit. + /// + /// # Errors + /// + /// Returns [`Ext4Error::InsufficientJournalCredits`] when the extended + /// reservation would exceed `max_reserved_credits`. + pub(crate) fn reserve_more( + &mut self, + credits: JournalCredits, + max_reserved_credits: u32, + ) -> Ext4Result<()> { self.journal.with_running(self.id, |running| { + let projected_credits = running + .reserved_credits + .checked_add(credits.get()) + .ok_or(Ext4Error::Overflow)?; + if projected_credits > max_reserved_credits { + return Err(Ext4Error::InsufficientJournalCredits); + } running.reserve(credits)?; self.remaining_credits = self .remaining_credits @@ -1062,13 +1077,25 @@ mod tests { handle .consume_metadata_credit(FilesystemBlock::new(1)) .unwrap(); - handle.reserve_more(JournalCredits::new(2)).unwrap(); + handle.reserve_more(JournalCredits::new(2), 3).unwrap(); assert_eq!(handle.remaining_credits(), 2); handle .consume_metadata_credit(FilesystemBlock::new(2)) .unwrap(); } + #[test] + fn reserve_more_rejects_transaction_limit_overflow() { + let journal = JournalTransactions::new(TransactionId::new(8)); + let mut handle = journal.begin(JournalCredits::new(2)).unwrap(); + + assert_eq!( + handle.reserve_more(JournalCredits::new(2), 3), + Err(Ext4Error::InsufficientJournalCredits) + ); + assert_eq!(handle.remaining_credits(), 2); + } + #[test] fn repeated_metadata_block_consumes_one_credit() { let journal = JournalTransactions::new(TransactionId::new(10)); diff --git a/fs/filesystems/kext4/src/journal.rs b/fs/filesystems/kext4/src/journal.rs index 4ac41a519..f0810d213 100644 --- a/fs/filesystems/kext4/src/journal.rs +++ b/fs/filesystems/kext4/src/journal.rs @@ -258,7 +258,7 @@ impl Ext4Filesystem { Ok(journal) } - fn running_transaction_credit_limit(&self) -> Ext4Result { + pub(crate) fn running_transaction_credit_limit(&self) -> Ext4Result { self.journal .as_ref() .ok_or(Ext4Error::Unsupported(UnsupportedKind::JournaledWrite))? diff --git a/fs/filesystems/kext4/src/xattr.rs b/fs/filesystems/kext4/src/xattr.rs index a5f752d12..f2fa65d2c 100644 --- a/fs/filesystems/kext4/src/xattr.rs +++ b/fs/filesystems/kext4/src/xattr.rs @@ -2,7 +2,7 @@ // Copyright 2025 KylinSoft Co., Ltd. // See LICENSES for license details. -use alloc::vec::Vec; +use alloc::{vec, vec::Vec}; use crate::{ ChecksumTarget, CorruptKind, Ext4Error, Ext4Filesystem, Ext4Result, FilesystemBlock, @@ -190,6 +190,29 @@ enum XattrMutation { Unchanged, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct XattrStoragePlan { + inline: Vec, + external: Vec, +} + +impl XattrStoragePlan { + fn layout(&self) -> XattrStorageLayout { + if !self.external.is_empty() { + XattrStorageLayout::External { shared: false } + } else { + xattr_inline_layout(&self.inline) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InodeXattrStoragePlan { + extra_isize: u16, + storage: XattrStoragePlan, + rewrite_external: bool, +} + impl Ext4Filesystem { /// Reads all supported extended attributes stored on an inode. pub fn read_xattrs(&self, inode: &Ext4Inode) -> Ext4Result> { @@ -230,9 +253,9 @@ impl Ext4Filesystem { /// Sets or replaces an extended attribute. /// /// This R9 baseline supports the common `user`, `trusted`, and `security` - /// namespaces, plus opaque POSIX ACL xattr storage. The updated xattr set is - /// kept in the inode body when it fits, otherwise a single external xattr - /// block is created or replaced with refcount/checksum maintenance. + /// namespaces, plus opaque POSIX ACL xattr storage. The updated xattr set + /// may be split between the inode body and one external xattr block, with + /// refcount and checksum maintenance for the external part. pub fn set_xattr( &mut self, inode: &Ext4Inode, @@ -263,7 +286,7 @@ impl Ext4Filesystem { ) -> Ext4Result<()> { validate_settable_xattr(namespace, name)?; self.validate_inode_timestamp_update(inode, timestamp)?; - let (credits, xattrs, mutation) = self.xattr_mutation_plan(inode, timestamp, |xattrs| { + let (credits, plan, mutation) = self.xattr_mutation_plan(inode, timestamp, |xattrs| { set_xattr_value_with_mode(xattrs, namespace, name, value, mode) })?; if mutation == XattrMutation::Unchanged { @@ -274,7 +297,7 @@ impl Ext4Filesystem { crate::journal::RecoveryFlagPolicy::ClearAfterCheckpoint, )?; let mut handle = journal.begin(credits)?; - let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, xattrs); + let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, plan); self.complete_metadata_mutation(handle, result) } @@ -288,7 +311,7 @@ impl Ext4Filesystem { ) -> Ext4Result<()> { validate_settable_xattr(namespace, name)?; self.validate_inode_timestamp_update(inode, timestamp)?; - let (credits, xattrs, _) = self.xattr_mutation_plan(inode, timestamp, |xattrs| { + let (credits, plan, _) = self.xattr_mutation_plan(inode, timestamp, |xattrs| { remove_xattr_value(xattrs, namespace, name).map(|_| XattrMutation::Changed) })?; let journal = self.metadata_journal_for_mutation( @@ -296,7 +319,7 @@ impl Ext4Filesystem { crate::journal::RecoveryFlagPolicy::ClearAfterCheckpoint, )?; let mut handle = journal.begin(credits)?; - let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, xattrs); + let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, plan); self.complete_metadata_mutation(handle, result) } @@ -305,23 +328,31 @@ impl Ext4Filesystem { inode: &Ext4Inode, timestamp: Ext4Timestamp, handle: &mut JournalHandle<'_>, - xattrs: Vec, + plan: InodeXattrStoragePlan, ) -> Ext4Result<()> { - let old_external_block = inode.file_acl_block(); + self.apply_xattr_storage_plan(inode, plan, Some(timestamp), handle) + } - let inline_capacity = inode.inline_xattr_bytes().len(); - let needs_external_block = - !xattrs.is_empty() && inline_xattr_encoded_len(&xattrs)? > inline_capacity; - let new_external_block = if !needs_external_block { + fn apply_xattr_storage_plan( + &mut self, + inode: &Ext4Inode, + plan: InodeXattrStoragePlan, + timestamp: Option, + handle: &mut JournalHandle<'_>, + ) -> Ext4Result<()> { + let old_external_block = inode.file_acl_block(); + let new_external_block = if plan.storage.external.is_empty() { None + } else if !plan.rewrite_external && old_external_block != 0 { + Some(FilesystemBlock::new(old_external_block)) } else if old_external_block != 0 && self.external_xattr_block_refcount(inode, old_external_block)? == 1 { let block = FilesystemBlock::new(old_external_block); - self.write_external_xattr_block(block, &xattrs, handle)?; + self.write_external_xattr_block(block, &plan.storage.external, handle)?; Some(block) } else { - Some(self.create_external_xattr_block(&xattrs, handle)?) + Some(self.create_external_xattr_block(&plan.storage.external, handle)?) }; if old_external_block != 0 && new_external_block.is_none_or(|block| block.get() != old_external_block) @@ -330,7 +361,7 @@ impl Ext4Filesystem { } let (inode_table_block, inode_table_bytes, updated_inode) = - self.prepare_xattr_inode_update(inode, &xattrs, new_external_block, timestamp)?; + self.prepare_xattr_inode_update(inode, &plan, new_external_block, timestamp)?; let inode_table_access = self.metadata_io.write_access(inode_table_block, handle)?; replace_metadata_access_bytes(&inode_table_access, inode_table_bytes)?; self.publish_inode_metadata(inode, updated_inode) @@ -341,13 +372,14 @@ impl Ext4Filesystem { inode: &Ext4Inode, timestamp: Ext4Timestamp, update: impl FnOnce(&mut Vec) -> Ext4Result, - ) -> Ext4Result<(JournalCredits, Vec, XattrMutation)> { + ) -> Ext4Result<(JournalCredits, InodeXattrStoragePlan, XattrMutation)> { let old_external_block = inode.file_acl_block(); let mut xattrs = self.read_xattrs(inode)?; let old_inline_layout = (old_external_block == 0).then(|| xattr_inline_layout(&xattrs)); let mutation = update(&mut xattrs)?; if mutation == XattrMutation::Unchanged { - return Ok((JournalCredits::new(0), xattrs, mutation)); + let plan = self.plan_inode_xattr_storage(inode, &xattrs, true)?; + return Ok((JournalCredits::new(0), plan, mutation)); } let old_layout = if let Some(layout) = old_inline_layout { layout @@ -357,35 +389,206 @@ impl Ext4Filesystem { shared: refcount > 1, } }; - let new_layout = xattr_layout_after_update(&xattrs, inode.inline_xattr_bytes().len())?; - if matches!(new_layout, XattrStorageLayout::External { .. }) { - let block_size = - usize::try_from(self.layout().block_size()).map_err(|_| Ext4Error::Overflow)?; - if external_xattr_encoded_len(&xattrs)? > block_size { - return Err(Ext4Error::Unsupported(UnsupportedKind::ExternalXattrBlock)); - } + let plan = self.plan_inode_xattr_storage(inode, &xattrs, true)?; + let new_layout = plan.storage.layout(); + if !plan.storage.external.is_empty() { let reuses_existing = matches!(old_layout, XattrStorageLayout::External { shared: false }); if !reuses_existing && self.superblock().free_blocks_count() == 0 { return Err(Ext4Error::NoSpace); } } - let planned_external_block = matches!(new_layout, XattrStorageLayout::External { .. }) + let planned_external_block = (!plan.storage.external.is_empty()) .then_some(FilesystemBlock::new(old_external_block.max(1))); - self.prepare_xattr_inode_update(inode, &xattrs, planned_external_block, timestamp)?; + self.prepare_xattr_inode_update(inode, &plan, planned_external_block, Some(timestamp))?; Ok(( JournalCredits::new(xattr_mutation_credit_count(old_layout, new_layout)), - xattrs, + plan, mutation, )) } - fn prepare_xattr_inode_update( + fn plan_inode_xattr_storage( &self, inode: &Ext4Inode, xattrs: &[Ext4Xattr], + can_allocate_external: bool, + ) -> Ext4Result { + let inode_size = usize::from(self.superblock().inode_size()); + let block_size = + usize::try_from(self.layout().block_size()).map_err(|_| Ext4Error::Overflow)?; + let current_extra_isize = inode.extra_isize(); + let desired_extra_isize = current_extra_isize.max( + self.superblock() + .want_extra_isize() + .max(self.superblock().min_extra_isize()), + ); + let minimum_extra_isize = current_extra_isize.max(self.superblock().min_extra_isize()); + let candidates = [ + desired_extra_isize, + minimum_extra_isize, + current_extra_isize, + ]; + + for (index, extra_isize) in candidates.into_iter().enumerate() { + if candidates[..index].contains(&extra_isize) { + continue; + } + let inline_capacity = inode_size + .checked_sub(disk_inode::GOOD_OLD_INODE_SIZE) + .and_then(|available| available.checked_sub(usize::from(extra_isize))) + .ok_or(Ext4Error::Corrupt(CorruptKind::InvalidInode))?; + let Some(storage) = plan_xattr_storage(xattrs, inline_capacity, block_size)? else { + continue; + }; + if extra_isize != current_extra_isize + && !storage.external.is_empty() + && !can_allocate_external + { + continue; + } + return Ok(InodeXattrStoragePlan { + extra_isize, + storage, + rewrite_external: true, + }); + } + + Err(Ext4Error::NoSpace) + } + + /// Best-effort expansion of an existing inode's extra fields. + /// + /// Linux performs this step when an inode is dirtied. KExt4 follows the + /// same fallback rule: try `want_extra_isize`, then `min_extra_isize`, and + /// keep the current size if neither layout can preserve every xattr. + pub(crate) fn try_expand_inode_extra_isize( + &mut self, + inode: &Ext4Inode, + handle: &mut JournalHandle<'_>, + ) -> Ext4Result<()> { + let current_extra_isize = inode.extra_isize(); + let desired_extra_isize = self + .superblock() + .want_extra_isize() + .max(self.superblock().min_extra_isize()); + if current_extra_isize >= desired_extra_isize { + return Ok(()); + } + + let mut inline_xattrs = Vec::new(); + self.read_inline_xattrs(inode, &mut inline_xattrs)?; + let mut external_xattrs = Vec::new(); + self.read_external_xattrs(inode, &mut external_xattrs)?; + let old_external_block = inode.file_acl_block(); + let can_allocate_external = if old_external_block == 0 { + self.superblock().free_blocks_count() != 0 + } else { + self.external_xattr_block_refcount(inode, old_external_block)? == 1 + || self.superblock().free_blocks_count() != 0 + }; + let plan = match self.plan_inode_xattr_expansion( + inode, + &inline_xattrs, + &external_xattrs, + can_allocate_external, + ) { + Ok(plan) => plan, + Err(Ext4Error::NoSpace) => return Ok(()), + Err(error) => return Err(error), + }; + if plan.extra_isize == current_extra_isize { + return Ok(()); + } + + let old_layout = if old_external_block == 0 { + xattr_inline_layout(&inline_xattrs) + } else { + XattrStorageLayout::External { + shared: self.external_xattr_block_refcount(inode, old_external_block)? > 1, + } + }; + let total_credits = if plan.rewrite_external { + xattr_mutation_credit_count(old_layout, plan.storage.layout()) + } else { + XATTR_INODE_UPDATE_CREDITS + }; + let additional_credits = total_credits.saturating_sub(XATTR_INODE_UPDATE_CREDITS); + if additional_credits != 0 { + let credit_limit = self.running_transaction_credit_limit()?; + match handle.reserve_more(JournalCredits::new(additional_credits), credit_limit) { + Ok(()) => {} + Err(Ext4Error::InsufficientJournalCredits | Ext4Error::JournalBusy) => { + return Ok(()); + } + Err(error) => return Err(error), + } + } + self.apply_xattr_storage_plan(inode, plan, None, handle) + } + + fn plan_inode_xattr_expansion( + &self, + inode: &Ext4Inode, + inline_xattrs: &[Ext4Xattr], + external_xattrs: &[Ext4Xattr], + can_rewrite_external: bool, + ) -> Ext4Result { + let inode_size = usize::from(self.superblock().inode_size()); + let block_size = + usize::try_from(self.layout().block_size()).map_err(|_| Ext4Error::Overflow)?; + let current_extra_isize = inode.extra_isize(); + let desired_extra_isize = current_extra_isize.max( + self.superblock() + .want_extra_isize() + .max(self.superblock().min_extra_isize()), + ); + let minimum_extra_isize = current_extra_isize.max(self.superblock().min_extra_isize()); + let candidates = [ + desired_extra_isize, + minimum_extra_isize, + current_extra_isize, + ]; + + let mut current_external = Vec::from(external_xattrs); + sort_xattrs(&mut current_external); + for (index, extra_isize) in candidates.into_iter().enumerate() { + if candidates[..index].contains(&extra_isize) { + continue; + } + let inline_capacity = inode_size + .checked_sub(disk_inode::GOOD_OLD_INODE_SIZE) + .and_then(|available| available.checked_sub(usize::from(extra_isize))) + .ok_or(Ext4Error::Corrupt(CorruptKind::InvalidInode))?; + let Some(storage) = plan_xattr_expansion_storage( + inline_xattrs, + external_xattrs, + inline_capacity, + block_size, + )? + else { + continue; + }; + let rewrite_external = storage.external != current_external; + if extra_isize != current_extra_isize && rewrite_external && !can_rewrite_external { + continue; + } + return Ok(InodeXattrStoragePlan { + extra_isize, + storage, + rewrite_external, + }); + } + + Err(Ext4Error::NoSpace) + } + + fn prepare_xattr_inode_update( + &self, + inode: &Ext4Inode, + plan: &InodeXattrStoragePlan, new_external_block: Option, - timestamp: Ext4Timestamp, + timestamp: Option, ) -> Ext4Result<(FilesystemBlock, Vec, Ext4InodeMetadata)> { let old_external_block = inode.file_acl_block(); let inode_table_block = self.inode_table_entry_block(inode.number())?; @@ -398,41 +601,45 @@ impl Ext4Filesystem { inode, |inode_bytes| { let raw = disk_inode::RawInode::decode(inode_bytes)?; - let inline_xattr_offset = + if plan.extra_isize < raw.extra_isize() { + return Err(Ext4Error::Corrupt(CorruptKind::InvalidInode)); + } + let old_inline_xattr_offset = inline_xattr_offset(inode_bytes.len(), raw.extra_isize())?; + inode_bytes + .get_mut(old_inline_xattr_offset..) + .ok_or(Ext4Error::Corrupt(CorruptKind::Truncated))? + .fill(0); + if inode_bytes.len() >= disk_inode::EXTRA_ISIZE_OFFSET + 2 { + put_u16( + inode_bytes, + disk_inode::EXTRA_ISIZE_OFFSET, + plan.extra_isize, + )?; + } else if plan.extra_isize != 0 { + return Err(Ext4Error::Corrupt(CorruptKind::InvalidInode)); + } + let inline_xattr_offset = inline_xattr_offset(inode_bytes.len(), plan.extra_isize)?; let inline_xattr_bytes = inode_bytes .get_mut(inline_xattr_offset..) .ok_or(Ext4Error::Corrupt(CorruptKind::Truncated))?; - if let Some(block) = new_external_block { - inline_xattr_bytes.fill(0); - update_inode_xattr_block_bytes( - inode_bytes, - &raw, - InodeXattrBlockUpdate { - file_acl: block.get(), - current_blocks: inode.blocks(), - had_external_block: old_external_block != 0, - has_external_block: true, - block_size: self.layout().block_size(), - has_64bit: self.superblock().features().has_64bit(), - }, - )?; - } else { - encode_inline_xattrs(xattrs, inline_xattr_bytes)?; - update_inode_xattr_block_bytes( - inode_bytes, - &raw, - InodeXattrBlockUpdate { - file_acl: 0, - current_blocks: inode.blocks(), - had_external_block: old_external_block != 0, - has_external_block: false, - block_size: self.layout().block_size(), - has_64bit: self.superblock().features().has_64bit(), - }, - )?; + encode_inline_xattrs(&plan.storage.inline, inline_xattr_bytes)?; + update_inode_xattr_block_bytes( + inode_bytes, + &raw, + InodeXattrBlockUpdate { + file_acl: new_external_block.map_or(0, FilesystemBlock::get), + current_blocks: inode.blocks(), + had_external_block: old_external_block != 0, + has_external_block: new_external_block.is_some(), + block_size: self.layout().block_size(), + has_64bit: self.superblock().features().has_64bit(), + }, + )?; + if let Some(timestamp) = timestamp { + update_inode_ctime_bytes(inode_bytes, timestamp)?; } - update_inode_ctime_bytes(inode_bytes, timestamp) + Ok(()) }, )?; Ok((inode_table_block, inode_table_bytes, updated_inode)) @@ -810,18 +1017,200 @@ fn xattr_inline_layout(xattrs: &[Ext4Xattr]) -> XattrStorageLayout { } } -fn xattr_layout_after_update( +fn plan_xattr_storage( xattrs: &[Ext4Xattr], inline_capacity: usize, -) -> Ext4Result { + external_capacity: usize, +) -> Ext4Result> { if xattrs.is_empty() { - return Ok(XattrStorageLayout::Empty); + return Ok(Some(XattrStoragePlan { + inline: Vec::new(), + external: Vec::new(), + })); } - if inline_xattr_encoded_len(xattrs)? <= inline_capacity { - Ok(XattrStorageLayout::Inline) - } else { - Ok(XattrStorageLayout::External { shared: false }) + + let mut ordered = Vec::from(xattrs); + sort_xattrs(&mut ordered); + if inline_xattr_encoded_len(&ordered)? <= inline_capacity { + return Ok(Some(XattrStoragePlan { + inline: ordered, + external: Vec::new(), + })); + } + + let inline_base = disk_xattr::XATTR_IBODY_HEADER_SIZE + .checked_add(4) + .ok_or(Ext4Error::Overflow)?; + let external_base = disk_xattr::XATTR_HEADER_SIZE + .checked_add(4) + .ok_or(Ext4Error::Overflow)?; + let inline_payload_capacity = inline_capacity.saturating_sub(inline_base); + let Some(external_payload_capacity) = external_capacity.checked_sub(external_base) else { + return Ok(None); + }; + let entry_sizes = ordered + .iter() + .map(xattr_encoded_entry_len) + .collect::>>()?; + let total_entry_size = entry_sizes.iter().try_fold(0usize, |total, size| { + total.checked_add(*size).ok_or(Ext4Error::Overflow) + })?; + let minimum_inline_size = total_entry_size.saturating_sub(external_payload_capacity); + if minimum_inline_size > inline_payload_capacity { + return Ok(None); + } + + let Some(is_inline) = + select_xattr_subset(&entry_sizes, minimum_inline_size, inline_payload_capacity)? + else { + return Ok(None); + }; + + let mut inline = Vec::new(); + let mut external = Vec::new(); + for (entry_index, xattr) in ordered.into_iter().enumerate() { + if is_inline[entry_index] { + inline.push(xattr); + } else { + external.push(xattr); + } } + debug_assert!(inline_xattr_encoded_len(&inline)? <= inline_capacity); + debug_assert!(external_xattr_encoded_len(&external)? <= external_capacity); + Ok(Some(XattrStoragePlan { inline, external })) +} + +fn plan_xattr_expansion_storage( + inline_xattrs: &[Ext4Xattr], + external_xattrs: &[Ext4Xattr], + inline_capacity: usize, + external_capacity: usize, +) -> Ext4Result> { + let mut inline_xattrs = Vec::from(inline_xattrs); + let mut external_xattrs = Vec::from(external_xattrs); + sort_xattrs(&mut inline_xattrs); + sort_xattrs(&mut external_xattrs); + if inline_xattr_encoded_len(&inline_xattrs)? <= inline_capacity { + return Ok(Some(XattrStoragePlan { + inline: inline_xattrs, + external: external_xattrs, + })); + } + + let inline_base = disk_xattr::XATTR_IBODY_HEADER_SIZE + .checked_add(4) + .ok_or(Ext4Error::Overflow)?; + let external_base = disk_xattr::XATTR_HEADER_SIZE + .checked_add(4) + .ok_or(Ext4Error::Overflow)?; + let inline_payload_capacity = inline_capacity.saturating_sub(inline_base); + let Some(external_payload_capacity) = external_capacity.checked_sub(external_base) else { + return Ok(None); + }; + let external_size = external_xattrs.iter().try_fold(0usize, |total, xattr| { + total + .checked_add(xattr_encoded_entry_len(xattr)?) + .ok_or(Ext4Error::Overflow) + })?; + let Some(external_free) = external_payload_capacity.checked_sub(external_size) else { + return Ok(None); + }; + let inline_entry_sizes = inline_xattrs + .iter() + .map(xattr_encoded_entry_len) + .collect::>>()?; + let total_inline_size = inline_entry_sizes.iter().try_fold(0usize, |total, size| { + total.checked_add(*size).ok_or(Ext4Error::Overflow) + })?; + let minimum_inline_size = total_inline_size.saturating_sub(external_free); + let Some(is_inline) = select_xattr_subset( + &inline_entry_sizes, + minimum_inline_size, + inline_payload_capacity, + )? + else { + return Ok(None); + }; + + let mut kept_inline = Vec::new(); + for (entry_index, xattr) in inline_xattrs.into_iter().enumerate() { + if is_inline[entry_index] { + kept_inline.push(xattr); + } else { + external_xattrs.push(xattr); + } + } + sort_xattrs(&mut external_xattrs); + debug_assert!(inline_xattr_encoded_len(&kept_inline)? <= inline_capacity); + debug_assert!(external_xattr_encoded_len(&external_xattrs)? <= external_capacity); + Ok(Some(XattrStoragePlan { + inline: kept_inline, + external: external_xattrs, + })) +} + +fn select_xattr_subset( + entry_sizes: &[usize], + minimum_size: usize, + maximum_size: usize, +) -> Ext4Result>> { + if minimum_size > maximum_size { + return Ok(None); + } + + // Entry bytes have the same size in both storage regions. A bounded + // subset-sum over the inode payload therefore finds a valid split without + // the false ENOSPC cases caused by a greedy move order. + let mut reachable = vec![false; maximum_size + 1]; + let mut predecessor = vec![None; maximum_size + 1]; + reachable[0] = true; + for (entry_index, entry_size) in entry_sizes.iter().copied().enumerate() { + if entry_size > maximum_size { + continue; + } + for size in (entry_size..=maximum_size).rev() { + if !reachable[size] && reachable[size - entry_size] { + reachable[size] = true; + predecessor[size] = Some((size - entry_size, entry_index)); + } + } + } + let Some(mut selected_size) = (minimum_size..=maximum_size) + .rev() + .find(|size| reachable[*size]) + else { + return Ok(None); + }; + let mut selected = vec![false; entry_sizes.len()]; + while selected_size != 0 { + let Some((previous_size, entry_index)) = predecessor[selected_size] else { + return Err(Ext4Error::Corrupt(CorruptKind::InvalidXattr)); + }; + selected[entry_index] = true; + selected_size = previous_size; + } + Ok(Some(selected)) +} + +fn xattr_encoded_entry_len(xattr: &Ext4Xattr) -> Ext4Result { + disk_xattr::entry_len(xattr.name.len())? + .checked_add(disk_xattr::padded_len(xattr.value.len())?) + .ok_or(Ext4Error::Overflow) +} + +fn sort_xattrs(xattrs: &mut [Ext4Xattr]) { + xattrs.sort_by(|left, right| { + ( + left.namespace.index(), + left.name.len(), + left.name.as_slice(), + ) + .cmp(&( + right.namespace.index(), + right.name.len(), + right.name.as_slice(), + )) + }); } fn xattr_mutation_credit_count(old: XattrStorageLayout, new: XattrStorageLayout) -> u32 { @@ -856,10 +1245,7 @@ fn inline_xattr_encoded_len(xattrs: &[Ext4Xattr]) -> Ext4Result { let mut len = disk_xattr::XATTR_IBODY_HEADER_SIZE; for xattr in xattrs { len = len - .checked_add(disk_xattr::entry_len(xattr.name.len())?) - .ok_or(Ext4Error::Overflow)?; - len = len - .checked_add(disk_xattr::padded_len(xattr.value.len())?) + .checked_add(xattr_encoded_entry_len(xattr)?) .ok_or(Ext4Error::Overflow)?; } len.checked_add(4).ok_or(Ext4Error::Overflow) @@ -869,10 +1255,7 @@ fn external_xattr_encoded_len(xattrs: &[Ext4Xattr]) -> Ext4Result { let mut len = disk_xattr::XATTR_HEADER_SIZE; for xattr in xattrs { len = len - .checked_add(disk_xattr::entry_len(xattr.name.len())?) - .ok_or(Ext4Error::Overflow)?; - len = len - .checked_add(disk_xattr::padded_len(xattr.value.len())?) + .checked_add(xattr_encoded_entry_len(xattr)?) .ok_or(Ext4Error::Overflow)?; } len.checked_add(4).ok_or(Ext4Error::Overflow) @@ -978,18 +1361,7 @@ fn encode_xattr_entries( mut hash_writer: XattrEntryHashWriter<'_>, ) -> Ext4Result<()> { let mut sorted = Vec::from(xattrs); - sorted.sort_by(|left, right| { - ( - left.namespace.index(), - left.name.len(), - left.name.as_slice(), - ) - .cmp(&( - right.namespace.index(), - right.name.len(), - right.name.as_slice(), - )) - }); + sort_xattrs(&mut sorted); let mut entry_offset = entries_offset; let mut value_cursor = output.len(); @@ -1369,7 +1741,7 @@ fn validate_non_overlapping_value_ranges(ranges: &mut [(usize, usize)]) -> Ext4R #[cfg(test)] mod tests { - use alloc::{vec, vec::Vec}; + use alloc::{format, vec, vec::Vec}; use super::*; @@ -1737,6 +2109,95 @@ mod tests { ); } + fn numbered_xattrs(count: usize) -> Vec { + (0..count) + .map(|index| Ext4Xattr { + namespace: Ext4XattrNamespace::User, + name: format!("{index}").into_bytes(), + value: Vec::from(&b"aa"[..]), + }) + .collect() + } + + #[test] + fn xattr_storage_plan_uses_inode_and_external_block_together() { + let xattrs = numbered_xattrs(179); + + let plan = plan_xattr_storage(&xattrs, 256, 4096) + .expect("plan mixed xattr storage") + .expect("combined storage has capacity"); + + assert_eq!(plan.inline.len(), 10); + assert_eq!(plan.external.len(), 169); + assert!(inline_xattr_encoded_len(&plan.inline).unwrap() <= 256); + assert!(external_xattr_encoded_len(&plan.external).unwrap() <= 4096); + let mut names = plan + .inline + .iter() + .chain(&plan.external) + .map(|xattr| xattr.name.clone()) + .collect::>(); + names.sort(); + let mut expected_names = xattrs + .iter() + .map(|xattr| xattr.name.clone()) + .collect::>(); + expected_names.sort(); + assert_eq!(names, expected_names); + } + + #[test] + fn extra_isize_layout_falls_back_to_minimum_then_current_size() { + let almost_full = numbered_xattrs(178); + let current = plan_xattr_storage(&almost_full, 256, 4096) + .unwrap() + .expect("initial mixed layout fits"); + assert!( + plan_xattr_expansion_storage(¤t.inline, ¤t.external, 176, 4096) + .unwrap() + .is_none() + ); + let minimum = plan_xattr_expansion_storage(¤t.inline, ¤t.external, 240, 4096) + .unwrap() + .expect("minimum extra isize layout fits"); + assert_eq!(minimum.inline.len(), 9); + assert_eq!(minimum.external.len(), 169); + + let full = numbered_xattrs(179); + let current = plan_xattr_storage(&full, 256, 4096) + .unwrap() + .expect("current extra isize layout remains valid"); + assert!( + plan_xattr_expansion_storage(¤t.inline, ¤t.external, 176, 4096) + .unwrap() + .is_none() + ); + assert!( + plan_xattr_expansion_storage(¤t.inline, ¤t.external, 240, 4096) + .unwrap() + .is_none() + ); + assert_eq!(current.inline.len(), 10); + assert_eq!(current.external.len(), 169); + } + + #[test] + fn extra_isize_expansion_does_not_rebalance_existing_external_xattrs() { + let inline = numbered_xattrs(4); + let external = vec![Ext4Xattr { + namespace: Ext4XattrNamespace::User, + name: Vec::from(&b"external"[..]), + value: Vec::from(&b"value"[..]), + }]; + + let plan = plan_xattr_expansion_storage(&inline, &external, 176, 4096) + .unwrap() + .expect("inline entries already fit expanded inode"); + + assert_eq!(plan.inline, inline); + assert_eq!(plan.external, external); + } + #[test] fn xattr_mutation_credits_follow_storage_transition() { assert_eq!( -- Gitee From ed14544f79d1e7e27f4468454525079720e3bee5 Mon Sep 17 00:00:00 2001 From: zgyxyd666 <1170312156@qq.com> Date: Mon, 10 Aug 2026 17:46:02 +0800 Subject: [PATCH 2/3] fs: encode ext4 external xattr hashes --- fs/filesystems/kext4/docs/design.md | 4 +++- fs/filesystems/kext4/docs/security.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/fs/filesystems/kext4/docs/design.md b/fs/filesystems/kext4/docs/design.md index 2fab40d29..182af979d 100644 --- a/fs/filesystems/kext4/docs/design.md +++ b/fs/filesystems/kext4/docs/design.md @@ -295,7 +295,9 @@ Xattr 修改会把 inline xattr 和 external xattr 解码一次,在同一份 m 存在性检查、值更新、存储布局选择与 journal credits 计算。布局规划器把 inode body 和 single external block 作为两个可同时使用的容量区间,以 entry 编码大小做有界 subset-sum,避免 “整组放 inode 或整组放 block”造成的假 `ENOSPC`;提交时同时维护 `i_file_acl`、`i_blocks`、 -block checksum 和 refcount。 +Linux-compatible external entry hash、block hash、block checksum 和 refcount。Inline entry 的 +`e_hash` 按 ext4 格式保持为零,external entry 则对 name 与四字节补齐后的 value 计算旋转异或 +hash,block header 再按 entry 顺序聚合 `h_hash`。 `Ext4XattrSetMode` 表达无标志、create、replace 和 create+replace 四种组合;组合标志在属性 存在时返回 `EEXIST`,缺失时返回 `ENODATA`,不会通过 bridge 的锁外预查实现。允许替换时, 若现有值逐字节相同,core 在 journal handle、metadata write 和 ctime 更新前返回原 inode。 diff --git a/fs/filesystems/kext4/docs/security.md b/fs/filesystems/kext4/docs/security.md index f00bbf594..7c088f66b 100644 --- a/fs/filesystems/kext4/docs/security.md +++ b/fs/filesystems/kext4/docs/security.md @@ -94,6 +94,8 @@ KVFS bridge 信任 KVFS 已经提供内核拥有的 path name、dentry、inode ` 扣除 ext4 reserved blocks 和 core 持有的 delayed-allocation mount aggregate。 - 从磁盘解码的 external xattr name 必须拒绝内嵌 NUL,保持与 Linux/e2fsck 的 corruption handling 一致。 +- 新写 external xattr block 必须按 Linux ext4 算法生成每项 `e_hash` 和聚合 `h_hash`,并在其后 + 计算 metadata checksum;不能用只保证 KExt4 自身可读、但会被 e2fsck 判坏的零 hash 编码。 - Xattr name-only 遍历仍必须验证 entry 末端、value range、external block header 和 checksum; sink 只能在单次回调期间借用 name bytes,不能保存该引用。 - External xattr block 的 `h_reserved` 按 Linux 语义作为 opaque 字段处理,不能仅因其非零拒绝 @@ -156,7 +158,7 @@ exclusive data lock 下先把 hole reservation 发布到同一个 delayed set, | T-12 | 磁盘 inode 的 immutable/append-only 状态在 bridge 中丢失,导致 xattr 被修改 | 中 | iget 构造 KVFS inode 时总是使用空 `NodeFlags` | core 以语义方法暴露 `EXT4_IMMUTABLE_FL/EXT4_APPEND_FL`;bridge 在发布 VFS inode identity 时映射为 KVFS flags,由通用 xattr 权限层在 mutation 前返回 `EPERM` | | T-13 | 同一 inode 出现 snapshot 分叉,或 inode number reuse 继承旧 transient state | 中 | KVFS 初始化/释放期间并发 `iget`,core namei 按编号重载 live child,或 orphan removal 为 resident 前驱解码临时对象 | KVFS `New/Live/Freeing` cache 是唯一 identity table;`New/Freeing` 等待并重试;bridge 向 unlink/rmdir/rename 传入既有 private state;legacy orphan next 只读写 journaled inode-table bytes;KExt4 无 resident cache;reuse 只能在 `Freeing` 完成并删除旧 slot 后发生 | | T-14 | shrink 后重新增长暴露旧 PageCache 数据 | 高 | ext4 backing prepare 提前把 VFS `i_size` 改成目标值,导致 `truncate_setsize()` 误判长度未变并跳过 folio 丢弃或 EOF 清零 | regular-file metadata publish 只更新 `i_disksize`;唯一 `i_size` 由 VFS 在 PageCache 顺序点发布;core prepare 与 KVFS shrink/regrow、partial-grow 回归测试共同约束该职责边界 | -| T-15 | 扩大 `i_extra_isize` 时覆盖或丢失 inode-body xattr | 高 | 新 extra fields 与旧 inline 区间重叠,或只校验单一区域容量后开始迁移 | 先对 inline/external 两区执行完整布局规划;目标值失败依次退到 minimum/current;同一 journal transaction 重写 external block、清理旧 inline 区、编码新布局并更新 inode checksum,单元测试覆盖 ext4/022 的 720/656/640 三种结果 | +| T-15 | 扩大 `i_extra_isize` 时覆盖、丢失 xattr,或生成 e2fsck 不接受的 external EA block | 高 | 新 extra fields 与旧 inline 区间重叠,只校验单一区域容量,或 external entry/block hash 留零 | 先对 inline/external 两区执行完整布局规划;目标值失败依次退到 minimum/current;同一 journal transaction 重写 external block、生成 Linux-compatible `e_hash`/`h_hash`、清理旧 inline 区并更新 inode checksum;单元测试覆盖 ext4/022 的 720/656/640 三种布局与固定 hash 向量 | ## 故障模式与影响分析(FMEA) -- Gitee From 1355981e273eb88204059f04f3985abaa30af21a Mon Sep 17 00:00:00 2001 From: zgyxyd666 <1170312156@qq.com> Date: Tue, 11 Aug 2026 13:50:12 +0800 Subject: [PATCH 3/3] fs: enhance ext4 xattr handling with extra isize expansion management --- fs/filesystems/kext4/docs/design.md | 9 +- fs/filesystems/kext4/docs/security.md | 10 +- fs/filesystems/kext4/src/inode.rs | 32 +++ fs/filesystems/kext4/src/xattr.rs | 271 +++++++++++++++++--------- 4 files changed, 222 insertions(+), 100 deletions(-) diff --git a/fs/filesystems/kext4/docs/design.md b/fs/filesystems/kext4/docs/design.md index 182af979d..b448e7cb2 100644 --- a/fs/filesystems/kext4/docs/design.md +++ b/fs/filesystems/kext4/docs/design.md @@ -300,7 +300,10 @@ Linux-compatible external entry hash、block hash、block checksum 和 refcount hash,block header 再按 entry 顺序聚合 `h_hash`。 `Ext4XattrSetMode` 表达无标志、create、replace 和 create+replace 四种组合;组合标志在属性 存在时返回 `EEXIST`,缺失时返回 `ENODATA`,不会通过 bridge 的锁外预查实现。允许替换时, -若现有值逐字节相同,core 在 journal handle、metadata write 和 ctime 更新前返回原 inode。 +若现有值逐字节相同,core 在布局规划、journal handle、metadata write 和 ctime 更新前返回。 +布局候选按 `want_extra_isize`、`min_extra_isize`、当前值排序,但只有已有私有 external block +可原地重写或 allocator 尚有空闲 block 时,才接受需要 external storage 的候选;磁盘已满时 +会继续回退到当前 extra-isize 下可用的全 inline 布局,不把可完成的 xattr 更新误报为 `ENOSPC`。 `Ext4Inode` 从磁盘 `i_flags` 暴露 immutable 和 append-only 状态;bridge 在 iget 时把它们映射 为 KVFS `NodeFlags`,使通用 xattr 权限层在进入 namespace 或 KExt4 mutation 前返回 `EPERM`。 @@ -309,6 +312,10 @@ hash,block header 再按 entry 顺序聚合 `h_hash`。 大小而继续普通 metadata 更新。需要缩小 inline 区域时,规划器在同一 journal transaction 中 把选中的属性迁移到 external block,再移动 inode-body 编码并更新 `i_extra_isize`;handle 只为 新增的 external metadata targets 扩展 credits,且扩展不得越过当前 journal transaction limit。 +确认没有可行扩展布局后,`Ext4Inode` 在 resident 私有状态中记录 Linux +`EXT4_STATE_NO_EXPAND` 对等标志,后续写入不再重复读取、校验和规划相同的 xattr;成功删除 +xattr 会清除此标志,因为 inode-body/external 空间已经改变。`JournalBusy` 或 journal credits +暂时不足发生在实际布局迁移之前,不记录为永久失败,后续 transaction 仍可重试。 `list_xattrs()` 使用 `Ext4XattrNameSink` 逐项借用已校验的磁盘名称,只验证 value range 而不 复制 value。KVFS bridge 在 sink 中添加 `user.*`、`trusted.*`、`security.*` 前缀并继续流式 diff --git a/fs/filesystems/kext4/docs/security.md b/fs/filesystems/kext4/docs/security.md index 7c088f66b..daf345ea6 100644 --- a/fs/filesystems/kext4/docs/security.md +++ b/fs/filesystems/kext4/docs/security.md @@ -105,7 +105,9 @@ KVFS bridge 信任 KVFS 已经提供内核拥有的 path name、dentry、inode ` - 扩大 `i_extra_isize` 前必须先证明全部 xattr 可在缩小后的 inode body 与一个 external block 中完整分区;迁移、`i_file_acl`/`i_blocks` 和 inode checksum 更新必须属于同一 transaction。 `want_extra_isize` 无法满足时只能退到 `min_extra_isize` 或保留当前值,不能丢弃属性或阻断 - 本来合法的普通 inode metadata 更新。 + 本来合法的普通 inode metadata 更新。需要新 external block 的候选必须在规划阶段检查 allocator + 可用性,不能先选中不可落盘布局再返回假 `ENOSPC`。确认无法扩展后只在 resident inode 上记录 + `no_expand`;删除 xattr 清除该状态,journal 的暂时性 credit/忙碌失败不应永久禁止重试。 - 运行态 inode allocation 必须使用 bridge 已通过 `inode_init_owner()` 导出的显式 UID/GID;KExt4 core 不得把新 inode owner 默认为 root。 - FIEMAP 的 logical/physical/length 乘法和加法必须 checked;遍历必须同时受请求末端与 @@ -158,7 +160,7 @@ exclusive data lock 下先把 hole reservation 发布到同一个 delayed set, | T-12 | 磁盘 inode 的 immutable/append-only 状态在 bridge 中丢失,导致 xattr 被修改 | 中 | iget 构造 KVFS inode 时总是使用空 `NodeFlags` | core 以语义方法暴露 `EXT4_IMMUTABLE_FL/EXT4_APPEND_FL`;bridge 在发布 VFS inode identity 时映射为 KVFS flags,由通用 xattr 权限层在 mutation 前返回 `EPERM` | | T-13 | 同一 inode 出现 snapshot 分叉,或 inode number reuse 继承旧 transient state | 中 | KVFS 初始化/释放期间并发 `iget`,core namei 按编号重载 live child,或 orphan removal 为 resident 前驱解码临时对象 | KVFS `New/Live/Freeing` cache 是唯一 identity table;`New/Freeing` 等待并重试;bridge 向 unlink/rmdir/rename 传入既有 private state;legacy orphan next 只读写 journaled inode-table bytes;KExt4 无 resident cache;reuse 只能在 `Freeing` 完成并删除旧 slot 后发生 | | T-14 | shrink 后重新增长暴露旧 PageCache 数据 | 高 | ext4 backing prepare 提前把 VFS `i_size` 改成目标值,导致 `truncate_setsize()` 误判长度未变并跳过 folio 丢弃或 EOF 清零 | regular-file metadata publish 只更新 `i_disksize`;唯一 `i_size` 由 VFS 在 PageCache 顺序点发布;core prepare 与 KVFS shrink/regrow、partial-grow 回归测试共同约束该职责边界 | -| T-15 | 扩大 `i_extra_isize` 时覆盖、丢失 xattr,或生成 e2fsck 不接受的 external EA block | 高 | 新 extra fields 与旧 inline 区间重叠,只校验单一区域容量,或 external entry/block hash 留零 | 先对 inline/external 两区执行完整布局规划;目标值失败依次退到 minimum/current;同一 journal transaction 重写 external block、生成 Linux-compatible `e_hash`/`h_hash`、清理旧 inline 区并更新 inode checksum;单元测试覆盖 ext4/022 的 720/656/640 三种布局与固定 hash 向量 | +| T-15 | 扩大 `i_extra_isize` 时覆盖、丢失 xattr,生成 e2fsck 不接受的 external EA block,或在磁盘满时拒绝本可 inline 完成的更新 | 高 | 新 extra fields 与旧 inline 区间重叠,只校验单一区域容量,external entry/block hash 留零,或布局规划未约束 external block 是否可获得 | 先对 inline/external 两区执行完整布局规划;目标值失败依次退到 minimum/current,并在候选阶段拒绝无法分配或复用 external block 的布局;同一 journal transaction 重写 external block、生成 Linux-compatible `e_hash`/`h_hash`、清理旧 inline 区并更新 inode checksum;单元测试覆盖 ext4/022 的 720/656/640 布局、磁盘满 inline fallback 与独立固定 hash 向量 | ## 故障模式与影响分析(FMEA) @@ -173,6 +175,7 @@ exclusive data lock 下先把 hole reservation 发布到同一个 delayed set, | F-07 | committed journal 占满可追加空间 | checkpoint 落后于 commit,head 接近 oldest tail | 新 transaction 暂时不能持久化 | mutation 等待 checkpoint progress | 3 | append 前按环形 live range 校验空间并保留一个空 block;提交路径捕获 `JournalBusy`,同步推进最老 pending checkpoint 后重试 | | F-08 | FIEMAP 查询遇到损坏 mapping | extent tree/legacy pointer 返回非法长度或物理范围 | 当前 ioctl 返回数据错误 | 文件内容不被修改,调用方不能取得布局 | 3 | 复用 core mapping 校验;bridge 拒绝零进度和算术溢出,不输出未经检查的 extent | | F-09 | eviction 与并发 `iget` 交错 | final teardown 已开始时另一路按相同 inode number 加载 | 调用方可能观察正在释放的 metadata,或建立并行 identity | stale I/O、重复释放或 transient state 串线 | 2 | KVFS cache mutex 下完成 `Live -> Freeing`;lookup/insert 在内部等待,finish 精确移除旧 Weak entry 并唤醒后重试,不向路径操作返回 `EINVAL`/`ESTALE` | +| F-10 | 存量 inode 的 extra-isize 扩展在每次写入重复失败 | xattr 布局或 external block 资源持续不满足,但失败结论没有驻留状态 | 每次 writeback 重复 metadata I/O、checksum、解码、排序和布局规划 | 写入吞吐下降,磁盘满场景可能被放大 | 4 | 确定布局不可行时在唯一 resident `Ext4Inode` 状态中设置 `no_expand`;成功删除 xattr 后清除,journal busy/credits 不足保持可重试 | ## 故障管理 @@ -227,7 +230,8 @@ KExt4 会存储并返回 filesystem data 和 metadata,其中 xattr value 可 - xattr list 是否只借用名称,同时继续校验 value range 和 external block checksum?相同值 set 是否在 journal/ctime 更新前返回?磁盘 immutable/append-only flags 是否在 iget 时 映射到 KVFS,并在任何 xattr mutation 前返回 `EPERM`?inline/external 混合布局是否保存 - 全部属性,extra-isize 扩展失败是否安全回退而不影响普通 metadata 更新? + 全部属性,磁盘满时是否跳过需要新 external block 的候选并回退 inline,extra-isize 扩展失败 + 是否安全回退且只缓存已经完成布局/资源判定的失败,而不把 journal 暂时繁忙记成永久状态? - 每个 mutation 是否为所有可能 dirty 或 revoke 的 metadata block 预留了足够 journal credits? - 线性目录转 HTree 后立即 split 时,extent 预检是否把两次独立 block allocation 视为两个 最坏情况下不合并的 mapping,并在 `EXT4_INDEX_FL` 发布前预留 HTree credits? diff --git a/fs/filesystems/kext4/src/inode.rs b/fs/filesystems/kext4/src/inode.rs index 8d21622a1..a79414129 100644 --- a/fs/filesystems/kext4/src/inode.rs +++ b/fs/filesystems/kext4/src/inode.rs @@ -483,6 +483,7 @@ struct Ext4InodeState { visible_size: u64, delayed_extents: BTreeMap, reserved_data_blocks: u64, + is_extra_isize_expansion_disabled: bool, } impl Ext4InodeState { @@ -587,6 +588,7 @@ impl Ext4Inode { visible_size, delayed_extents: BTreeMap::new(), reserved_data_blocks: 0, + is_extra_isize_expansion_disabled: false, }), } } @@ -848,6 +850,18 @@ impl Ext4Inode { self.with_metadata(|metadata| metadata.extra_isize) } + pub(crate) fn is_extra_isize_expansion_disabled(&self) -> bool { + sync::lock(&self.state).is_extra_isize_expansion_disabled + } + + pub(crate) fn disable_extra_isize_expansion(&self) { + sync::lock(&self.state).is_extra_isize_expansion_disabled = true; + } + + pub(crate) fn enable_extra_isize_expansion(&self) { + sync::lock(&self.state).is_extra_isize_expansion_disabled = false; + } + pub(crate) fn inline_xattr_bytes(&self) -> Vec { self.with_metadata(|metadata| metadata.inline_xattr.clone()) } @@ -2553,6 +2567,7 @@ mod tests { flags, block, file_acl, + extra_isize: 0, inline_xattr: alloc::vec::Vec::new(), generation: 0, links_count: 1, @@ -2577,6 +2592,7 @@ mod tests { flags: 0, block, file_acl: 0, + extra_isize: 0, inline_xattr: alloc::vec::Vec::new(), generation: 0, links_count: 1, @@ -2601,6 +2617,7 @@ mod tests { flags: disk_inode::EXT4_EXTENTS_FL, block: [0; disk_inode::INODE_BLOCK_BYTES], file_acl: 0, + extra_isize: 0, inline_xattr: alloc::vec::Vec::new(), generation: 1, links_count: 1, @@ -2610,6 +2627,21 @@ mod tests { } } + #[test] + fn extra_isize_expansion_failure_state_is_resident_and_resettable() { + let inode = test_inode(regular_inode_metadata()); + assert!(!inode.is_extra_isize_expansion_disabled()); + + inode.disable_extra_isize_expansion(); + let mut updated = regular_inode_metadata(); + updated.extra_isize = 16; + inode.publish_metadata(updated).unwrap(); + assert!(inode.is_extra_isize_expansion_disabled()); + + inode.enable_extra_isize_expansion(); + assert!(!inode.is_extra_isize_expansion_disabled()); + } + #[test] fn delayed_allocation_extents_merge_split_and_count_blocks() { let inode = test_inode(regular_inode_metadata()); diff --git a/fs/filesystems/kext4/src/xattr.rs b/fs/filesystems/kext4/src/xattr.rs index f2fa65d2c..d857794cc 100644 --- a/fs/filesystems/kext4/src/xattr.rs +++ b/fs/filesystems/kext4/src/xattr.rs @@ -213,6 +213,20 @@ struct InodeXattrStoragePlan { rewrite_external: bool, } +enum XattrMutationPlan { + Unchanged, + Changed { + credits: JournalCredits, + storage: InodeXattrStoragePlan, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ExtraIsizeLayoutCandidate { + extra_isize: u16, + inline_capacity: usize, +} + impl Ext4Filesystem { /// Reads all supported extended attributes stored on an inode. pub fn read_xattrs(&self, inode: &Ext4Inode) -> Ext4Result> { @@ -286,18 +300,19 @@ impl Ext4Filesystem { ) -> Ext4Result<()> { validate_settable_xattr(namespace, name)?; self.validate_inode_timestamp_update(inode, timestamp)?; - let (credits, plan, mutation) = self.xattr_mutation_plan(inode, timestamp, |xattrs| { - set_xattr_value_with_mode(xattrs, namespace, name, value, mode) - })?; - if mutation == XattrMutation::Unchanged { + let XattrMutationPlan::Changed { credits, storage } = + self.xattr_mutation_plan(inode, timestamp, |xattrs| { + set_xattr_value_with_mode(xattrs, namespace, name, value, mode) + })? + else { return Ok(()); - } + }; let journal = self.metadata_journal_for_mutation( credits, crate::journal::RecoveryFlagPolicy::ClearAfterCheckpoint, )?; let mut handle = journal.begin(credits)?; - let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, plan); + let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, storage); self.complete_metadata_mutation(handle, result) } @@ -311,16 +326,22 @@ impl Ext4Filesystem { ) -> Ext4Result<()> { validate_settable_xattr(namespace, name)?; self.validate_inode_timestamp_update(inode, timestamp)?; - let (credits, plan, _) = self.xattr_mutation_plan(inode, timestamp, |xattrs| { - remove_xattr_value(xattrs, namespace, name).map(|_| XattrMutation::Changed) - })?; + let XattrMutationPlan::Changed { credits, storage } = + self.xattr_mutation_plan(inode, timestamp, |xattrs| { + remove_xattr_value(xattrs, namespace, name).map(|_| XattrMutation::Changed) + })? + else { + return Ok(()); + }; let journal = self.metadata_journal_for_mutation( credits, crate::journal::RecoveryFlagPolicy::ClearAfterCheckpoint, )?; let mut handle = journal.begin(credits)?; - let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, plan); - self.complete_metadata_mutation(handle, result) + let result = self.update_xattr_in_transaction(inode, timestamp, &mut handle, storage); + self.complete_metadata_mutation(handle, result)?; + inode.enable_extra_isize_expansion(); + Ok(()) } fn update_xattr_in_transaction( @@ -372,14 +393,13 @@ impl Ext4Filesystem { inode: &Ext4Inode, timestamp: Ext4Timestamp, update: impl FnOnce(&mut Vec) -> Ext4Result, - ) -> Ext4Result<(JournalCredits, InodeXattrStoragePlan, XattrMutation)> { + ) -> Ext4Result { let old_external_block = inode.file_acl_block(); let mut xattrs = self.read_xattrs(inode)?; let old_inline_layout = (old_external_block == 0).then(|| xattr_inline_layout(&xattrs)); let mutation = update(&mut xattrs)?; if mutation == XattrMutation::Unchanged { - let plan = self.plan_inode_xattr_storage(inode, &xattrs, true)?; - return Ok((JournalCredits::new(0), plan, mutation)); + return Ok(XattrMutationPlan::Unchanged); } let old_layout = if let Some(layout) = old_inline_layout { layout @@ -389,72 +409,38 @@ impl Ext4Filesystem { shared: refcount > 1, } }; - let plan = self.plan_inode_xattr_storage(inode, &xattrs, true)?; + let can_materialize_external = can_materialize_external_xattr_layout( + old_layout, + self.superblock().free_blocks_count(), + ); + let plan = self.plan_inode_xattr_storage(inode, &xattrs, can_materialize_external)?; let new_layout = plan.storage.layout(); - if !plan.storage.external.is_empty() { - let reuses_existing = - matches!(old_layout, XattrStorageLayout::External { shared: false }); - if !reuses_existing && self.superblock().free_blocks_count() == 0 { - return Err(Ext4Error::NoSpace); - } - } let planned_external_block = (!plan.storage.external.is_empty()) .then_some(FilesystemBlock::new(old_external_block.max(1))); self.prepare_xattr_inode_update(inode, &plan, planned_external_block, Some(timestamp))?; - Ok(( - JournalCredits::new(xattr_mutation_credit_count(old_layout, new_layout)), - plan, - mutation, - )) + Ok(XattrMutationPlan::Changed { + credits: JournalCredits::new(xattr_mutation_credit_count(old_layout, new_layout)), + storage: plan, + }) } fn plan_inode_xattr_storage( &self, inode: &Ext4Inode, xattrs: &[Ext4Xattr], - can_allocate_external: bool, + can_materialize_external: bool, ) -> Ext4Result { let inode_size = usize::from(self.superblock().inode_size()); let block_size = usize::try_from(self.layout().block_size()).map_err(|_| Ext4Error::Overflow)?; let current_extra_isize = inode.extra_isize(); - let desired_extra_isize = current_extra_isize.max( - self.superblock() - .want_extra_isize() - .max(self.superblock().min_extra_isize()), - ); - let minimum_extra_isize = current_extra_isize.max(self.superblock().min_extra_isize()); - let candidates = [ - desired_extra_isize, - minimum_extra_isize, + let candidates = extra_isize_layout_candidates( + inode_size, current_extra_isize, - ]; - - for (index, extra_isize) in candidates.into_iter().enumerate() { - if candidates[..index].contains(&extra_isize) { - continue; - } - let inline_capacity = inode_size - .checked_sub(disk_inode::GOOD_OLD_INODE_SIZE) - .and_then(|available| available.checked_sub(usize::from(extra_isize))) - .ok_or(Ext4Error::Corrupt(CorruptKind::InvalidInode))?; - let Some(storage) = plan_xattr_storage(xattrs, inline_capacity, block_size)? else { - continue; - }; - if extra_isize != current_extra_isize - && !storage.external.is_empty() - && !can_allocate_external - { - continue; - } - return Ok(InodeXattrStoragePlan { - extra_isize, - storage, - rewrite_external: true, - }); - } - - Err(Ext4Error::NoSpace) + self.superblock().want_extra_isize(), + self.superblock().min_extra_isize(), + )?; + plan_xattr_mutation_storage(xattrs, candidates, block_size, can_materialize_external) } /// Best-effort expansion of an existing inode's extra fields. @@ -467,6 +453,9 @@ impl Ext4Filesystem { inode: &Ext4Inode, handle: &mut JournalHandle<'_>, ) -> Ext4Result<()> { + if inode.is_extra_isize_expansion_disabled() { + return Ok(()); + } let current_extra_isize = inode.extra_isize(); let desired_extra_isize = self .superblock() @@ -481,7 +470,7 @@ impl Ext4Filesystem { let mut external_xattrs = Vec::new(); self.read_external_xattrs(inode, &mut external_xattrs)?; let old_external_block = inode.file_acl_block(); - let can_allocate_external = if old_external_block == 0 { + let can_rewrite_external = if old_external_block == 0 { self.superblock().free_blocks_count() != 0 } else { self.external_xattr_block_refcount(inode, old_external_block)? == 1 @@ -491,13 +480,17 @@ impl Ext4Filesystem { inode, &inline_xattrs, &external_xattrs, - can_allocate_external, + can_rewrite_external, ) { Ok(plan) => plan, - Err(Ext4Error::NoSpace) => return Ok(()), + Err(Ext4Error::NoSpace) => { + inode.disable_extra_isize_expansion(); + return Ok(()); + } Err(error) => return Err(error), }; if plan.extra_isize == current_extra_isize { + inode.disable_extra_isize_expansion(); return Ok(()); } @@ -538,28 +531,16 @@ impl Ext4Filesystem { let block_size = usize::try_from(self.layout().block_size()).map_err(|_| Ext4Error::Overflow)?; let current_extra_isize = inode.extra_isize(); - let desired_extra_isize = current_extra_isize.max( - self.superblock() - .want_extra_isize() - .max(self.superblock().min_extra_isize()), - ); - let minimum_extra_isize = current_extra_isize.max(self.superblock().min_extra_isize()); - let candidates = [ - desired_extra_isize, - minimum_extra_isize, + let candidates = extra_isize_layout_candidates( + inode_size, current_extra_isize, - ]; + self.superblock().want_extra_isize(), + self.superblock().min_extra_isize(), + )?; let mut current_external = Vec::from(external_xattrs); sort_xattrs(&mut current_external); - for (index, extra_isize) in candidates.into_iter().enumerate() { - if candidates[..index].contains(&extra_isize) { - continue; - } - let inline_capacity = inode_size - .checked_sub(disk_inode::GOOD_OLD_INODE_SIZE) - .and_then(|available| available.checked_sub(usize::from(extra_isize))) - .ok_or(Ext4Error::Corrupt(CorruptKind::InvalidInode))?; + select_extra_isize_storage_plan(candidates, |inline_capacity| { let Some(storage) = plan_xattr_expansion_storage( inline_xattrs, external_xattrs, @@ -567,20 +548,14 @@ impl Ext4Filesystem { block_size, )? else { - continue; + return Ok(None); }; let rewrite_external = storage.external != current_external; - if extra_isize != current_extra_isize && rewrite_external && !can_rewrite_external { - continue; + if rewrite_external && !can_rewrite_external { + return Ok(None); } - return Ok(InodeXattrStoragePlan { - extra_isize, - storage, - rewrite_external, - }); - } - - Err(Ext4Error::NoSpace) + Ok(Some((storage, rewrite_external))) + }) } fn prepare_xattr_inode_update( @@ -1017,6 +992,80 @@ fn xattr_inline_layout(xattrs: &[Ext4Xattr]) -> XattrStorageLayout { } } +fn extra_isize_layout_candidates( + inode_size: usize, + current_extra_isize: u16, + wanted_extra_isize: u16, + minimum_extra_isize: u16, +) -> Ext4Result<[Option; 3]> { + let desired_extra_isize = current_extra_isize.max(wanted_extra_isize.max(minimum_extra_isize)); + let minimum_extra_isize = current_extra_isize.max(minimum_extra_isize); + let extra_isizes = [ + desired_extra_isize, + minimum_extra_isize, + current_extra_isize, + ]; + let mut candidates = [None; 3]; + + for (index, extra_isize) in extra_isizes.into_iter().enumerate() { + if extra_isizes[..index].contains(&extra_isize) { + continue; + } + let inline_capacity = inode_size + .checked_sub(disk_inode::GOOD_OLD_INODE_SIZE) + .and_then(|available| available.checked_sub(usize::from(extra_isize))) + .ok_or(Ext4Error::Corrupt(CorruptKind::InvalidInode))?; + candidates[index] = Some(ExtraIsizeLayoutCandidate { + extra_isize, + inline_capacity, + }); + } + + Ok(candidates) +} + +fn select_extra_isize_storage_plan( + candidates: [Option; 3], + mut plan_storage: impl FnMut(usize) -> Ext4Result>, +) -> Ext4Result { + for candidate in candidates.into_iter().flatten() { + let Some((storage, rewrite_external)) = plan_storage(candidate.inline_capacity)? else { + continue; + }; + return Ok(InodeXattrStoragePlan { + extra_isize: candidate.extra_isize, + storage, + rewrite_external, + }); + } + + Err(Ext4Error::NoSpace) +} + +fn can_materialize_external_xattr_layout( + old_layout: XattrStorageLayout, + free_blocks_count: u64, +) -> bool { + matches!(old_layout, XattrStorageLayout::External { shared: false }) || free_blocks_count != 0 +} + +fn plan_xattr_mutation_storage( + xattrs: &[Ext4Xattr], + candidates: [Option; 3], + external_capacity: usize, + can_materialize_external: bool, +) -> Ext4Result { + select_extra_isize_storage_plan(candidates, |inline_capacity| { + let Some(storage) = plan_xattr_storage(xattrs, inline_capacity, external_capacity)? else { + return Ok(None); + }; + if !storage.external.is_empty() && !can_materialize_external { + return Ok(None); + } + Ok(Some((storage, true))) + }) +} + fn plan_xattr_storage( xattrs: &[Ext4Xattr], inline_capacity: usize, @@ -2146,6 +2195,36 @@ mod tests { assert_eq!(names, expected_names); } + #[test] + fn xattr_storage_plan_falls_back_to_current_inline_layout_when_disk_is_full() { + let xattrs = vec![Ext4Xattr { + namespace: Ext4XattrNamespace::User, + name: Vec::from(&b"key"[..]), + value: vec![0x5a; 88], + }]; + let candidates = extra_isize_layout_candidates(256, 0, 16, 0).unwrap(); + + let external_plan = plan_xattr_mutation_storage(&xattrs, candidates, 4096, true) + .expect("desired extra isize can use a new external block"); + assert_eq!(external_plan.extra_isize, 16); + assert!(!external_plan.storage.external.is_empty()); + + let plan = plan_xattr_mutation_storage(&xattrs, candidates, 4096, false) + .expect("current extra isize keeps the xattr inline without allocating a block"); + + assert_eq!(plan.extra_isize, 0); + assert_eq!(plan.storage.inline, xattrs); + assert!(plan.storage.external.is_empty()); + assert!(!can_materialize_external_xattr_layout( + XattrStorageLayout::Inline, + 0 + )); + assert!(can_materialize_external_xattr_layout( + XattrStorageLayout::External { shared: false }, + 0 + )); + } + #[test] fn extra_isize_layout_falls_back_to_minimum_then_current_size() { let almost_full = numbered_xattrs(178); -- Gitee