Skip to main content

vhdx/log/
core.rs

1//! VHDX Log Section Parser
2//!
3//! Implements zero-copy parsing of the VHDX log ring buffer.
4//! The log consists of variable-sized entries (4KB aligned), each containing
5//! a header, descriptors, and data sectors.
6
7use std::borrow::Cow;
8use std::fmt;
9use std::sync::OnceLock;
10
11use crate::constants::{
12    DESCRIPTOR_SIZE, ENTRY_HEADER_SIZE, SECTOR_SIZE, SIGNATURE_DESC, SIGNATURE_LOGE, SIGNATURE_ZERO,
13};
14use crate::error::{Error, Result, SignaturePosition};
15use crate::types::{Crc32c, Guid};
16
17// ---------------------------------------------------------------------------
18// Log layout
19// ---------------------------------------------------------------------------
20// Log
21// ---------------------------------------------------------------------------
22
23/// View into the VHDX log ring buffer.
24///
25/// The log is a circular buffer stored contiguously at a location specified
26/// in the VHDX header. It consists of variable-sized entries that are at
27/// least 4 KB aligned.
28#[derive(Clone, Copy)]
29pub struct Log<'a> {
30    data: &'a [u8],
31}
32
33impl<'a> Log<'a> {
34    /// Create a new `Log` view over the raw log buffer.
35    ///
36    /// The buffer length must be a multiple of 4 KB (MB-aligned on disk,
37    /// but we just need 4 KB alignment for entry parsing).
38    ///
39    /// # Errors
40    ///
41    /// Returns `Error::LogEntryCorrupted` if the buffer size is not a
42    /// multiple of 4 KB.
43    pub(crate) fn new(data: &'a [u8]) -> Result<Self> {
44        if !data.len().is_multiple_of(SECTOR_SIZE as usize) {
45            return Err(Error::LogEntryCorrupted(
46                "log buffer size is not a multiple of 4KB".into(),
47            ));
48        }
49        Ok(Self { data })
50    }
51
52    /// Return the total size of the log buffer in bytes.
53    #[must_use]
54    pub(crate) fn len(&self) -> usize {
55        self.data.len()
56    }
57
58    /// Return `true` if the log buffer is empty.
59    #[must_use]
60    #[cfg(test)]
61    pub(crate) fn is_empty(&self) -> bool {
62        self.data.is_empty()
63    }
64
65    /// Get a log entry by index (0-based, scanning from the start of the buffer).
66    ///
67    /// Entries are located by walking the buffer: entry 0 starts at offset 0,
68    /// entry 1 starts at `entry0.header().entry_length()` bytes from the start, etc.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the index is out of bounds or an entry is malformed.
73    pub fn entry(&self, index: usize) -> Result<Entry<'_>> {
74        let mut offset: usize = 0;
75        for i in 0..=index {
76            if offset >= self.data.len() {
77                return Err(Error::InvalidParameter(format!(
78                    "log entry index {index} out of bounds"
79                )));
80            }
81            if i == index {
82                return self.parse_entry_at(offset);
83            }
84            // Skip this entry by reading its length from the header
85            let entry_length = u32_at(&self.data[offset + 8..offset + 12]).ok_or_else(|| {
86                Error::LogEntryCorrupted("log buffer too small for entry header".into())
87            })?;
88            if entry_length == 0 || !(entry_length as usize).is_multiple_of(SECTOR_SIZE as usize) {
89                return Err(Error::LogEntryCorrupted(format!(
90                    "invalid entry length {entry_length} at index {i}"
91                )));
92            }
93            offset += entry_length as usize;
94        }
95        Err(Error::InvalidParameter(format!(
96            "log entry index {index} not found"
97        )))
98    }
99
100    /// Parse a log entry at a specific byte offset within the log buffer.
101    ///
102    /// The offset must be 4KB-aligned and the entry must be fully contained
103    /// within the buffer.
104    ///
105    /// # Errors
106    ///
107    /// Returns errors from [`parse_entry_at`](Self::parse_entry_at):
108    /// `Error::InvalidSignature` if the entry signature is not `"loge"`,
109    /// `Error::LogEntryCorrupted` if the entry length is invalid or the
110    /// entry extends beyond the buffer.
111    pub(crate) fn entry_at(&self, offset: usize) -> Result<Entry<'_>> {
112        self.parse_entry_at(offset)
113    }
114
115    /// Iterate over all valid log entries in the buffer.
116    ///
117    /// Scans entries sequentially from the beginning. Stops when the buffer
118    /// is exhausted or an invalid entry is encountered.
119    pub fn entries(&self) -> impl Iterator<Item = Entry<'_>> + '_ {
120        LogEntryIter {
121            log: self,
122            offset: 0,
123            done: false,
124        }
125    }
126
127    /// Parse an entry starting at `offset` within the log buffer.
128    fn parse_entry_at(&self, offset: usize) -> Result<Entry<'_>> {
129        if offset + ENTRY_HEADER_SIZE as usize > self.data.len() {
130            return Err(Error::LogEntryCorrupted(
131                "insufficient data for log entry header".into(),
132            ));
133        }
134        let entry_data = &self.data[offset..];
135
136        // Validate signature
137        let sig = &entry_data[0..4];
138        if sig != SIGNATURE_LOGE.into_inner().to_le_bytes() {
139            let mut found = [0u8; 4];
140            found.copy_from_slice(sig);
141            return Err(Error::InvalidSignature {
142                position: SignaturePosition::LogEntry,
143                expected: crate::error::pad_signature_4to8(
144                    SIGNATURE_LOGE.into_inner().to_le_bytes(),
145                ),
146                found: crate::error::pad_signature_4to8(found),
147            });
148        }
149
150        // Read entry_length and validate
151        let entry_length = u32_at(&entry_data[8..12])
152            .ok_or_else(|| Error::LogEntryCorrupted("entry_length read failed".into()))?;
153        if entry_length == 0 || !(entry_length as usize).is_multiple_of(SECTOR_SIZE as usize) {
154            return Err(Error::LogEntryCorrupted(format!(
155                "entry length {entry_length} is not a multiple of 4KB"
156            )));
157        }
158        let total = entry_length as usize;
159        if offset + total > self.data.len() {
160            return Err(Error::LogEntryCorrupted(format!(
161                "entry extends beyond log buffer (offset={offset}, length={total}, buf={})",
162                self.data.len()
163            )));
164        }
165
166        Ok(Entry {
167            data: &entry_data[..total],
168            assembled_sectors: OnceLock::new(),
169        })
170    }
171}
172
173/// Iterator over log entries in a sequential scan.
174struct LogEntryIter<'a> {
175    log: &'a Log<'a>,
176    offset: usize,
177    done: bool,
178}
179
180impl<'a> Iterator for LogEntryIter<'a> {
181    type Item = Entry<'a>;
182
183    fn next(&mut self) -> Option<Self::Item> {
184        if self.done || self.offset >= self.log.data.len() {
185            return None;
186        }
187        // Check if there's room for at least a header
188        if self.offset + ENTRY_HEADER_SIZE as usize > self.log.data.len() {
189            self.done = true;
190            return None;
191        }
192        let remaining = &self.log.data[self.offset..];
193        // Check signature — if not "loge", stop
194        if remaining[0..4] != SIGNATURE_LOGE.into_inner().to_le_bytes() {
195            self.done = true;
196            return None;
197        }
198        if let Ok(entry) = self.log.parse_entry_at(self.offset) {
199            let entry_length = entry.header().entry_length() as usize;
200            self.offset += entry_length;
201            Some(entry)
202        } else {
203            self.done = true;
204            None
205        }
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Entry
211// ---------------------------------------------------------------------------
212
213/// A single log entry, containing a header, descriptors, and data sectors.
214#[derive(Debug)]
215pub struct Entry<'a> {
216    /// The full entry data (header + descriptor sectors + data sectors).
217    data: &'a [u8],
218    /// Lazily initialized per-sector `OnceLock` cells. Each cell holds the full
219    /// reassembled 4096-byte sector on first access: `LeadingBytes(8) + Middle(4084) + TrailingBytes(4)`.
220    assembled_sectors: OnceLock<Vec<OnceLock<[u8; SECTOR_SIZE as usize]>>>,
221}
222
223impl<'a> Entry<'a> {
224    /// Return the entry header (first 64 bytes).
225    pub fn header(&self) -> LogEntryHeader<'_> {
226        LogEntryHeader {
227            data: &self.data[..ENTRY_HEADER_SIZE as usize],
228        }
229    }
230
231    /// Get a descriptor by index (0-based).
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the index is out of range or the descriptor is malformed.
236    pub fn descriptor(&self, index: usize) -> Result<Descriptor<'_>> {
237        let desc_count = self.header().descriptor_count() as usize;
238        if index >= desc_count {
239            return Err(Error::InvalidParameter(format!(
240                "descriptor index {index} out of range (count={desc_count})"
241            )));
242        }
243        let raw = self.descriptor_bytes(index)?;
244        let sig = &raw[0..4];
245        if sig == SIGNATURE_DESC.into_inner().to_le_bytes() {
246            Ok(Descriptor::Data(DataDescriptor { data: raw }))
247        } else if sig == SIGNATURE_ZERO.into_inner().to_le_bytes() {
248            Ok(Descriptor::Zero(ZeroDescriptor { data: raw }))
249        } else {
250            let mut found = [0u8; 4];
251            found.copy_from_slice(sig);
252            Err(Error::LogEntryCorrupted(format!(
253                "LOG_DESCRIPTOR_SIGNATURE_INVALID: unknown signature {found:?} at descriptor {index}"
254            )))
255        }
256    }
257
258    /// Iterate over all descriptors in this entry.
259    ///
260    /// Each item is validated; signature errors produce `Err`.
261    pub fn descriptors(&self) -> impl Iterator<Item = Result<Descriptor<'_>>> + '_ {
262        let count = self.header().descriptor_count() as usize;
263        (0..count).map(|i| self.descriptor(i))
264    }
265
266    /// Iterate over data sectors in this entry.
267    ///
268    /// The number of data sectors equals the number of data descriptors.
269    /// Data sectors start after all descriptor sectors.
270    ///
271    /// On first call, a Vec of empty `OnceLock` cells is initialized (one per
272    /// data descriptor). Each sector is assembled individually on first access
273    /// via `DataSector::data()`.
274    pub fn data(&self) -> impl Iterator<Item = DataSector<'_>> + '_ {
275        // Initialize the Vec of empty OnceLock cells — one per DATA descriptor
276        let caches = self.assembled_sectors.get_or_init(|| {
277            let desc_count = self.header().descriptor_count() as usize;
278            let mut data_count = 0;
279            for di in 0..desc_count {
280                if let Ok(Descriptor::Data(_)) = self.descriptor(di) {
281                    data_count += 1;
282                }
283            }
284            (0..data_count).map(|_| OnceLock::new()).collect()
285        });
286
287        let desc_count = self.header().descriptor_count() as usize;
288        // Number of descriptor sectors: first sector holds 64-byte header + 126 descriptors,
289        // subsequent sectors hold 128 descriptors each.
290        let desc_sectors = if desc_count == 0 {
291            1 // first sector still exists with header only
292        } else {
293            let after_first = desc_count.saturating_sub(126);
294            1 + after_first.div_ceil(128)
295        };
296        let data_offset = desc_sectors * SECTOR_SIZE as usize;
297        let entry_length = self.data.len();
298        let raw_data = self.data;
299
300        // Build list of (descriptor_index,) for data descriptors only
301        let mut data_indices: Vec<usize> = Vec::new();
302        for di in 0..desc_count {
303            if let Ok(Descriptor::Data(_)) = self.descriptor(di) {
304                data_indices.push(di);
305            }
306        }
307
308        data_indices
309            .into_iter()
310            .enumerate()
311            .filter_map(move |(sector_idx, di)| {
312                let sector_start = data_offset + sector_idx * SECTOR_SIZE as usize;
313                if sector_start + SECTOR_SIZE as usize > entry_length {
314                    return None;
315                }
316                let Ok(Descriptor::Data(desc)) = self.descriptor(di) else {
317                    return None;
318                };
319                Some(DataSector {
320                    data: &raw_data[sector_start..sector_start + SECTOR_SIZE as usize],
321                    leading_bytes: desc.leading_bytes_raw(),
322                    trailing_bytes: desc.trailing_bytes_raw(),
323                    cache: &caches[sector_idx],
324                })
325            })
326    }
327
328    /// Validate the CRC-32C checksum of this entry.
329    ///
330    /// The checksum covers the entire entry (per `EntryLength`), with the
331    /// Checksum field (bytes 4..8) set to zero during computation.
332    ///
333    /// This method avoids allocation by zeroing the checksum field in place
334    /// (previously used `self.data.to_vec()`).
335    ///
336    /// # Errors
337    ///
338    /// Returns `Error::InvalidChecksum` if the computed CRC-32C does not
339    /// match the stored checksum.
340    pub(crate) fn verify_checksum(&self) -> Result<()> {
341        let stored = self.header().checksum();
342        let computed = Crc32c::from_raw(crate::common::crc32c_zeroed_checksum(self.data));
343
344        if computed != stored {
345            return Err(Error::InvalidChecksum {
346                expected: stored.value(),
347                actual: computed.value(),
348            });
349        }
350        Ok(())
351    }
352
353    /// Get the raw bytes for descriptor at the given index.
354    ///
355    /// Descriptors are laid out starting at byte 64 (after the header).
356    /// First sector: bytes 64..4096 → 126 descriptors (32 bytes each).
357    /// Subsequent sectors: full 128 descriptors each.
358    fn descriptor_bytes(&self, index: usize) -> Result<&'a [u8]> {
359        let abs_offset = if index < 126 {
360            // First descriptor sector: header (64) + index * 32
361            ENTRY_HEADER_SIZE as usize + index * DESCRIPTOR_SIZE as usize
362        } else {
363            // Subsequent descriptor sectors
364            let remaining = index - 126;
365            let sector_index = remaining / 128;
366            let within_sector = remaining % 128;
367            SECTOR_SIZE as usize * (1 + sector_index) + within_sector * DESCRIPTOR_SIZE as usize
368        };
369        if abs_offset + DESCRIPTOR_SIZE as usize > self.data.len() {
370            return Err(Error::LogEntryCorrupted(format!(
371                "descriptor {index} at offset {abs_offset} extends beyond entry"
372            )));
373        }
374        Ok(&self.data[abs_offset..abs_offset + DESCRIPTOR_SIZE as usize])
375    }
376}
377
378// ---------------------------------------------------------------------------
379// LogEntryHeader
380// ---------------------------------------------------------------------------
381
382/// Log entry header (64 bytes).
383///
384/// Layout (MS-VHDX §2.3.1.1):
385/// ```text
386/// [0..4]   Signature ("loge")
387/// [4..8]   Checksum (CRC-32C)
388/// [8..12]  EntryLength
389/// [12..16] Tail
390/// [16..24] SequenceNumber
391/// [24..28] DescriptorCount
392/// [28..32] Reserved
393/// [32..48] LogGuid
394/// [48..56] FlushedFileOffset
395/// [56..64] LastFileOffset
396/// ```
397pub struct LogEntryHeader<'a> {
398    data: &'a [u8],
399}
400
401impl<'a> LogEntryHeader<'a> {
402    /// Entry signature. MUST be `"loge"` (0x65676F6C).
403    ///
404    /// # Panics
405    ///
406    /// Panics if the header slice is shorter than 4 bytes.
407    #[must_use]
408    pub fn signature(&self) -> &'a [u8; 4] {
409        self.data[0..4].try_into().expect("header is 64 bytes")
410    }
411
412    /// CRC-32C checksum computed over the entire entry (checksum field zeroed).
413    #[must_use]
414    pub fn checksum(&self) -> Crc32c {
415        Crc32c::from_raw(u32_at(&self.data[4..8]).unwrap_or(0))
416    }
417
418    /// Total length of the entry in bytes. MUST be a multiple of 4KB.
419    #[must_use]
420    pub fn entry_length(&self) -> u32 {
421        u32_at(&self.data[8..12]).unwrap_or(0)
422    }
423
424    /// Offset from log start to the first entry of the active sequence.
425    /// MUST be a multiple of 4KB.
426    #[must_use]
427    pub fn tail(&self) -> u32 {
428        u32_at(&self.data[12..16]).unwrap_or(0)
429    }
430
431    /// Monotonically increasing sequence number. MUST be > 0.
432    #[must_use]
433    pub fn sequence_number(&self) -> u64 {
434        u64_at(&self.data[16..24]).unwrap_or(0)
435    }
436
437    /// Number of descriptors in this entry.
438    #[must_use]
439    pub fn descriptor_count(&self) -> u32 {
440        u32_at(&self.data[24..28]).unwrap_or(0)
441    }
442
443    /// Reserved. MUST be 0.
444    #[must_use]
445    pub fn reserved(&self) -> u32 {
446        u32_at(&self.data[28..32]).unwrap_or(0)
447    }
448
449    /// `LogGuid` that was present in the file header when this entry was written.
450    #[must_use]
451    pub fn log_guid(&self) -> Guid {
452        let mut bytes = [0u8; 16];
453        bytes.copy_from_slice(&self.data[32..48]);
454        Guid::from_bytes(bytes)
455    }
456
457    /// VHDX file size guaranteed to be stable on disk.
458    #[must_use]
459    pub fn flushed_file_offset(&self) -> u64 {
460        u64_at(&self.data[48..56]).unwrap_or(0)
461    }
462
463    /// File size that all allocated structures fit into.
464    #[must_use]
465    pub fn last_file_offset(&self) -> u64 {
466        u64_at(&self.data[56..64]).unwrap_or(0)
467    }
468}
469
470// ---------------------------------------------------------------------------
471// Descriptor enum
472// ---------------------------------------------------------------------------
473
474/// A log entry descriptor — either a data or zero descriptor.
475///
476/// The variant is determined by the 4-byte signature:
477/// - `"desc"` → `Data(DataDescriptor)`
478/// - `"zero"` → `Zero(ZeroDescriptor)`
479/// - Any other signature is treated as corruption.
480pub enum Descriptor<'a> {
481    Data(DataDescriptor<'a>),
482    Zero(ZeroDescriptor<'a>),
483}
484
485impl Descriptor<'_> {
486    /// Return the sequence number from the descriptor.
487    #[must_use]
488    pub(crate) fn sequence_number(&self) -> u64 {
489        match self {
490            Descriptor::Data(d) => d.sequence_number(),
491            Descriptor::Zero(z) => z.sequence_number(),
492        }
493    }
494}
495
496impl fmt::Debug for Descriptor<'_> {
497    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498        match self {
499            Descriptor::Data(d) => f
500                .debug_struct("Descriptor::Data")
501                .field("file_offset", &d.file_offset())
502                .field("sequence_number", &d.sequence_number())
503                .finish(),
504            Descriptor::Zero(z) => f
505                .debug_struct("Descriptor::Zero")
506                .field("file_offset", &z.file_offset())
507                .field("zero_length", &z.zero_length())
508                .field("sequence_number", &z.sequence_number())
509                .finish(),
510        }
511    }
512}
513
514// ---------------------------------------------------------------------------
515// DataDescriptor
516// ---------------------------------------------------------------------------
517
518/// Data descriptor (32 bytes).
519///
520/// Layout (MS-VHDX §2.3.1.3):
521/// ```text
522/// [0..4]   DataSignature ("desc")
523/// [4..8]   TrailingBytes
524/// [8..16]  LeadingBytes
525/// [16..24] FileOffset
526/// [24..32] SequenceNumber
527/// ```
528pub struct DataDescriptor<'a> {
529    data: &'a [u8],
530}
531
532impl<'a> DataDescriptor<'a> {
533    /// Signature. MUST be `"desc"` (0x63736564).
534    ///
535    /// # Panics
536    ///
537    /// Panics if the descriptor slice is shorter than 4 bytes.
538    #[must_use]
539    pub fn signature(&self) -> &'a [u8; 4] {
540        self.data[0..4].try_into().expect("descriptor is 32 bytes")
541    }
542
543    /// Trailing 4 bytes removed from the 4KB sector update.
544    #[must_use]
545    pub fn trailing_bytes(&self) -> u32 {
546        u32_at(&self.data[4..8]).unwrap_or(0)
547    }
548
549    /// Leading 8 bytes removed from the 4KB sector update.
550    #[must_use]
551    pub fn leading_bytes(&self) -> u64 {
552        u64_at(&self.data[8..16]).unwrap_or(0)
553    }
554
555    /// File offset where the data must be written. MUST be 4KB aligned.
556    #[must_use]
557    pub fn file_offset(&self) -> u64 {
558        u64_at(&self.data[16..24]).unwrap_or(0)
559    }
560
561    /// MUST match the entry header's `SequenceNumber`.
562    #[must_use]
563    pub fn sequence_number(&self) -> u64 {
564        u64_at(&self.data[24..32]).unwrap_or(0)
565    }
566
567    /// Return the leading bytes as a raw slice (8 bytes).
568    #[must_use]
569    pub(crate) fn leading_bytes_raw(&self) -> &'a [u8] {
570        &self.data[8..16]
571    }
572
573    /// Return the trailing bytes as a raw slice (4 bytes).
574    #[must_use]
575    pub(crate) fn trailing_bytes_raw(&self) -> &'a [u8] {
576        &self.data[4..8]
577    }
578}
579
580// ---------------------------------------------------------------------------
581// ZeroDescriptor
582// ---------------------------------------------------------------------------
583
584/// Zero descriptor (32 bytes).
585///
586/// Layout (MS-VHDX §2.3.1.2):
587/// ```text
588/// [0..4]   ZeroSignature ("zero")
589/// [4..8]   Reserved
590/// [8..16]  ZeroLength
591/// [16..24] FileOffset
592/// [24..32] SequenceNumber
593/// ```
594pub struct ZeroDescriptor<'a> {
595    data: &'a [u8],
596}
597
598impl<'a> ZeroDescriptor<'a> {
599    /// Signature. MUST be `"zero"` (0x6F72657A).
600    ///
601    /// # Panics
602    ///
603    /// Panics if the descriptor slice is shorter than 4 bytes.
604    #[must_use]
605    pub fn signature(&self) -> &'a [u8; 4] {
606        self.data[0..4].try_into().expect("descriptor is 32 bytes")
607    }
608
609    /// Reserved. MUST be 0.
610    #[must_use]
611    pub fn reserved(&self) -> u32 {
612        u32_at(&self.data[4..8]).unwrap_or(0)
613    }
614
615    /// Length of the section to zero. MUST be 4KB aligned.
616    #[must_use]
617    pub fn zero_length(&self) -> u64 {
618        u64_at(&self.data[8..16]).unwrap_or(0)
619    }
620
621    /// File offset to zero. MUST be 4KB aligned.
622    #[must_use]
623    pub fn file_offset(&self) -> u64 {
624        u64_at(&self.data[16..24]).unwrap_or(0)
625    }
626
627    /// MUST match the entry header's `SequenceNumber`.
628    #[must_use]
629    pub fn sequence_number(&self) -> u64 {
630        u64_at(&self.data[24..32]).unwrap_or(0)
631    }
632}
633
634// ---------------------------------------------------------------------------
635// DataSector
636// ---------------------------------------------------------------------------
637
638/// Data sector (4096 bytes).
639///
640/// Layout (MS-VHDX §2.3.1.4):
641/// ```text
642/// [0..4]    DataSignature ("data")
643/// [4..8]    SequenceHigh (high 4 bytes of SequenceNumber)
644/// [8..4092] Data (4084 bytes — middle portion of original sector)
645/// [4092..4096] SequenceLow (low 4 bytes of SequenceNumber)
646/// ```
647pub struct DataSector<'a> {
648    /// Raw sector bytes in the log buffer (4096 bytes).
649    pub(super) data: &'a [u8],
650    /// Leading 8 bytes from the data descriptor.
651    leading_bytes: &'a [u8],
652    /// Trailing 4 bytes from the data descriptor.
653    trailing_bytes: &'a [u8],
654    /// Per-sector lazy cache for the assembled 4096-byte sector.
655    cache: &'a OnceLock<[u8; SECTOR_SIZE as usize]>,
656}
657
658impl<'a> DataSector<'a> {
659    /// Signature. MUST be `"data"` (0x61746164).
660    ///
661    /// # Panics
662    ///
663    /// Panics if the data sector slice is shorter than 4 bytes.
664    #[must_use]
665    pub fn signature(&self) -> &'a [u8; 4] {
666        self.data[0..4]
667            .try_into()
668            .expect("data sector is 4096 bytes")
669    }
670
671    /// The reconstructed full 64-bit sequence number.
672    #[must_use]
673    pub fn sequence_number(&self) -> u64 {
674        let high = u32::from_le_bytes(self.data[4..8].try_into().unwrap_or([0; 4]));
675        let low = u32::from_le_bytes(self.data[4092..4096].try_into().unwrap_or([0; 4]));
676        (u64::from(high) << 32) | u64::from(low)
677    }
678
679    /// Return the assembled full 4096-byte sector.
680    ///
681    /// The assembled data is: `LeadingBytes(8B) + middle(4084B) + TrailingBytes(4B)`.
682    /// Lazily assembled on first access via per-sector `OnceLock` cache.
683    #[must_use]
684    pub fn data(&self) -> Cow<'a, [u8]> {
685        let assembled = self.cache.get_or_init(|| {
686            let mut buf = [0u8; SECTOR_SIZE as usize];
687            buf[0..8].copy_from_slice(&self.leading_bytes[..8]);
688            buf[8..4092].copy_from_slice(&self.data[8..4092]);
689            buf[4092..4096].copy_from_slice(&self.trailing_bytes[..4]);
690            buf
691        });
692        Cow::Borrowed(assembled)
693    }
694}
695
696// ---------------------------------------------------------------------------
697// DataSectorAssembly — helper for assembling full 4096-byte sectors
698// ---------------------------------------------------------------------------
699
700/// An assembled data sector containing the full 4096 bytes.
701///
702/// Created by pairing a `DataSector` with a `DataDescriptor` to reconstruct
703/// the original sector: `LeadingBytes(8) + Data(4084) + TrailingBytes(4)`.
704///
705/// This type is kept `pub(crate)` for cross-validation in tests only.
706/// Normal code should use `DataSector::data()` which returns a borrowed
707/// view into the Entry's lazily-assembled buffer.
708#[cfg(test)]
709pub(crate) struct DataSectorAssembly {
710    buf: [u8; SECTOR_SIZE as usize],
711}
712
713#[cfg(test)]
714impl DataSectorAssembly {
715    /// Assemble a full 4096-byte sector from a data sector and its descriptor.
716    ///
717    /// The assembled data is: `LeadingBytes(8B) + DataSector middle(4084B) + TrailingBytes(4B)`.
718    pub fn new(descriptor: &DataDescriptor<'_>, sector: &DataSector<'_>) -> Self {
719        let mut buf = [0u8; SECTOR_SIZE as usize];
720        // Leading 8 bytes
721        buf[0..8].copy_from_slice(descriptor.leading_bytes_raw());
722        // Middle 4084 bytes
723        buf[8..4092].copy_from_slice(&sector.data[8..4092]);
724        // Trailing 4 bytes
725        buf[4092..4096].copy_from_slice(descriptor.trailing_bytes_raw());
726        Self { buf }
727    }
728
729    /// Return the assembled 4096-byte sector.
730    #[must_use]
731    pub fn data(&self) -> &[u8] {
732        &self.buf
733    }
734}
735
736// ---------------------------------------------------------------------------
737// Helpers
738// ---------------------------------------------------------------------------
739
740/// Read a little-endian u32 from a 4-byte slice.
741fn u32_at(buf: &[u8]) -> Option<u32> {
742    if buf.len() < 4 {
743        return None;
744    }
745    Some(u32::from_le_bytes(buf[..4].try_into().unwrap()))
746}
747
748/// Read a little-endian u64 from an 8-byte slice.
749fn u64_at(buf: &[u8]) -> Option<u64> {
750    if buf.len() < 8 {
751        return None;
752    }
753    Some(u64::from_le_bytes(buf[..8].try_into().unwrap()))
754}