Skip to main content

filevault/
lib.rs

1//! # filevault — Apple CoreStorage / FileVault 2 (FVDE) reader & decryptor
2//!
3//! A panic-free, bounds-checked decryptor for CoreStorage / FileVault 2 volumes
4//! (macOS 10.7–10.15, AES-XTS-128, password protector). Parses the physical
5//! volume header, decrypts the CoreStorage metadata, derives the volume key
6//! hierarchy from a password (PBKDF2-HMAC-SHA256 → RFC 3394 AES-KW → SHA-256
7//! tweak key, all RustCrypto — never hand-rolled), and exposes the plaintext
8//! logical volume as a `Read + Seek` stream.
9//!
10//! APFS-native encryption (10.13+) is a separate format and is out of scope.
11//!
12//! ```no_run
13//! use std::fs::File;
14//! use filevault::FileVaultVolume;
15//!
16//! let image = File::open("cs_partition.raw")?;
17//! let mut volume = FileVaultVolume::unlock_with_password(image, "fvde-TEST")?;
18//! let mut sector = [0u8; 512];
19//! volume.read_at(0, &mut sector)?;
20//! # Ok::<(), filevault::FileVaultError>(())
21//! ```
22
23#![forbid(unsafe_code)]
24// `doc_markdown` false-positives on the domain proper nouns that pervade these
25// docs (CoreStorage, FileVault, FVDE, PBKDF2, RustCrypto, UserType, …).
26#![allow(clippy::doc_markdown)]
27#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
28
29use std::io::{Read, Seek, SeekFrom};
30
31pub mod context;
32pub mod error;
33pub mod metadata;
34pub mod read;
35pub mod unlock;
36pub mod volume;
37pub mod volume_header;
38pub mod xts;
39
40pub use context::{EncryptionContext, LogicalVolumeInfo, Protector, ProtectorKind};
41pub use error::FileVaultError;
42pub use volume::DecryptedVolume;
43pub use volume_header::VolumeHeader;
44
45/// Physical volume header length.
46const VOLUME_HEADER_LEN: usize = 512;
47
48/// Password-independent metadata parsed from a CoreStorage volume.
49///
50/// Everything here is derivable WITHOUT the password, so `filevault-forensic`
51/// can audit protector inventory, encryption state, and KDF strength on a locked
52/// volume. Producing it never unwraps any key.
53#[derive(Debug, Clone)]
54pub struct FileVaultInfo {
55    /// Physical volume identifier (UUID string).
56    pub physical_volume_identifier: String,
57    /// PBKDF2 iteration count of the password protector.
58    pub pbkdf2_iterations: u32,
59    /// PBKDF2 salt (16 bytes).
60    pub pbkdf2_salt: [u8; 16],
61    /// Logical volume family UUID (string).
62    pub family_uuid: String,
63    /// Logical volume identifier UUID (string), if present.
64    pub lv_identifier: Option<String>,
65    /// Logical volume name, if present.
66    pub lv_name: Option<String>,
67    /// Logical volume size in bytes.
68    pub lv_size: u64,
69    /// Encryption method label (always AES-XTS-128 for a supported volume).
70    pub encryption_method: &'static str,
71    /// LV conversion status (`Complete` / `Converting` / …), if present.
72    pub conversion_status: Option<String>,
73    /// Protectors (crypto users) present.
74    pub protectors: Vec<Protector>,
75}
76
77/// An unlocked CoreStorage / FileVault volume presenting the decrypted logical
78/// volume as a `Read + Seek` stream.
79#[derive(Debug)]
80pub struct FileVaultVolume<R: Read + Seek> {
81    decrypted: DecryptedVolume<R>,
82    info: FileVaultInfo,
83}
84
85impl<R: Read + Seek> FileVaultVolume<R> {
86    /// Parse, decrypt the metadata, derive the key hierarchy from `password`,
87    /// and return an unlocked volume.
88    ///
89    /// # Errors
90    /// Any [`FileVaultError`] from parsing (not CoreStorage, unsupported method,
91    /// missing metadata) or key derivation (wrong password → `KeyUnwrap`).
92    pub fn unlock_with_password(mut reader: R, password: &str) -> Result<Self, FileVaultError> {
93        let mut header_bytes = [0u8; VOLUME_HEADER_LEN];
94        reader.seek(SeekFrom::Start(0))?;
95        read_exact_or_err(&mut reader, &mut header_bytes)?;
96        let header = VolumeHeader::parse(&header_bytes)?;
97
98        let plaintext = decrypt_metadata_region(&mut reader, &header)?;
99        let context = EncryptionContext::extract(&plaintext)?;
100        let lv = LogicalVolumeInfo::extract(&plaintext)?;
101
102        let family_uuid_bytes =
103            parse_uuid_bytes(&lv.family_uuid).ok_or(FileVaultError::MetadataStructureMissing {
104                what: "parseable family UUID",
105            })?;
106        let keys = unlock::derive_volume_keys(password, &context, &family_uuid_bytes)?;
107
108        let (physical_base, size) = resolve_lv_extent(&plaintext, &header, &lv);
109
110        let info = build_info(&header, &context, &lv, size);
111        let decrypted = DecryptedVolume::new(reader, keys, physical_base, size);
112        Ok(FileVaultVolume { decrypted, info })
113    }
114
115    /// Read and decrypt `buf.len()` bytes at logical `offset` (see
116    /// [`DecryptedVolume::read_at`]).
117    ///
118    /// # Errors
119    /// [`FileVaultError::Io`] if the underlying read fails.
120    pub fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<usize, FileVaultError> {
121        self.decrypted.read_at(offset, buf)
122    }
123
124    /// The logical volume size in bytes.
125    #[must_use]
126    pub fn size(&self) -> u64 {
127        self.decrypted.size()
128    }
129
130    /// The password-independent parsed metadata.
131    #[must_use]
132    pub fn info(&self) -> &FileVaultInfo {
133        &self.info
134    }
135
136    /// Consume the volume, returning the inner decrypted `Read + Seek` stream.
137    #[must_use]
138    pub fn into_decrypted(self) -> DecryptedVolume<R> {
139        self.decrypted
140    }
141}
142
143impl<R: Read + Seek> Read for FileVaultVolume<R> {
144    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
145        self.decrypted.read(buf)
146    }
147}
148
149impl<R: Read + Seek> Seek for FileVaultVolume<R> {
150    fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
151        self.decrypted.seek(pos)
152    }
153}
154
155/// Parse only the password-independent metadata from a reader, WITHOUT the
156/// password — the entry point `filevault-forensic` uses to audit a locked volume.
157///
158/// # Errors
159/// Any [`FileVaultError`] from header/metadata parsing.
160pub fn parse_info<R: Read + Seek>(mut reader: R) -> Result<FileVaultInfo, FileVaultError> {
161    let mut header_bytes = [0u8; VOLUME_HEADER_LEN];
162    reader.seek(SeekFrom::Start(0))?;
163    read_exact_or_err(&mut reader, &mut header_bytes)?;
164    let header = VolumeHeader::parse(&header_bytes)?;
165
166    let plaintext = decrypt_metadata_region(&mut reader, &header)?;
167    let context = EncryptionContext::extract(&plaintext)?;
168    let lv = LogicalVolumeInfo::extract(&plaintext)?;
169    let (_, size) = resolve_lv_extent(&plaintext, &header, &lv);
170    Ok(build_info(&header, &context, &lv, size))
171}
172
173/// Read the plaintext metadata region, then read and decrypt the encrypted
174/// metadata region, returning the decrypted metadata bytes.
175fn decrypt_metadata_region<R: Read + Seek>(
176    reader: &mut R,
177    header: &VolumeHeader,
178) -> Result<Vec<u8>, FileVaultError> {
179    let (region_offset, _) = metadata::plaintext_metadata_region(header);
180
181    // Read the first plaintext block to learn the region size.
182    let block_size = header.block_size as usize;
183    if block_size == 0 {
184        return Err(FileVaultError::OutOfRange {
185            what: "block size is zero",
186        });
187    }
188    let mut first_block = vec![0u8; block_size];
189    reader.seek(SeekFrom::Start(region_offset))?;
190    read_exact_or_err(reader, &mut first_block)?;
191
192    let region_size = metadata::plaintext_metadata_size(header, &first_block)?;
193    let mut region = vec![0u8; region_size as usize];
194    reader.seek(SeekFrom::Start(region_offset))?;
195    read_exact_or_err(reader, &mut region)?;
196
197    let location = metadata::locate_encrypted_metadata(header, &region)?;
198    let mut ciphertext = vec![0u8; location.length as usize];
199    reader.seek(SeekFrom::Start(location.primary_offset))?;
200    read_exact_or_err(reader, &mut ciphertext)?;
201
202    metadata::decrypt_metadata(header, &mut ciphertext);
203    Ok(ciphertext)
204}
205
206/// Derive the LV physical base and size. Prefer the parsed `0x0305` segment
207/// descriptor (a single contiguous segment); fall back to the plist LV size for
208/// the size when the segment map is absent.
209fn resolve_lv_extent(
210    metadata_bytes: &[u8],
211    header: &VolumeHeader,
212    lv: &LogicalVolumeInfo,
213) -> (u64, u64) {
214    let block_size = u64::from(header.block_size);
215    let segments = metadata::parse_segments(metadata_bytes, header.block_size as usize);
216    if let Some(first) = segments.first() {
217        let base = first.physical_block.saturating_mul(block_size);
218        let size = if lv.size != 0 {
219            lv.size
220        } else {
221            u64::from(first.number_of_blocks).saturating_mul(block_size)
222        };
223        (base, size)
224    } else {
225        (0, lv.size)
226    }
227}
228
229/// Assemble the password-independent [`FileVaultInfo`].
230fn build_info(
231    header: &VolumeHeader,
232    context: &EncryptionContext,
233    lv: &LogicalVolumeInfo,
234    size: u64,
235) -> FileVaultInfo {
236    FileVaultInfo {
237        physical_volume_identifier: format_uuid(&header.physical_volume_identifier),
238        pbkdf2_iterations: context.iterations,
239        pbkdf2_salt: context.salt,
240        family_uuid: lv.family_uuid.clone(),
241        lv_identifier: lv.lv_identifier.clone(),
242        lv_name: lv.name.clone(),
243        lv_size: size,
244        encryption_method: "AES-XTS-128",
245        conversion_status: context.conversion_status.clone(),
246        protectors: context.protectors.clone(),
247    }
248}
249
250/// Read exactly `buf.len()` bytes, erroring loudly on a short read (a truncated
251/// image is a bootstrap failure, never a silent empty result).
252fn read_exact_or_err<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), FileVaultError> {
253    reader.read_exact(buf).map_err(FileVaultError::Io)
254}
255
256/// Format 16 on-disk UUID bytes as the canonical `8-4-4-4-12` string.
257fn format_uuid(bytes: &[u8; 16]) -> String {
258    uuid::Uuid::from_bytes(*bytes).hyphenated().to_string()
259}
260
261/// Parse a UUID string to its 16 canonical bytes (as used for the tweak key).
262fn parse_uuid_bytes(text: &str) -> Option<[u8; 16]> {
263    uuid::Uuid::parse_str(text).ok().map(|u| *u.as_bytes())
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use aes_kw::KekAes128;
270    use base64::engine::general_purpose::STANDARD as B64;
271    use base64::Engine;
272    use hmac::Hmac;
273    use pbkdf2::pbkdf2;
274    use sha2::{Digest, Sha256};
275    use std::io::Cursor;
276
277    const BLOCK: usize = 4096;
278    const PASSWORD: &str = "synthetic-pass";
279    const SALT: [u8; 16] = [0xAB; 16];
280    const ITERS: u32 = 1000;
281    const KEK: [u8; 16] = [0x10; 16];
282    const VMK: [u8; 16] = [0x20; 16];
283    const KEY1: [u8; 16] = [0x30; 16]; // metadata XTS key1
284    const PV_ID: [u8; 16] = [0x40; 16]; // metadata XTS key2
285    const FAMILY: [u8; 16] = [
286        0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
287        0x10,
288    ];
289    const LV_PHYS_BLOCK: u64 = 16; // LV base = 16 * 4096 = 0x10000
290    const LV_BLOCKS: u32 = 4; // 4 * 4096 = 16384 bytes
291
292    fn wrap(kek: &[u8; 16], key: &[u8; 16]) -> [u8; 24] {
293        let k = KekAes128::from(*kek);
294        let mut out = [0u8; 24];
295        k.wrap(key, &mut out).unwrap();
296        out
297    }
298
299    fn set_block_type(block: &mut [u8], t: u16) {
300        block[10..12].copy_from_slice(&t.to_le_bytes());
301    }
302
303    /// Build a decrypted metadata region: block 0 = context plist (0x0013),
304    /// block 1 = 0x001a familyUUID+size, block 2 = 0x0305 segment descriptor.
305    fn build_decrypted_metadata() -> Vec<u8> {
306        let passphrase_key = {
307            let mut pk = [0u8; 16];
308            pbkdf2::<Hmac<Sha256>>(PASSWORD.as_bytes(), &SALT, ITERS, &mut pk).unwrap();
309            pk
310        };
311        let wrapped_kek = wrap(&passphrase_key, &KEK);
312        let wrapped_vmk = wrap(&KEK, &VMK);
313
314        // PassphraseWrappedKEKStruct (284 bytes): salt@8, wrappedKEK@32, iters@168.
315        let mut pw = vec![0u8; 284];
316        pw[8..24].copy_from_slice(&SALT);
317        pw[32..56].copy_from_slice(&wrapped_kek);
318        pw[168..172].copy_from_slice(&ITERS.to_le_bytes());
319        // KEKWrappedVolumeKeyStruct (256): wrappedVMK@8.
320        let mut kekw = vec![0u8; 256];
321        kekw[8..32].copy_from_slice(&wrapped_vmk);
322
323        let plist = format!(
324            "<dict ID=\"0\"><key>CryptoUsers</key><array ID=\"2\"><dict ID=\"3\">\
325             <key>PassphraseWrappedKEKStruct</key><data ID=\"4\">{}</data>\
326             <key>UserType</key><integer size=\"32\" ID=\"5\">0x10000001</integer>\
327             <key>BlockAlgorithm</key><string ID=\"6\">AES-XTS</string>\
328             <key>KEKWrappedVolumeKeyStruct</key><data ID=\"7\">{}</data>\
329             </dict></array>\
330             <key>ConversionStatus</key><string ID=\"8\">Complete</string></dict>",
331            B64.encode(&pw),
332            B64.encode(&kekw),
333        );
334
335        let mut meta = vec![0u8; BLOCK * 3];
336        // Block 0: context plist.
337        set_block_type(&mut meta[0..BLOCK], 0x0013);
338        let body = plist.as_bytes();
339        meta[64..64 + body.len()].copy_from_slice(body);
340
341        // Block 1: 0x001a familyUUID + name + uuid + size.
342        set_block_type(&mut meta[BLOCK..2 * BLOCK], 0x001a);
343        let fam_uuid = uuid::Uuid::from_bytes(FAMILY).hyphenated().to_string();
344        let lv_plist = format!(
345            "<dict ID=\"0\">\
346             <key>com.apple.corestorage.lv.familyUUID</key><string ID=\"1\">{fam_uuid}</string>\
347             <key>com.apple.corestorage.lv.name</key><string ID=\"2\">SynthLV</string>\
348             <key>com.apple.corestorage.lv.uuid</key><string ID=\"3\">00000000-0000-0000-0000-000000000001</string>\
349             <key>com.apple.corestorage.lv.size</key><integer size=\"64\" ID=\"4\">0x4000</integer></dict>"
350        );
351        let lb = lv_plist.as_bytes();
352        meta[BLOCK + 64..BLOCK + 64 + lb.len()].copy_from_slice(lb);
353
354        // Block 2: 0x0305 segment descriptor (1 entry).
355        let base = 2 * BLOCK;
356        set_block_type(&mut meta[base..base + BLOCK], 0x0305);
357        let payload = base + 64;
358        meta[payload..payload + 4].copy_from_slice(&1u32.to_le_bytes());
359        let entry = payload + 8;
360        meta[entry + 8..entry + 16].copy_from_slice(&0u64.to_le_bytes()); // logical block
361        meta[entry + 16..entry + 20].copy_from_slice(&LV_BLOCKS.to_le_bytes());
362        let phys = LV_PHYS_BLOCK | (0x99u64 << 48);
363        meta[entry + 32..entry + 40].copy_from_slice(&phys.to_le_bytes());
364        meta
365    }
366
367    /// Build a whole synthetic CoreStorage image (header + plaintext metadata +
368    /// encrypted metadata + encrypted LV).
369    fn build_image() -> Vec<u8> {
370        // Layout (blocks): 0 header-block region, plaintext meta region at block 1,
371        // encrypted meta at block 8, LV at block 16.
372        let enc_meta_block: u64 = 8;
373        let plaintext_meta_block: u64 = 1;
374
375        // Decrypt-then-encrypt the metadata for the encrypted region.
376        let plain_meta = build_decrypted_metadata();
377        let mut enc_meta = plain_meta.clone();
378        crate::xts::encrypt_units(&mut enc_meta, &KEY1, &PV_ID, 8192, 0);
379
380        // Plaintext metadata region (one block): 0x0011 pointer.
381        let meta_size = BLOCK as u32; // region = 1 block
382        let mut region = vec![0u8; BLOCK];
383        set_block_type(&mut region, 0x0011);
384        region[64..68].copy_from_slice(&meta_size.to_le_bytes()); // metadata_size@payload 0
385                                                                  // Descriptor offset (region-relative). Put descriptor at payload 200.
386        let desc_off = 200u32;
387        region[64 + 156..64 + 160].copy_from_slice(&desc_off.to_le_bytes());
388        let d = desc_off as usize;
389        region[d + 8..d + 16]
390            .copy_from_slice(&(plain_meta.len() as u64 / BLOCK as u64).to_le_bytes()); // count
391        region[d + 32..d + 40].copy_from_slice(&enc_meta_block.to_le_bytes()); // primary block
392
393        // LV plaintext -> encrypt with VMK/tweak_key.
394        let tweak_key = {
395            let mut h = Sha256::new();
396            h.update(VMK);
397            h.update(FAMILY);
398            let dg = h.finalize();
399            let mut tk = [0u8; 16];
400            tk.copy_from_slice(&dg[..16]);
401            tk
402        };
403        let lv_len = (LV_BLOCKS as usize) * BLOCK;
404        let lv_plain: Vec<u8> = (0..lv_len).map(|i| (i & 0xff) as u8).collect();
405        let mut lv_cipher = lv_plain.clone();
406        crate::xts::encrypt_units(&mut lv_cipher, &VMK, &tweak_key, 512, 0);
407
408        // Assemble the image.
409        let lv_block: u64 = LV_PHYS_BLOCK;
410        let total = (lv_block as usize + LV_BLOCKS as usize) * BLOCK;
411        let mut image = vec![0u8; total];
412
413        // Header at block 0.
414        image[88] = b'C';
415        image[89] = b'S';
416        image[48..52].copy_from_slice(&512u32.to_le_bytes());
417        image[96..100].copy_from_slice(&(BLOCK as u32).to_le_bytes());
418        image[64..72].copy_from_slice(&(total as u64).to_le_bytes());
419        image[172..176].copy_from_slice(&2u32.to_le_bytes());
420        image[104..112].copy_from_slice(&plaintext_meta_block.to_le_bytes());
421        image[176..192].copy_from_slice(&KEY1);
422        image[304..320].copy_from_slice(&PV_ID);
423
424        // Plaintext metadata region at block 1.
425        let po = plaintext_meta_block as usize * BLOCK;
426        image[po..po + region.len()].copy_from_slice(&region);
427        // Encrypted metadata at block 8.
428        let eo = enc_meta_block as usize * BLOCK;
429        image[eo..eo + enc_meta.len()].copy_from_slice(&enc_meta);
430        // LV at block 16.
431        let lo = lv_block as usize * BLOCK;
432        image[lo..lo + lv_cipher.len()].copy_from_slice(&lv_cipher);
433
434        image
435    }
436
437    #[test]
438    fn unlock_synthetic_volume_end_to_end() {
439        let image = build_image();
440        let mut vol = FileVaultVolume::unlock_with_password(Cursor::new(image), PASSWORD).unwrap();
441        assert_eq!(vol.size(), 0x4000);
442        assert_eq!(vol.info().pbkdf2_iterations, ITERS);
443        assert_eq!(vol.info().lv_name.as_deref(), Some("SynthLV"));
444        assert_eq!(vol.info().encryption_method, "AES-XTS-128");
445        assert_eq!(vol.info().conversion_status.as_deref(), Some("Complete"));
446        assert_eq!(vol.info().protectors.len(), 1);
447
448        // Decrypt LV offset 0 -> known plaintext (0,1,2,...).
449        let mut buf = [0u8; 512];
450        assert_eq!(vol.read_at(0, &mut buf).unwrap(), 512);
451        let expected: Vec<u8> = (0..512).map(|i| (i & 0xff) as u8).collect();
452        assert_eq!(&buf[..], &expected[..]);
453
454        // Read + Seek passthrough.
455        assert_eq!(vol.seek(SeekFrom::Start(512)).unwrap(), 512);
456        let mut b2 = [0u8; 16];
457        assert!(std::io::Read::read(&mut vol, &mut b2).unwrap() > 0);
458
459        // parse_info (no password) matches.
460        let image2 = build_image();
461        let info = parse_info(Cursor::new(image2)).unwrap();
462        assert_eq!(info.family_uuid, vol.info().family_uuid);
463        assert_eq!(info.lv_size, 0x4000);
464        assert_eq!(
465            info.physical_volume_identifier,
466            uuid::Uuid::from_bytes(PV_ID).hyphenated().to_string()
467        );
468
469        // into_decrypted exposes the stream.
470        let mut dec = vol.into_decrypted();
471        assert_eq!(dec.size(), 0x4000);
472        let mut b3 = [0u8; 16];
473        assert_eq!(dec.read_at(0, &mut b3).unwrap(), 16);
474    }
475
476    #[test]
477    fn unlock_wrong_password_fails() {
478        let image = build_image();
479        assert!(matches!(
480            FileVaultVolume::unlock_with_password(Cursor::new(image), "nope"),
481            Err(FileVaultError::KeyUnwrap { .. })
482        ));
483    }
484
485    #[test]
486    fn unlock_non_corestorage_fails() {
487        let image = vec![0u8; 4096];
488        assert!(matches!(
489            FileVaultVolume::unlock_with_password(Cursor::new(image), PASSWORD),
490            Err(FileVaultError::NotCoreStorage { .. })
491        ));
492    }
493
494    #[test]
495    fn truncated_image_errors_loudly() {
496        // A header-only image: metadata read runs past EOF -> Io error, not empty.
497        let mut image = vec![0u8; 600];
498        image[88] = b'C';
499        image[89] = b'S';
500        image[96..100].copy_from_slice(&(BLOCK as u32).to_le_bytes());
501        image[172..176].copy_from_slice(&2u32.to_le_bytes());
502        image[104..112].copy_from_slice(&1u64.to_le_bytes());
503        let err = parse_info(Cursor::new(image)).unwrap_err();
504        assert!(matches!(err, FileVaultError::Io(_)));
505    }
506
507    #[test]
508    fn zero_block_size_header_errors() {
509        // block_size 0 in the header -> decrypt_metadata_region OutOfRange.
510        let mut image = vec![0u8; 4096];
511        image[88] = b'C';
512        image[89] = b'S';
513        image[96..100].copy_from_slice(&0u32.to_le_bytes());
514        image[172..176].copy_from_slice(&2u32.to_le_bytes());
515        let err = parse_info(Cursor::new(image)).unwrap_err();
516        assert!(matches!(err, FileVaultError::OutOfRange { .. }));
517    }
518
519    #[test]
520    fn format_and_parse_uuid_roundtrip() {
521        let s = format_uuid(&FAMILY);
522        assert_eq!(parse_uuid_bytes(&s), Some(FAMILY));
523        assert_eq!(parse_uuid_bytes("not-a-uuid"), None);
524    }
525
526    #[test]
527    fn resolve_extent_uses_segment_blocks_when_lv_size_zero() {
528        // Segment present, but lv.size == 0 -> size derived from segment blocks.
529        let header = VolumeHeader {
530            block_size: BLOCK as u32,
531            bytes_per_sector: 512,
532            physical_volume_size: 0,
533            metadata_block_numbers: [1, 0, 0, 0],
534            key_data: KEY1,
535            physical_volume_identifier: PV_ID,
536        };
537        // Metadata with a single 0x0305 segment (16 blocks) and no lv.size key.
538        let mut meta = vec![0u8; BLOCK];
539        meta[10..12].copy_from_slice(&0x0305u16.to_le_bytes());
540        let payload = 64;
541        meta[payload..payload + 4].copy_from_slice(&1u32.to_le_bytes());
542        let entry = payload + 8;
543        meta[entry + 16..entry + 20].copy_from_slice(&2u32.to_le_bytes());
544        meta[entry + 32..entry + 40].copy_from_slice(&5u64.to_le_bytes());
545        let lv = LogicalVolumeInfo {
546            size: 0,
547            family_uuid: "x".to_string(),
548            lv_identifier: None,
549            name: None,
550        };
551        let (base, size) = resolve_lv_extent(&meta, &header, &lv);
552        assert_eq!(base, 5 * BLOCK as u64);
553        assert_eq!(size, 2 * BLOCK as u64);
554    }
555
556    #[test]
557    fn resolve_extent_no_segment_falls_back_to_lv_size() {
558        let header = VolumeHeader {
559            block_size: BLOCK as u32,
560            bytes_per_sector: 512,
561            physical_volume_size: 0,
562            metadata_block_numbers: [1, 0, 0, 0],
563            key_data: KEY1,
564            physical_volume_identifier: PV_ID,
565        };
566        let meta = vec![0u8; BLOCK]; // no 0x0305 block
567        let lv = LogicalVolumeInfo {
568            size: 12345,
569            family_uuid: "x".to_string(),
570            lv_identifier: None,
571            name: None,
572        };
573        let (base, size) = resolve_lv_extent(&meta, &header, &lv);
574        assert_eq!(base, 0);
575        assert_eq!(size, 12345);
576    }
577}