Skip to main content

arcbox_ext4/
xattr.rs

1// Extended attribute handling.
2//
3// Provides name compression / decompression (prefix stripping), size
4// calculations, hashing, and serialization for both inline (inode) and
5// block-level xattrs.
6
7use crate::constants::*;
8use crate::error::FormatError;
9
10// ---------------------------------------------------------------------------
11// Prefix table
12// ---------------------------------------------------------------------------
13
14/// Known xattr name prefixes.  The index is stored on disk; the prefix string
15/// is stripped from (or prepended to) the attribute name.
16const XATTR_PREFIXES: &[(u8, &str)] = &[
17    (1, "user."),
18    (2, "system.posix_acl_access"),
19    (3, "system.posix_acl_default"),
20    (4, "trusted."),
21    (6, "security."),
22    (7, "system."),
23    (8, "system.richacl"),
24];
25
26// ---------------------------------------------------------------------------
27// Helpers
28// ---------------------------------------------------------------------------
29
30/// Round `n` up to the next multiple of `align`.
31#[inline]
32fn align_up(n: usize, align: usize) -> usize {
33    (n + align - 1) & !(align - 1)
34}
35
36// ---------------------------------------------------------------------------
37// ExtendedAttribute
38// ---------------------------------------------------------------------------
39
40/// A single extended attribute, with the name already compressed (prefix
41/// stripped) for on-disk storage.
42#[derive(Debug, Clone)]
43pub struct ExtendedAttribute {
44    /// The name with its prefix stripped (the "suffix").
45    pub name: String,
46    /// Prefix index (see `XATTR_PREFIXES`).
47    pub index: u8,
48    /// Raw attribute value.
49    pub value: Vec<u8>,
50}
51
52impl ExtendedAttribute {
53    /// Create from a full attribute name (e.g. "user.mime_type") and value.
54    /// The name is automatically compressed by finding the longest matching
55    /// prefix.
56    pub fn new(full_name: &str, value: Vec<u8>) -> Self {
57        let (index, suffix) = Self::compress_name(full_name);
58        Self {
59            name: suffix,
60            index,
61            value,
62        }
63    }
64
65    /// Compress an attribute name by finding the longest matching prefix.
66    /// Returns `(prefix_index, suffix)`.  If no prefix matches, index 0 is
67    /// returned and the full name is kept.
68    pub fn compress_name(name: &str) -> (u8, String) {
69        let mut best_index = 0u8;
70        let mut best_prefix_len = 0usize;
71
72        for &(idx, prefix) in XATTR_PREFIXES {
73            if name.starts_with(prefix) && prefix.len() > best_prefix_len {
74                best_index = idx;
75                best_prefix_len = prefix.len();
76            }
77        }
78
79        let suffix = &name[best_prefix_len..];
80        (best_index, suffix.to_string())
81    }
82
83    /// Reconstruct the full attribute name from a prefix index and suffix.
84    pub fn decompress_name(index: u8, suffix: &str) -> String {
85        for &(idx, prefix) in XATTR_PREFIXES {
86            if idx == index {
87                return format!("{}{}", prefix, suffix);
88            }
89        }
90        // Unknown index -- return the suffix as-is.
91        suffix.to_string()
92    }
93
94    /// On-disk size of the entry header + name (aligned to 4 bytes).
95    /// The entry header (XAttrEntry) is 16 bytes.
96    pub fn entry_size(&self) -> u32 {
97        align_up(self.name.len() + 16, 4) as u32
98    }
99
100    /// On-disk size of the value (aligned to 4 bytes).
101    pub fn value_size(&self) -> u32 {
102        align_up(self.value.len(), 4) as u32
103    }
104
105    /// Total on-disk footprint: entry + value.
106    pub fn total_size(&self) -> u32 {
107        self.entry_size() + self.value_size()
108    }
109
110    /// Compute the ext4 xattr hash for this attribute.
111    ///
112    /// The hash covers the name (byte-by-byte mixed into a rolling hash) and
113    /// the value (word-by-word).  This matches the kernel's
114    /// `ext4_xattr_hash_entry` algorithm.
115    pub fn hash(&self) -> u32 {
116        // Hash the name.
117        let mut h = 0u32;
118        for &b in self.name.as_bytes() {
119            h = (h << NAME_HASH_SHIFT) ^ (h >> (8 * 4 - NAME_HASH_SHIFT)) ^ (b as u32);
120        }
121
122        // Mix in the value, processing it as little-endian u32 words.
123        // Partial trailing bytes are handled by zero-padding.
124        let value = &self.value;
125        let full_words = value.len() / 4;
126        for i in 0..full_words {
127            let off = i * 4;
128            let word = u32::from_le_bytes([
129                value[off],
130                value[off + 1],
131                value[off + 2],
132                value[off + 3],
133            ]);
134            h = (h << VALUE_HASH_SHIFT) ^ (h >> (8 * 4 - VALUE_HASH_SHIFT)) ^ word;
135        }
136
137        // Handle trailing bytes (if any).
138        let tail = value.len() % 4;
139        if tail > 0 {
140            let off = full_words * 4;
141            let mut bytes = [0u8; 4];
142            bytes[..tail].copy_from_slice(&value[off..]);
143            let word = u32::from_le_bytes(bytes);
144            h = (h << VALUE_HASH_SHIFT) ^ (h >> (8 * 4 - VALUE_HASH_SHIFT)) ^ word;
145        }
146
147        h
148    }
149}
150
151/// Shift amount used when hashing attribute name bytes.
152const NAME_HASH_SHIFT: u32 = 5;
153/// Shift amount used when hashing attribute value words.
154const VALUE_HASH_SHIFT: u32 = 16;
155
156// ---------------------------------------------------------------------------
157// XattrState
158// ---------------------------------------------------------------------------
159
160/// Tracks which extended attributes go inline (in the inode's extra space)
161/// versus in a separate xattr block.
162pub struct XattrState {
163    /// Capacity for inline xattrs (typically `INODE_EXTRA_SIZE` = 96).
164    inode_capacity: u32,
165    /// Capacity for block xattrs (one full filesystem block).
166    block_capacity: u32,
167    /// Attributes assigned to inline storage.
168    inline_attrs: Vec<ExtendedAttribute>,
169    /// Attributes assigned to block storage.
170    block_attrs: Vec<ExtendedAttribute>,
171    /// Bytes consumed in the inline area (includes the 4-byte magic header).
172    used_inline: u32,
173    /// Bytes consumed in the block area (includes the 32-byte block header).
174    used_block: u32,
175    /// The inode that owns these xattrs (for error reporting).
176    inode_number: u32,
177}
178
179impl XattrState {
180    /// Create a new xattr state tracker for the given inode.
181    pub fn new(inode: u32, inode_capacity: u32, block_capacity: u32) -> Self {
182        Self {
183            inode_capacity,
184            block_capacity,
185            inline_attrs: Vec::new(),
186            block_attrs: Vec::new(),
187            // The inline area starts with a 4-byte magic header.
188            used_inline: XATTR_INODE_HEADER_SIZE,
189            // The block area starts with a 32-byte header.
190            used_block: XATTR_BLOCK_HEADER_SIZE,
191            inode_number: inode,
192        }
193    }
194
195    /// Add an attribute.  It is placed inline if there is room; otherwise it
196    /// goes into the block area.  Returns an error if neither has enough space.
197    pub fn add(&mut self, attr: ExtendedAttribute) -> Result<(), FormatError> {
198        let total = attr.total_size();
199
200        // Try inline first.  Reserve 4 bytes for the null terminator that
201        // marks the end of the entry list (required by ext4 readers).
202        if self.used_inline + total + 4 <= self.inode_capacity {
203            self.used_inline += total;
204            self.inline_attrs.push(attr);
205            return Ok(());
206        }
207
208        // Fall back to block.  Same 4-byte null terminator reservation.
209        if self.used_block + total + 4 <= self.block_capacity {
210            self.used_block += total;
211            self.block_attrs.push(attr);
212            return Ok(());
213        }
214
215        Err(FormatError::XattrInsufficientSpace(self.inode_number))
216    }
217
218    /// Whether any inline xattrs have been recorded.
219    pub fn has_inline(&self) -> bool {
220        !self.inline_attrs.is_empty()
221    }
222
223    /// Whether any block xattrs have been recorded.
224    pub fn has_block(&self) -> bool {
225        !self.block_attrs.is_empty()
226    }
227
228    /// Serialize the inline xattrs into a buffer suitable for the inode's
229    /// inline xattr area.
230    ///
231    /// The returned buffer is exactly `inode_capacity` bytes.  Layout:
232    ///   - 4-byte magic (`XATTR_HEADER_MAGIC`)
233    ///   - Entries packed from the front (header + name + padding)
234    ///   - Values packed from the back (aligned to 4 bytes)
235    pub fn write_inline(&self) -> Result<Vec<u8>, FormatError> {
236        let capacity = self.inode_capacity as usize;
237        let mut buf = vec![0u8; capacity];
238
239        // Write the 4-byte magic.
240        buf[0..4].copy_from_slice(&XATTR_HEADER_MAGIC.to_le_bytes());
241
242        let mut entry_offset = XATTR_INODE_HEADER_SIZE as usize;
243        let mut value_end = capacity;
244
245        for attr in &self.inline_attrs {
246            // Place the value at the back, aligned down to 4 bytes.
247            let val_size_aligned = align_up(attr.value.len(), 4);
248            value_end -= val_size_aligned;
249
250            // The value_offset field is relative to the start of the first
251            // entry (i.e. right after the 4-byte magic header for inline attrs).
252            let rel_value_offset = value_end - XATTR_INODE_HEADER_SIZE as usize;
253
254            // Write the entry header.
255            if entry_offset + 16 + attr.name.len() > buf.len() {
256                return Err(FormatError::MalformedXattrBuffer);
257            }
258            write_xattr_entry(
259                &mut buf[entry_offset..],
260                &attr.name,
261                attr.index,
262                rel_value_offset as u16,
263                attr.value.len() as u32,
264                0, // Hash is 0 for inline xattrs.
265            );
266            entry_offset += align_up(16 + attr.name.len(), 4);
267
268            // Write the value.
269            buf[value_end..value_end + attr.value.len()].copy_from_slice(&attr.value);
270        }
271
272        Ok(buf)
273    }
274
275    /// Serialize the block xattrs into a full block-sized buffer.
276    ///
277    /// Layout:
278    ///   - 32-byte header (magic + refcount=1 + blocks=1 + 20 zero bytes)
279    ///   - Entries sorted by (index, name_len, name), packed from the front
280    ///   - Values packed from the back
281    pub fn write_block(&self) -> Result<Vec<u8>, FormatError> {
282        let capacity = self.block_capacity as usize;
283        let mut buf = vec![0u8; capacity];
284
285        // Write the 32-byte block header.
286        buf[0..4].copy_from_slice(&XATTR_HEADER_MAGIC.to_le_bytes());
287        // refcount = 1
288        buf[4..8].copy_from_slice(&1u32.to_le_bytes());
289        // blocks = 1
290        buf[8..12].copy_from_slice(&1u32.to_le_bytes());
291        // Bytes 12..32 are zero (already zeroed).
292
293        // Sort attributes by (index, name_len, name) for deterministic output.
294        let mut sorted: Vec<&ExtendedAttribute> = self.block_attrs.iter().collect();
295        sorted.sort_by(|a, b| {
296            a.index.cmp(&b.index)
297                .then_with(|| a.name.len().cmp(&b.name.len()))
298                .then_with(|| a.name.cmp(&b.name))
299        });
300
301        let mut entry_offset = XATTR_BLOCK_HEADER_SIZE as usize;
302        let mut value_end = capacity;
303
304        for attr in &sorted {
305            // Place the value at the back.
306            let val_size_aligned = align_up(attr.value.len(), 4);
307            value_end -= val_size_aligned;
308
309            // For block xattrs, value_offset is relative to the start of the
310            // block (absolute offset within the block buffer).
311            let rel_value_offset = value_end;
312
313            if entry_offset + 16 + attr.name.len() > buf.len() {
314                return Err(FormatError::MalformedXattrBuffer);
315            }
316            write_xattr_entry(
317                &mut buf[entry_offset..],
318                &attr.name,
319                attr.index,
320                rel_value_offset as u16,
321                attr.value.len() as u32,
322                attr.hash(),
323            );
324            entry_offset += align_up(16 + attr.name.len(), 4);
325
326            // Write the value.
327            buf[value_end..value_end + attr.value.len()].copy_from_slice(&attr.value);
328        }
329
330        Ok(buf)
331    }
332}
333
334/// Write a single xattr entry (16-byte header + name + padding) into `buf`.
335fn write_xattr_entry(
336    buf: &mut [u8],
337    name: &str,
338    name_index: u8,
339    value_offset: u16,
340    value_size: u32,
341    hash: u32,
342) {
343    let name_bytes = name.as_bytes();
344
345    // name_len (1 byte)
346    buf[0] = name_bytes.len() as u8;
347    // name_index (1 byte)
348    buf[1] = name_index;
349    // value_offset (2 bytes LE)
350    buf[2..4].copy_from_slice(&value_offset.to_le_bytes());
351    // value_inum (4 bytes LE) -- always 0
352    buf[4..8].copy_from_slice(&0u32.to_le_bytes());
353    // value_size (4 bytes LE)
354    buf[8..12].copy_from_slice(&value_size.to_le_bytes());
355    // hash (4 bytes LE)
356    buf[12..16].copy_from_slice(&hash.to_le_bytes());
357    // name
358    buf[16..16 + name_bytes.len()].copy_from_slice(name_bytes);
359    // Padding is already zero (buffer was zeroed on creation).
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_compress_name_user_prefix() {
368        let (idx, suffix) = ExtendedAttribute::compress_name("user.mime_type");
369        assert_eq!(idx, 1);
370        assert_eq!(suffix, "mime_type");
371    }
372
373    #[test]
374    fn test_compress_name_security_prefix() {
375        let (idx, suffix) = ExtendedAttribute::compress_name("security.selinux");
376        assert_eq!(idx, 6);
377        assert_eq!(suffix, "selinux");
378    }
379
380    #[test]
381    fn test_compress_name_system_posix_acl() {
382        // "system.posix_acl_access" is an exact match for index 2, which is
383        // longer than the generic "system." prefix (index 7).
384        let (idx, suffix) = ExtendedAttribute::compress_name("system.posix_acl_access");
385        assert_eq!(idx, 2);
386        assert_eq!(suffix, "");
387    }
388
389    #[test]
390    fn test_compress_name_system_generic() {
391        let (idx, suffix) = ExtendedAttribute::compress_name("system.something");
392        assert_eq!(idx, 7);
393        assert_eq!(suffix, "something");
394    }
395
396    #[test]
397    fn test_compress_name_no_match() {
398        let (idx, suffix) = ExtendedAttribute::compress_name("unknown.attr");
399        assert_eq!(idx, 0);
400        assert_eq!(suffix, "unknown.attr");
401    }
402
403    #[test]
404    fn test_decompress_name() {
405        assert_eq!(
406            ExtendedAttribute::decompress_name(1, "mime_type"),
407            "user.mime_type"
408        );
409        assert_eq!(
410            ExtendedAttribute::decompress_name(6, "selinux"),
411            "security.selinux"
412        );
413        assert_eq!(
414            ExtendedAttribute::decompress_name(2, ""),
415            "system.posix_acl_access"
416        );
417        // Unknown index returns suffix as-is.
418        assert_eq!(
419            ExtendedAttribute::decompress_name(99, "foo"),
420            "foo"
421        );
422    }
423
424    #[test]
425    fn test_entry_and_value_sizes() {
426        let attr = ExtendedAttribute::new("user.x", vec![0u8; 10]);
427        // name = "x" (1 byte), entry header = 16 bytes -> align_up(17, 4) = 20
428        assert_eq!(attr.entry_size(), 20);
429        // value = 10 bytes -> align_up(10, 4) = 12
430        assert_eq!(attr.value_size(), 12);
431        assert_eq!(attr.total_size(), 32);
432    }
433
434    #[test]
435    fn test_hash_deterministic() {
436        let attr = ExtendedAttribute::new("user.test", b"hello".to_vec());
437        let h1 = attr.hash();
438        let h2 = attr.hash();
439        assert_eq!(h1, h2);
440        assert_ne!(h1, 0);
441    }
442
443    #[test]
444    fn test_xattr_state_inline() {
445        let mut state = XattrState::new(11, INODE_EXTRA_SIZE, 4096);
446        let attr = ExtendedAttribute::new("user.x", vec![1, 2, 3]);
447        state.add(attr).unwrap();
448
449        assert!(state.has_inline());
450        assert!(!state.has_block());
451
452        let buf = state.write_inline().unwrap();
453        assert_eq!(buf.len(), INODE_EXTRA_SIZE as usize);
454        // First 4 bytes should be the magic.
455        let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
456        assert_eq!(magic, XATTR_HEADER_MAGIC);
457    }
458
459    #[test]
460    fn test_xattr_state_overflow_to_block() {
461        // Use a tiny inline capacity so everything overflows to block.
462        let mut state = XattrState::new(11, XATTR_INODE_HEADER_SIZE, 4096);
463        let attr = ExtendedAttribute::new("user.large", vec![0u8; 100]);
464        state.add(attr).unwrap();
465
466        assert!(!state.has_inline());
467        assert!(state.has_block());
468
469        let buf = state.write_block().unwrap();
470        assert_eq!(buf.len(), 4096);
471        // First 4 bytes: magic.
472        let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
473        assert_eq!(magic, XATTR_HEADER_MAGIC);
474        // Bytes 4..8: refcount = 1.
475        let refcount = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
476        assert_eq!(refcount, 1);
477    }
478
479    #[test]
480    fn test_xattr_state_insufficient_space() {
481        // Both inline and block are too small.
482        let mut state = XattrState::new(11, XATTR_INODE_HEADER_SIZE, XATTR_BLOCK_HEADER_SIZE);
483        let attr = ExtendedAttribute::new("user.big", vec![0u8; 100]);
484        let result = state.add(attr);
485        assert!(result.is_err());
486    }
487
488    #[test]
489    fn test_compress_decompress_roundtrip() {
490        // Every known prefix should survive a compress -> decompress cycle.
491        let names = [
492            "user.custom_key",
493            "security.selinux",
494            "trusted.overlay.opaque",
495            "system.posix_acl_access",
496            "system.posix_acl_default",
497            "system.richacl",
498            "system.other",
499        ];
500        for full_name in names {
501            let (idx, suffix) = ExtendedAttribute::compress_name(full_name);
502            let reconstructed = ExtendedAttribute::decompress_name(idx, &suffix);
503            assert_eq!(
504                reconstructed, full_name,
505                "roundtrip failed for {full_name}"
506            );
507        }
508    }
509
510    #[test]
511    fn test_hash_different_values() {
512        // Different values should (almost certainly) produce different hashes.
513        let a = ExtendedAttribute::new("user.test", b"value_a".to_vec());
514        let b = ExtendedAttribute::new("user.test", b"value_b".to_vec());
515        assert_ne!(a.hash(), b.hash());
516    }
517
518    #[test]
519    fn test_hash_different_names() {
520        // Different names with the same value should produce different hashes.
521        let a = ExtendedAttribute::new("user.alpha", b"same".to_vec());
522        let b = ExtendedAttribute::new("user.beta", b"same".to_vec());
523        assert_ne!(a.hash(), b.hash());
524    }
525
526    #[test]
527    fn test_hash_empty_value() {
528        // An empty value should still produce a non-zero hash (from the name).
529        let attr = ExtendedAttribute::new("user.empty", Vec::new());
530        assert_ne!(attr.hash(), 0);
531    }
532
533    #[test]
534    fn test_hash_value_with_trailing_bytes() {
535        // Value whose length is not a multiple of 4 -- exercises the tail path.
536        let attr = ExtendedAttribute::new("user.tail", vec![1, 2, 3, 4, 5]);
537        let h = attr.hash();
538        assert_ne!(h, 0);
539        // Should be deterministic.
540        assert_eq!(h, attr.hash());
541    }
542
543    #[test]
544    fn test_entry_size_alignment() {
545        // Name of exact multiple of 4 bytes: "abcd" (4 chars) + 16 header = 20
546        // -> align_up(20, 4) = 20.
547        let attr = ExtendedAttribute::new("user.abcd", vec![0]);
548        assert_eq!(attr.entry_size(), 20);
549
550        // Name of 5 bytes: "abcde" + 16 = 21 -> align_up(21, 4) = 24.
551        let attr = ExtendedAttribute::new("user.abcde", vec![0]);
552        assert_eq!(attr.entry_size(), 24);
553
554        // Empty suffix: "system.posix_acl_access" compresses to suffix ""
555        // -> 0 bytes + 16 = 16 -> aligned = 16.
556        let attr = ExtendedAttribute::new("system.posix_acl_access", vec![0]);
557        assert_eq!(attr.entry_size(), 16);
558    }
559
560    #[test]
561    fn test_value_size_alignment() {
562        // 0 bytes -> 0.
563        let attr = ExtendedAttribute::new("user.x", Vec::new());
564        assert_eq!(attr.value_size(), 0);
565
566        // 1 byte -> align_up(1, 4) = 4.
567        let attr = ExtendedAttribute::new("user.x", vec![42]);
568        assert_eq!(attr.value_size(), 4);
569
570        // 4 bytes -> 4.
571        let attr = ExtendedAttribute::new("user.x", vec![0; 4]);
572        assert_eq!(attr.value_size(), 4);
573
574        // 5 bytes -> 8.
575        let attr = ExtendedAttribute::new("user.x", vec![0; 5]);
576        assert_eq!(attr.value_size(), 8);
577    }
578
579    #[test]
580    fn test_xattr_state_multiple_inline() {
581        // Add several small attributes that all fit inline.
582        let mut state = XattrState::new(11, INODE_EXTRA_SIZE, 4096);
583        state
584            .add(ExtendedAttribute::new("user.a", vec![1]))
585            .unwrap();
586        state
587            .add(ExtendedAttribute::new("user.b", vec![2]))
588            .unwrap();
589        state
590            .add(ExtendedAttribute::new("user.c", vec![3]))
591            .unwrap();
592
593        assert!(state.has_inline());
594        assert!(!state.has_block());
595
596        let buf = state.write_inline().unwrap();
597        let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
598        assert_eq!(magic, XATTR_HEADER_MAGIC);
599
600        // There should be data beyond the header.
601        assert!(buf[4..].iter().any(|&b| b != 0));
602    }
603
604    #[test]
605    fn test_xattr_state_mixed_inline_and_block() {
606        // Small inline capacity that fits one tiny attr, then overflow the rest.
607        // entry_size("user.a", [1]) = align_up(16 + 1, 4) = 20
608        // value_size([1]) = 4
609        // total = 24. Inline header = 4. Plus 4-byte null terminator reservation.
610        // Need at least 4 + 24 + 4 = 32 bytes inline capacity.
611        let inline_cap = 32;
612        let mut state = XattrState::new(11, inline_cap, 4096);
613        state
614            .add(ExtendedAttribute::new("user.a", vec![1]))
615            .unwrap();
616        // Second attr won't fit inline.
617        state
618            .add(ExtendedAttribute::new("user.b", vec![2]))
619            .unwrap();
620
621        assert!(state.has_inline());
622        assert!(state.has_block());
623    }
624
625    #[test]
626    fn test_write_block_sorted_output() {
627        // Attributes should be sorted by (index, name_len, name) in block output.
628        let mut state = XattrState::new(11, XATTR_INODE_HEADER_SIZE, 4096);
629        // Add in reverse order.
630        state
631            .add(ExtendedAttribute::new("user.zzz", vec![3]))
632            .unwrap();
633        state
634            .add(ExtendedAttribute::new("security.aaa", vec![1]))
635            .unwrap();
636        state
637            .add(ExtendedAttribute::new("user.aaa", vec![2]))
638            .unwrap();
639
640        let buf = state.write_block().unwrap();
641        // The block header is 32 bytes. The first entry starts at offset 32.
642        // Entry layout: [name_len(1), name_index(1), ...].
643        // security (index=6) should come before user (index=1)?
644        // Actually ext4 sorts by index numerically: 1 < 6.
645        // So user (index=1) entries first, then security (index=6).
646        let first_entry_index = buf[32 + 1]; // name_index of first entry
647        let second_entry_index = buf[32 + 1 + align_up(16 + 3, 4) as usize]; // "aaa" is 3 bytes
648        assert!(
649            first_entry_index <= second_entry_index,
650            "entries should be sorted by name_index: {} vs {}",
651            first_entry_index,
652            second_entry_index,
653        );
654    }
655
656    /// Helper matching the crate-private `align_up` for test assertions.
657    fn align_up(n: usize, align: usize) -> usize {
658        (n + align - 1) & !(align - 1)
659    }
660}